@barefootjs/jsx 0.31.1 → 0.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1531 -1155
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +8 -3
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/scope/binding-scope.d.ts +179 -0
  35. package/dist/scope/binding-scope.d.ts.map +1 -0
  36. package/dist/ssr-defaults.d.ts +43 -0
  37. package/dist/ssr-defaults.d.ts.map +1 -1
  38. package/dist/template-parts.d.ts +53 -0
  39. package/dist/template-parts.d.ts.map +1 -0
  40. package/dist/types.d.ts +18 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/__tests__/adapter-output.test.ts +8 -4
  44. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  45. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  46. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  47. package/src/__tests__/binding-scope.test.ts +200 -0
  48. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  49. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  50. package/src/__tests__/let-type-annotation.test.ts +208 -0
  51. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  52. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  53. package/src/__tests__/ssr-defaults.test.ts +124 -1
  54. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  55. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  56. package/src/adapters/interface.ts +11 -0
  57. package/src/adapters/jsx-adapter.ts +295 -5
  58. package/src/adapters/test-adapter.ts +13 -10
  59. package/src/analyzer.ts +70 -11
  60. package/src/compiler.ts +119 -18
  61. package/src/css-layer-prefixer.ts +80 -24
  62. package/src/errors.ts +18 -0
  63. package/src/html-types.ts +24 -0
  64. package/src/index.ts +11 -1
  65. package/src/ir-to-client-js/collect-elements.ts +4 -1
  66. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  67. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  68. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  69. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  70. package/src/ir-to-client-js/utils.ts +5 -24
  71. package/src/jsx-runtime/index.ts +2 -7
  72. package/src/jsx-to-ir.ts +245 -107
  73. package/src/module-exports.ts +11 -2
  74. package/src/prop-rewrite.ts +26 -6
  75. package/src/props-binding.ts +70 -0
  76. package/src/relocate.ts +19 -2
  77. package/src/scope/binding-scope.ts +238 -0
  78. package/src/ssr-defaults.ts +70 -0
  79. package/src/template-parts.ts +81 -0
  80. package/src/types.ts +18 -0
@@ -14,7 +14,7 @@
14
14
  * stack, so `items.map((title) => title.a)` never turns into the
15
15
  * syntactically invalid `.map((_p.title) => _p.title.a)` when `title`
16
16
  * is also a prop. (Names bound by loop callbacks that ENCLOSE the
17
- * expression are the caller's job — see the `ctx.loopParams` filter in
17
+ * expression are the caller's job — see the `ctx.scope` filter in
18
18
  * `jsx-to-ir.ts`'s `rewriteBarePropRefs` wrapper, #2222.)
19
19
  */
20
20
 
@@ -135,7 +135,11 @@ export function collectAstPropRefs(
135
135
  * Returns null when `text` does not parse cleanly as an expression —
136
136
  * the caller falls back to the legacy regex rewrite.
137
137
  */
138
- function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string | null {
138
+ function applyScopedPropRefRewrite(
139
+ text: string,
140
+ propRefs: Set<string>,
141
+ propAliases?: ReadonlyMap<string, string>,
142
+ ): string | null {
139
143
  // Wrap in parens so object literals and arrows parse as expressions.
140
144
  const prefix = '('
141
145
  const sf = ts.createSourceFile('__bf_prop_rewrite.ts', `${prefix}${text}\n)`, ts.ScriptTarget.Latest, true)
@@ -149,11 +153,15 @@ function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string
149
153
  const start = n.getStart(sf) - prefix.length
150
154
  const end = n.getEnd() - prefix.length
151
155
  if (start < 0 || end > text.length) return
156
+ // `_p` is always keyed by the caller-facing name (`sourceName ?? name`
157
+ // — #2524 CSR half); the local binding (`n.text`) only survives on the
158
+ // left of a shorthand expansion.
159
+ const callerKey = propAliases?.get(n.text) ?? n.text
152
160
  if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) {
153
- edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${n.text}` })
161
+ edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${callerKey}` })
154
162
  return
155
163
  }
156
- edits.push({ start, end, replacement: `${PROPS_PARAM}.${n.text}` })
164
+ edits.push({ start, end, replacement: `${PROPS_PARAM}.${callerKey}` })
157
165
  })
158
166
 
159
167
  if (edits.length === 0) return text
@@ -177,11 +185,14 @@ function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string
177
185
  export function applyRegexPropRefRewrite(
178
186
  text: string,
179
187
  propRefs: Iterable<string>,
188
+ propAliases?: ReadonlyMap<string, string>,
180
189
  ): string {
181
190
  const { protect, restore } = createTemplateAwareStringProtector()
182
191
  let result = protect(text)
183
192
 
184
193
  for (const propName of propRefs) {
194
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
195
+ const callerKey = propAliases?.get(propName) ?? propName
185
196
  const pattern = new RegExp(`(?<!${PROPS_PARAM}\\.)(?<!['"\\w.-])\\b${propName}\\b(?![a-zA-Z0-9_$])`, 'g')
186
197
  result = result.replace(pattern, (match, offset, str) => {
187
198
  // Skip object literal keys: preceded by { or , and followed by :
@@ -190,7 +201,7 @@ export function applyRegexPropRefRewrite(
190
201
  const before = str.slice(0, offset)
191
202
  if (/[{,]\s*$/.test(before)) return match
192
203
  }
193
- return `${PROPS_PARAM}.${propName}`
204
+ return `${PROPS_PARAM}.${callerKey}`
194
205
  })
195
206
  }
196
207
 
@@ -209,12 +220,18 @@ export function applyRegexPropRefRewrite(
209
220
  * `text` was produced by inlining a branch-local whose initializer
210
221
  * references the prop). The rewrite only touches genuine value
211
222
  * references, so passing an over-broad set is safe.
223
+ * @param propAliases - Local name → caller-facing key (`sourceName ?? name`)
224
+ * for aliased destructured props (`{ n: count }` → `count` → `n`).
225
+ * `_p` is always keyed by the caller-facing name (#2524 CSR half); a name
226
+ * absent from this map emits `_p.<name>` unchanged (the un-aliased case,
227
+ * where `sourceName ?? name` is an identity).
212
228
  */
213
229
  export function rewriteBarePropRefs(
214
230
  text: string,
215
231
  node: ts.Node,
216
232
  propNames: Set<string>,
217
233
  extraPropRefs?: ReadonlySet<string>,
234
+ propAliases?: ReadonlyMap<string, string>,
218
235
  ): string | undefined {
219
236
  // Walk AST to find which prop names are actually used as value references
220
237
  const foundPropRefs = new Set<string>()
@@ -225,5 +242,8 @@ export function rewriteBarePropRefs(
225
242
  }
226
243
  }
227
244
  if (foundPropRefs.size === 0) return undefined
228
- return applyScopedPropRefRewrite(text, foundPropRefs) ?? applyRegexPropRefRewrite(text, foundPropRefs)
245
+ return (
246
+ applyScopedPropRefRewrite(text, foundPropRefs, propAliases) ??
247
+ applyRegexPropRefRewrite(text, foundPropRefs, propAliases)
248
+ )
229
249
  }
@@ -0,0 +1,70 @@
1
+ import ts from 'typescript'
2
+ import type { ParamInfo } from './types.ts'
3
+
4
+ /**
5
+ * Authoritative IdentifierName classification for a destructure-pattern
6
+ * property key, built on TS's own `isIdentifierStart` / `isIdentifierPart`
7
+ * primitives (Unicode-aware, stays aligned with what TS itself accepts as
8
+ * a bare property key). Mirrors the `isIdent` precedent in
9
+ * `jsx-to-ir.ts` (#1244) — a source key like `data-key` or `aria-label`
10
+ * can't be emitted as a bare `key: local` destructure and must be quoted
11
+ * (`"data-key": local`).
12
+ */
13
+ export function isIdentifierName(key: string): boolean {
14
+ if (key.length === 0) return false
15
+ for (let i = 0; i < key.length; ) {
16
+ const cp = key.codePointAt(i)!
17
+ const ok = i === 0
18
+ ? ts.isIdentifierStart(cp, ts.ScriptTarget.Latest)
19
+ : ts.isIdentifierPart(cp, ts.ScriptTarget.Latest)
20
+ if (!ok) return false
21
+ i += cp > 0xFFFF ? 2 : 1
22
+ }
23
+ return true
24
+ }
25
+
26
+ /**
27
+ * The single destructure-binding renderer for a props param, shared by
28
+ * every JSX-runtime SSR adapter (Hono, TestAdapter). The caller-facing
29
+ * key is `sourceName ?? name` (ParamInfo's own rule) — `name` is only
30
+ * ever the LOCAL binding. Emits the plain shorthand when they match
31
+ * (byte-identical to the pre-rename-aware form); emits a `key: local`
32
+ * rename otherwise (b4f5075). This also covers the `class` → `className`
33
+ * rename: a source prop literally named `class` can only reach
34
+ * `propsParams` via an aliased destructure (`{ class: className }` —
35
+ * `class` is a reserved word, so it can never be an un-aliased binding),
36
+ * which sets `sourceName: 'class'` and takes the rename branch
37
+ * (`class: className`), not a bare `className`.
38
+ *
39
+ * One exported implementation, two consumers, zero drift — the
40
+ * hono/test-adapter pair carrying private copies is exactly the
41
+ * lockstep-rule duplication #2460/#2524 were about.
42
+ */
43
+ export function propsDestructureBinding(p: ParamInfo): string {
44
+ const callerKey = p.sourceName ?? p.name
45
+ const localName = p.name
46
+ const binding = callerKey === localName
47
+ ? localName
48
+ : `${isIdentifierName(callerKey) ? callerKey : JSON.stringify(callerKey)}: ${localName}`
49
+ return p.defaultValue ? `${binding} = ${p.defaultValue}` : binding
50
+ }
51
+
52
+ /**
53
+ * Local-name → caller-facing-key map for prop-reference rewrites —
54
+ * entries only for `ParamInfo`s that actually rename (`sourceName` set,
55
+ * see its docstring in `types.ts`). `_p` is always keyed by the
56
+ * caller-facing name (#2524 CSR half); an un-aliased prop leaves no
57
+ * entry, so `map?.get(name) ?? name` degrades to an identity there.
58
+ * Returns `undefined` when nothing renames, so callers can
59
+ * short-circuit.
60
+ */
61
+ export function buildPropAliasMap(params: readonly ParamInfo[]): Map<string, string> | undefined {
62
+ let map: Map<string, string> | undefined
63
+ for (const p of params) {
64
+ if (p.sourceName) {
65
+ if (!map) map = new Map()
66
+ map.set(p.name, p.sourceName)
67
+ }
68
+ }
69
+ return map
70
+ }
package/src/relocate.ts CHANGED
@@ -15,6 +15,7 @@ import type { Scope, BindingKind, IRMetadata } from './types.ts'
15
15
  import { isVisibleIn } from './types.ts'
16
16
  import type { AnalyzerContext } from './analyzer-context.ts'
17
17
  import { PROPS_PARAM } from './ir-to-client-js/utils.ts'
18
+ import { buildPropAliasMap } from './props-binding.ts'
18
19
  import type {
19
20
  TemplatePrimitiveRegistry,
20
21
  TemplateCallAcceptor,
@@ -42,6 +43,15 @@ export interface RelocateEnv {
42
43
  * `TransformContext._destructuredPropNames`.
43
44
  */
44
45
  propsForLift: Set<string>
46
+ /**
47
+ * Local prop name → caller-facing key (`sourceName ?? name`), entries
48
+ * only for `ParamInfo`s that rename (`{ n: count }` → `count` → `n`).
49
+ * `_p` is always keyed by the caller-facing name (#2524 CSR half) — the
50
+ * `lift-to-prop` action reads this so `count` lifts to `_p.n`, not
51
+ * `_p.count`. A name absent from this map is un-aliased, so
52
+ * `propSourceNames.get(name) ?? name` degrades to an identity there.
53
+ */
54
+ propSourceNames: ReadonlyMap<string, string>
45
55
  /**
46
56
  * Name of the props parameter (e.g. `props`). Used to detect
47
57
  * `props.X` member access at lift sites — those are not free refs
@@ -179,8 +189,10 @@ function decideAction(
179
189
  if (env.propsObjectName !== null && name === env.propsObjectName) {
180
190
  return { action: 'lift-to-prop', rewrittenAs: PROPS_PARAM }
181
191
  }
182
- // Lift `name` → `_p.name`.
183
- return { action: 'lift-to-prop', rewrittenAs: `${PROPS_PARAM}.${name}` }
192
+ // Lift `name` → `_p.<caller-facing key>` — `_p` is always keyed by
193
+ // the caller-facing name (#2524 CSR half), not the local binding.
194
+ const callerKey = env.propSourceNames.get(name) ?? name
195
+ return { action: 'lift-to-prop', rewrittenAs: `${PROPS_PARAM}.${callerKey}` }
184
196
  }
185
197
 
186
198
  if ((kind === 'init-local' || kind === 'sub-init-local') && toScope === 'template') {
@@ -860,6 +872,10 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
860
872
  if (kind === 'prop') propsForLift.add(name)
861
873
  }
862
874
 
875
+ // propSourceNames: local prop name → caller-facing key, entries only
876
+ // for `ParamInfo`s that actually rename. See `RelocateEnv.propSourceNames`.
877
+ const propSourceNames = buildPropAliasMap(src.propsParams) ?? new Map<string, string>()
878
+
863
879
  // aliasTargets (#2069 R2): one-hop alias resolution table for
864
880
  // `isCallAcceptedByAdapter`. A const whose FINAL resolved binding kind
865
881
  // is `init-local` or `module-local` (i.e. not a signal/memo/prop-alias
@@ -884,6 +900,7 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
884
900
  bindings,
885
901
  inlinable: new Map(), // populated by compute-inlinability after analyzer runs
886
902
  propsForLift,
903
+ propSourceNames,
887
904
  propsObjectName,
888
905
  allowFallback: true,
889
906
  aliasTargets,
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `BindingScope`: the one shared, immutable, stack-shaped model of "names
3
+ * bound by a loop callback" (#2482 Stage 0). Six independent ad-hoc
4
+ * mechanisms across the compiler currently answer this same question —
5
+ * `ctx.loopParams` (a mutated `Set<string>` in `jsx-to-ir.ts`),
6
+ * `collectLoopBoundNames` (`adapters/loop-bound-names.ts`),
7
+ * `resolveStaticLoopSource`'s `isNameShadowed` callback
8
+ * (`static-literal.ts`), and others — each reimplementing the same
9
+ * item/index/destructure/preamble-local bookkeeping with its own bugs and
10
+ * its own blind spots. This module is the single door those mechanisms
11
+ * migrate onto in later stages (Stage 0 only ships the service + tests;
12
+ * NO call site is migrated yet).
13
+ *
14
+ * Immutability is the point, not an incidental style choice:
15
+ * `ctx.loopParams` is `.add`/`.delete`-mutated as `jsx-to-ir.ts` walks in
16
+ * and back out of nested loops, so a caller that forgets (or races) a
17
+ * `.delete()` — or that holds a reference to the "current" set across a
18
+ * push/pop it didn't expect — silently observes the WRONG scope. A
19
+ * restore-bug of that shape is impossible by construction here:
20
+ * `enterLoopRow`/`enterCallback` never mutate `this`, they return a NEW
21
+ * `BindingScope` whose parent is untouched, so holding an old reference
22
+ * always sees the scope as it was, and there is no delete step to forget.
23
+ *
24
+ * Filter/sort callback params (`.filter(x => ...)`, `.sort((a, b) => ...)`,
25
+ * a nested arrow) are bound as `'callback'` frames via `enterCallback` —
26
+ * never folded into a `'loop-row'` frame's bindings. They are a distinct
27
+ * scope-introduction shape (an inner function's own parameter list, not a
28
+ * row's item/index/destructure/preamble names) even though both end up
29
+ * "just names you can't resolve against component-level state."
30
+ */
31
+
32
+ /**
33
+ * How a name inside a `ScopeFrame` came to be bound — the row shapes
34
+ * (`'item'`/`'index'`/`'destructure'`/`'preamble'`) for `'loop-row'` frames,
35
+ * `'param'` for `'callback'` frames. One shared type because both frame
36
+ * kinds carry the same binding metadata.
37
+ */
38
+ export type ScopeBindingSource = 'item' | 'index' | 'destructure' | 'preamble' | 'param'
39
+
40
+ export interface ScopeBinding {
41
+ readonly source: ScopeBindingSource
42
+ }
43
+
44
+ export interface ScopeFrame {
45
+ readonly kind: 'loop-row' | 'callback'
46
+ readonly bindings: ReadonlyMap<string, ScopeBinding>
47
+ }
48
+
49
+ /**
50
+ * Structural pick satisfied by BOTH `IRLoop` (`packages/jsx/src/types.ts`)
51
+ * and the client-JS `LoopCore` family (`packages/jsx/src/ir-to-client-js/types.ts`)
52
+ * without importing either — this module stays dependency-free and cannot
53
+ * form an import cycle with `types.ts` / `ir-to-client-js`.
54
+ */
55
+ export interface LoopBindingSource {
56
+ readonly param: string
57
+ readonly index?: string | null
58
+ readonly paramBindings?: readonly { readonly name: string }[]
59
+ readonly preamble?: { readonly declaredNames: readonly string[] } | null
60
+ }
61
+
62
+ /**
63
+ * A stack of `ScopeFrame`s, innermost frame at index 0 of the internal
64
+ * array (i.e. `frames[0]` is what `enterLoopRow`/`enterCallback` most
65
+ * recently pushed). `lookup`'s `depth` counts from `frames[0]`, so depth 0
66
+ * always means "the innermost frame," independent of how many ancestor
67
+ * frames exist.
68
+ */
69
+ export class BindingScope {
70
+ static readonly EMPTY: BindingScope = new BindingScope([])
71
+
72
+ private constructor(private readonly frames: readonly ScopeFrame[]) {}
73
+
74
+ /**
75
+ * Child scope with a new `'loop-row'` frame for one loop's per-item
76
+ * bindings. Parent (`this`) is not mutated; the returned scope is a
77
+ * NEW object with `frames = [newFrame, ...this.frames]`.
78
+ *
79
+ * Binding semantics mirror `jsx-to-ir.ts`'s `ctx.loopParams` add site
80
+ * EXACTLY (verified against lines ~4320-4345 and the matching delete
81
+ * site ~4695-4710 of `packages/jsx/src/jsx-to-ir.ts`):
82
+ *
83
+ * - When `loop.paramBindings` is non-empty (a destructured callback
84
+ * param, e.g. `.map(({ id, name }) => ...)`), each `paramBindings[i].name`
85
+ * is bound with source `'destructure'` and the raw `param` text
86
+ * (which for a destructured callback holds the ORIGINAL pattern
87
+ * source, e.g. `"{ id, name }"`, not a usable identifier) is NOT
88
+ * bound. This matches `jsx-to-ir.ts`:
89
+ * `if (paramBindings) { for (const b of paramBindings) ctx.loopParams.add(b.name) }`
90
+ * — the `else` branch (`ctx.loopParams.add(param)`) is skipped
91
+ * entirely when `paramBindings` is present.
92
+ * - Otherwise (a plain identifier param, e.g. `.map(item => ...)`),
93
+ * `param` itself is bound with source `'item'`.
94
+ * - `index` (the second callback param, e.g. `.map((item, i) => ...)`)
95
+ * is bound with source `'index'` when non-null/non-undefined.
96
+ * - Every name in `preamble.declaredNames` (a `.map()` callback's
97
+ * pre-return `const`/`let`/`function` locals, #2447) is bound with
98
+ * source `'preamble'`.
99
+ *
100
+ * NOTE on a sibling mechanism this method does NOT mirror:
101
+ * `adapters/loop-bound-names.ts`'s `collectLoopBoundNames` adds BOTH
102
+ * `node.param` AND every `paramBindings[i].name` unconditionally
103
+ * (never skipping `param` in the destructured case) — a deliberately
104
+ * coarser, over-inclusive collection used only to subtract names from
105
+ * a flat string-typing Set (safe to over-exclude there). This method
106
+ * follows the precise `jsx-to-ir.ts` `ctx.loopParams` semantics, since
107
+ * that is the mechanism actually doing scope-shadowed name RESOLUTION
108
+ * (the behavior `BindingScope` replaces), not coarse exclusion.
109
+ */
110
+ enterLoopRow(loop: LoopBindingSource): BindingScope {
111
+ const bindings = new Map<string, ScopeBinding>()
112
+ if (loop.paramBindings && loop.paramBindings.length > 0) {
113
+ for (const b of loop.paramBindings) bindings.set(b.name, { source: 'destructure' })
114
+ } else {
115
+ bindings.set(loop.param, { source: 'item' })
116
+ }
117
+ if (loop.index != null) bindings.set(loop.index, { source: 'index' })
118
+ for (const name of loop.preamble?.declaredNames ?? []) bindings.set(name, { source: 'preamble' })
119
+
120
+ const frame: ScopeFrame = { kind: 'loop-row', bindings }
121
+ return new BindingScope([frame, ...this.frames])
122
+ }
123
+
124
+ /**
125
+ * Child scope with a new `'callback'` frame binding `params` (a filter
126
+ * predicate's `x`, a sort comparator's `(a, b)`, or a nested arrow's
127
+ * parameter list) with source `'param'`. Parent is not mutated.
128
+ */
129
+ enterCallback(params: readonly string[]): BindingScope {
130
+ const bindings = new Map<string, ScopeBinding>()
131
+ for (const name of params) bindings.set(name, { source: 'param' })
132
+ const frame: ScopeFrame = { kind: 'callback', bindings }
133
+ return new BindingScope([frame, ...this.frames])
134
+ }
135
+
136
+ /** Innermost-first membership check across every frame in the stack. */
137
+ isBound(name: string): boolean {
138
+ for (const frame of this.frames) {
139
+ if (frame.bindings.has(name)) return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * Resolves `name` against the frame stack innermost-first. `depth 0`
146
+ * means the innermost (most recently entered) frame; `null` when `name`
147
+ * is not bound in any frame.
148
+ */
149
+ lookup(name: string): { readonly depth: number; readonly frame: ScopeFrame; readonly binding: ScopeBinding } | null {
150
+ for (let depth = 0; depth < this.frames.length; depth++) {
151
+ const frame = this.frames[depth]
152
+ const binding = frame.bindings.get(name)
153
+ if (binding) return { depth, frame, binding }
154
+ }
155
+ return null
156
+ }
157
+
158
+ /**
159
+ * Union of every frame's bound names (every `ScopeBindingSource`), for
160
+ * migration interop with legacy `Set<string>`-shaped consumers (e.g.
161
+ * `collectLoopBoundNames`'s return type) as later stages migrate them
162
+ * onto `BindingScope`.
163
+ *
164
+ * This is the SHADOW-GUARD query — see {@link valueBoundNames} for the
165
+ * other consumer class and why the two must not be conflated.
166
+ */
167
+ boundNames(): ReadonlySet<string> {
168
+ if (this.boundNamesCache) return this.boundNamesCache
169
+ const names = new Set<string>()
170
+ for (const frame of this.frames) {
171
+ for (const name of frame.bindings.keys()) names.add(name)
172
+ }
173
+ this.boundNamesCache = names
174
+ return names
175
+ }
176
+
177
+ // Both name queries are hot (shadow guards, slot/reactivity classifiers,
178
+ // binding-env memo keying) and the scope is immutable, so each computes
179
+ // once per instance. Callers receive the cached set as ReadonlySet —
180
+ // never mutate it.
181
+ private boundNamesCache: ReadonlySet<string> | undefined
182
+ private valueBoundNamesCache: ReadonlySet<string> | undefined
183
+
184
+ /**
185
+ * Union of names bound via `'item'`/`'index'`/`'destructure'` sources
186
+ * only — the loop row's own per-item identity — excluding `'preamble'`
187
+ * (a `.map()` callback's pre-return `const`/`let`/`function` locals,
188
+ * #2447) and `'param'` (an `enterCallback` frame's filter/sort/nested-
189
+ * arrow parameters).
190
+ *
191
+ * `BindingScope` has exactly two consumer classes, and conflating them
192
+ * is the #2482 Stage 1a Commit 2 regression this split exists to
193
+ * prevent (a `ctx.scope`-wide preamble merge flipped `tag-cloud` and
194
+ * `preamble-cells` conformance fixtures before this method existed):
195
+ *
196
+ * - SHADOW GUARDS (`tryResolveTemplateSpanFromConst`,
197
+ * `tryResolveIdentifierAsTemplateLiteral`, `rewriteBarePropRefs`
198
+ * in `jsx-to-ir.ts`) ask "is this name resolved to SOMETHING in
199
+ * this scope, so an outer const/prop of the same name must not be
200
+ * substituted here at this transform position" — every source
201
+ * qualifies, including a preamble local shadowing a module const.
202
+ * These call `isBound` / `boundNames()`.
203
+ * - REACTIVITY / SLOT-ID CLASSIFIERS (`referencesLoopParam`,
204
+ * `hasReactiveAttributes`, and the `BindingEnvironment.loopParams`
205
+ * feed built from `makeBindingEnv`, all in `jsx-to-ir.ts`) ask
206
+ * "does this expression read a value that changes per row and so
207
+ * needs its own patchable slot" — a preamble local already gets
208
+ * ITS OWN dedicated slot/region-patch machinery
209
+ * (`preambleRegions` / `markPreambleAttrSlots`, #2447), so folding
210
+ * it into this classification double-counts it. Worse: widening a
211
+ * text child's `reactive` flag this way is read by
212
+ * `hasDynamicContent` to decide whether the loop ROW's own root
213
+ * element needs a slot — an unrelated, narrower decision that must
214
+ * not move just because a preamble local is now scope-visible.
215
+ * These call `valueBoundNames()`.
216
+ */
217
+ valueBoundNames(): ReadonlySet<string> {
218
+ if (this.valueBoundNamesCache) return this.valueBoundNamesCache
219
+ const names = new Set<string>()
220
+ for (const frame of this.frames) {
221
+ for (const [name, binding] of frame.bindings) {
222
+ if (binding.source === 'item' || binding.source === 'index' || binding.source === 'destructure') {
223
+ names.add(name)
224
+ }
225
+ }
226
+ }
227
+ this.valueBoundNamesCache = names
228
+ return names
229
+ }
230
+
231
+ /**
232
+ * Drop-in for `resolveStaticLoopSource`'s `opts.isNameShadowed`
233
+ * (`packages/jsx/src/static-literal.ts:112-128`).
234
+ */
235
+ asShadowPredicate(): (name: string) => boolean {
236
+ return (name: string) => this.isBound(name)
237
+ }
238
+ }
@@ -62,6 +62,76 @@ export interface SsrDefault {
62
62
  isRestProps?: boolean
63
63
  }
64
64
 
65
+ /**
66
+ * TS twin of the runtime `derive*FromDefaults` family that ships in every
67
+ * OTHER SSR runtime port (Ruby's `BarefootJS::Context.derive_vars_from_defaults`
68
+ * — `packages/adapter-erb/lib/barefoot_js.rb:337-360` — plus Python's
69
+ * `barefootjs.runtime._derive_stash_from_defaults`, PHP's
70
+ * `Barefoot\BarefootJS::deriveStashFromDefaults`, Perl's
71
+ * `BarefootJS::_derive_stash_from_defaults`, and Rust's
72
+ * `barefootjs::manifest::derive_stash_from_defaults`). TypeScript had no such
73
+ * function — every adapter-tests conformance harness (and 3 production
74
+ * integration sites) either discarded `SsrDefault.propName` outright or keyed
75
+ * its seeding loop off the LOCAL template-var name instead of the
76
+ * caller-facing prop key, which is exactly the #2157 defect class (and its
77
+ * #2524 SSR-seeding recurrence) restated at
78
+ * `packages/adapter-erb/src/test-render.ts:276-287`. Any harness or
79
+ * integration deriving template-stash vars from an `extractSsrDefaults(...)`
80
+ * map MUST route through this function (or its runtime-language twin, when
81
+ * one is reachable) instead of hand-flattening `SsrDefault.value` and
82
+ * merging raw caller props over it — that flattening is precisely what
83
+ * silently drops the rename.
84
+ *
85
+ * Semantics (mirrors `derive_vars_from_defaults`'s observable behavior,
86
+ * including its edge cases — though not always the identical mechanism;
87
+ * e.g. Python's `_derive_stash_from_defaults` checks `props.get(prop_name)
88
+ * is not None` with no separate `in` membership test, relying on `dict.get`
89
+ * defaulting a missing key to `None` — behaviorally identical to this
90
+ * function's explicit `propName in props && props[propName] != null` for a
91
+ * plain dict/object, just expressed differently):
92
+ * - A non-object entry (a bare JSON value some callers may still pass,
93
+ * e.g. a manifest round-tripped through a generic JSON domain) is used
94
+ * AS-IS.
95
+ * - `isRestProps` entries: prefer `props[<this entry's own key>]` when the
96
+ * caller supplied one (checked via `in`, so an explicit `undefined` /
97
+ * `null` value still counts as "supplied" — the rest bag is a
98
+ * caller-assembled aggregate, not a single scalar with a meaningful
99
+ * "absent" state), else the static `value` fallback (normally `{}`).
100
+ * - Otherwise: prefer `props[propName]` when `propName` is set AND the
101
+ * caller supplied a NON-NULLISH (`!= null`, so `undefined` and `null`
102
+ * both fall through — mirrors every other port's "present and defined"
103
+ * check) value for it, else the static `value` fallback. `propName`-less
104
+ * entries (signal / memo locals) always use the static value — the
105
+ * caller cannot override them by construction.
106
+ */
107
+ export function deriveStashFromDefaults(
108
+ defaults: Record<string, SsrDefault>,
109
+ props: Record<string, unknown>,
110
+ ): Record<string, unknown> {
111
+ const extra: Record<string, unknown> = {}
112
+ for (const [name, d] of Object.entries(defaults)) {
113
+ if (d === null || typeof d !== 'object') {
114
+ // Defensive: every ENTRY `extractSsrDefaults` itself emits is always
115
+ // the `{ value, propName?, isRestProps? }` shape, but a caller may
116
+ // feed this a manifest round-tripped through a generic JSON domain
117
+ // (mirrors every runtime port's own `ref($d) eq 'HASH'` /
118
+ // `d.is_a?(Hash)` / `isinstance(d, dict)` guard).
119
+ extra[name] = d
120
+ continue
121
+ }
122
+ if (d.isRestProps) {
123
+ extra[name] = name in props ? props[name] : d.value
124
+ continue
125
+ }
126
+ if (d.propName !== undefined && d.propName in props && props[d.propName] != null) {
127
+ extra[name] = props[d.propName]
128
+ } else {
129
+ extra[name] = d.value
130
+ }
131
+ }
132
+ return extra
133
+ }
134
+
65
135
  const UNRESOLVED = Symbol('unresolved')
66
136
  type EvalResult = unknown | typeof UNRESOLVED
67
137
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The single renderer for a structured `template` variant's parts back into
3
+ * JS template-literal source.
4
+ *
5
+ * Three sites used to carry byte-identical copies of this loop — the IR-time
6
+ * component-prop collapse (`jsx-to-ir.ts`), the client-JS emitter
7
+ * (`ir-to-client-js/utils.ts`), and the JSX adapters' attribute renderer. They
8
+ * have to agree: the collapse's output is what a JSX adapter emits verbatim
9
+ * for a component prop, so a divergence between any two of them is a silent
10
+ * SSR/CSR mismatch. One door, three callers.
11
+ */
12
+
13
+ import type { IRTemplatePart } from './types.ts'
14
+
15
+ export interface TemplatePartsToJsOptions {
16
+ /**
17
+ * Prefer each part's prop-rewritten projection (`templateValue` /
18
+ * `templateCondition` / `templateKey`, i.e. destructured props rewritten
19
+ * to `_p.X`) when present. Used by the client-JS / module-registration
20
+ * template emitters, which run outside the component's destructured scope.
21
+ */
22
+ useTemplate?: boolean
23
+ /**
24
+ * Emit TypeScript type annotations. Only adapters whose output is
25
+ * type-checked as .tsx set this — see `JsxAdapterConfig.preserveTypes`.
26
+ * The neutral (untyped) form is what DSL adapters' expression pipelines
27
+ * and the client-JS bundle consume, so it must stay plain JS.
28
+ */
29
+ typed?: boolean
30
+ }
31
+
32
+ /**
33
+ * Render one `lookup` part — `${MAP[KEY]}` structurally captured at IR time
34
+ * so DSL adapters can emit a switch — as the equivalent runtime indexed
35
+ * access against the resolved cases.
36
+ *
37
+ * Under `typed`, the inlined object literal is annotated
38
+ * `as Record<string, string>` (#2565). The IR's `key` is the TYPE-STRIPPED
39
+ * source text of the index expression, so a narrowing assertion written at
40
+ * the use site — `strokePaths[name as keyof typeof strokePaths]` — is already
41
+ * gone by the time the record's cases are folded in here. That leaves the
42
+ * literal's exact key set indexed by the binding's unnarrowed union, which
43
+ * fails TS7053 ("expression of type 'IconName' can't be used to index type
44
+ * '{ check: string; … }'") in any consumer that type-checks its compiled
45
+ * templates. Widening the literal to a string index signature restores the
46
+ * assertion's effect without reconstructing its text, which may name types
47
+ * the emitted template never declares (`keyof typeof strokePaths` where
48
+ * `strokePaths` was localised into a component body). Purely a type-level
49
+ * annotation — the runtime expression is identical either way.
50
+ */
51
+ export function lookupPartToJsExpr(
52
+ part: Extract<IRTemplatePart, { type: 'lookup' }>,
53
+ opts?: TemplatePartsToJsOptions,
54
+ ): string {
55
+ const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
56
+ const obj = '{' + Object.entries(part.cases).map(
57
+ ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
58
+ ).join(', ') + '}'
59
+ const typed = opts?.typed ? ' as Record<string, string>' : ''
60
+ return `(${obj}${typed})[${key}]`
61
+ }
62
+
63
+ /** Convert a `template` variant's parts into a JS template-literal string. */
64
+ export function templatePartsToJsExpr(
65
+ parts: readonly IRTemplatePart[],
66
+ opts?: TemplatePartsToJsOptions,
67
+ ): string {
68
+ let result = '`'
69
+ for (const part of parts) {
70
+ if (part.type === 'string') {
71
+ result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
72
+ } else if (part.type === 'ternary') {
73
+ const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
74
+ result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
75
+ } else if (part.type === 'lookup') {
76
+ result += `\${${lookupPartToJsExpr(part, opts)}}`
77
+ }
78
+ }
79
+ result += '`'
80
+ return result
81
+ }
package/src/types.ts CHANGED
@@ -93,6 +93,16 @@ export interface ParamInfo {
93
93
  type: TypeInfo
94
94
  optional: boolean
95
95
  defaultValue?: string
96
+ /**
97
+ * `defaultValue` parsed into a structured tree, mirroring `SignalInfo.parsed`
98
+ * (Roadmap A). Attached best-effort by the analyzer (`tsNodeToParsedExpr` on
99
+ * the binding element's own `initializer` node — no re-parse of the
100
+ * `defaultValue` text) so adapters can classify a destructure default's
101
+ * literal shape (`{ count = 3 }`, `{ ratio = 1.5 }`) from structure instead
102
+ * of regexing `defaultValue`. Absent when the shape isn't supported;
103
+ * consumers fall back to text-matching `defaultValue`.
104
+ */
105
+ parsed?: ParsedExpr
96
106
  /** When true, the default value contains an arrow function or function expression (computed from AST). */
97
107
  defaultContainsArrow?: boolean
98
108
  /** When true, the parameter is a rest spread (`...args`) — emit must prepend `...`. */
@@ -1872,6 +1882,14 @@ export interface ConstantInfo {
1872
1882
  parsed?: ParsedExpr
1873
1883
  /** Value with TypeScript type annotations preserved, for .tsx output */
1874
1884
  typedValue?: string
1885
+ /**
1886
+ * The declaration's explicit type annotation, verbatim from source
1887
+ * (`node.type.getText()`), when the author wrote one. Distinct from
1888
+ * `type`, which is also populated by inference from the initializer —
1889
+ * emitters must only print THIS field, never an inferred type, onto a
1890
+ * declaration (#2589).
1891
+ */
1892
+ typeAnnotation?: string
1875
1893
  valueBranches?: string[]
1876
1894
  declarationKind: 'const' | 'let'
1877
1895
  isExported?: boolean