@barefootjs/jsx 0.29.0 → 0.30.2

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 (90) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +990 -367
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  25. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  27. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  29. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/types.d.ts +12 -0
  31. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/prop-rewrite.d.ts +24 -7
  34. package/dist/prop-rewrite.d.ts.map +1 -1
  35. package/dist/rich-type-evidence.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  38. package/dist/types.d.ts +78 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  42. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  43. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  44. package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
  45. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  46. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  47. package/src/__tests__/lazy-conditional.test.ts +190 -0
  48. package/src/__tests__/lazy-preamble.test.ts +297 -0
  49. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  50. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  51. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  52. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  53. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  54. package/src/__tests__/preamble-declarations.test.ts +207 -0
  55. package/src/__tests__/ssr-defaults.test.ts +44 -0
  56. package/src/adapters/child-scope.ts +22 -0
  57. package/src/adapters/loop-bound-names.ts +6 -0
  58. package/src/analyzer-context.ts +43 -0
  59. package/src/analyzer.ts +33 -3
  60. package/src/index.ts +1 -0
  61. package/src/ir-to-client-js/collect-elements.ts +51 -10
  62. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  63. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  65. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  67. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  68. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  70. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  71. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  72. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  73. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  74. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  75. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  76. package/src/ir-to-client-js/emit-registration.ts +1 -1
  77. package/src/ir-to-client-js/html-template.ts +51 -40
  78. package/src/ir-to-client-js/index.ts +1 -1
  79. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  80. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  81. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  82. package/src/ir-to-client-js/reactivity.ts +25 -0
  83. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  84. package/src/ir-to-client-js/types.ts +12 -0
  85. package/src/jsx-to-ir.ts +409 -24
  86. package/src/prop-rewrite.ts +149 -19
  87. package/src/rich-type-evidence.ts +6 -7
  88. package/src/ssr-defaults.ts +7 -2
  89. package/src/to-locale-date-lowering.ts +8 -2
  90. package/src/types.ts +80 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `.map()` callback preamble → backend-neutral value declarations (#2447).
3
+ *
4
+ * ## What was broken
5
+ *
6
+ * A block-body `.map()` whose preamble computes a value used in the row
7
+ * (`const cls = row.done ? 'done' : 'open'`) carried that preamble only as
8
+ * `segments` — JS text, which a template language cannot execute. Every DSL
9
+ * adapter emitted the row anyway, reading a name it never assigned: ERB read
10
+ * `v[:cls]` (an unseeded vars-Hash key), Go read `$.Cls` (a PARENT-struct
11
+ * field), the rest read a bare undefined local. The class rendered empty, on
12
+ * eight backends, with no diagnostic — the exact silent divergence the
13
+ * sound-or-loud invariant forbids.
14
+ *
15
+ * ## The two halves pinned here
16
+ *
17
+ * 1. **Lowerable → carried.** `MapCallbackPreamble.declarations` holds one
18
+ * `{ name, valueParsed }` per declaration, so each adapter emits it in its
19
+ * own per-row local syntax through the `ParsedExpr` door it already has.
20
+ * 2. **Not lowerable → loud.** Anything that is not a sequence of value
21
+ * declarations leaves `declarations` unset, and a DSL target refuses with
22
+ * the `/* @client *\/` escape rather than emitting a row that reads
23
+ * nothing. A JS runtime keeps running the preamble verbatim either way.
24
+ *
25
+ * All-or-nothing is the point of the shape assertions below: lowering the
26
+ * declarable prefix of a mixed preamble and dropping the rest would be a new
27
+ * silent divergence wearing the fix's clothes.
28
+ *
29
+ * The per-adapter emission is asserted end-to-end by the
30
+ * `loop-preamble-attr-value` conformance fixture (real ERB / Jinja / Twig /
31
+ * Blade / Kolon / Mojo / minijinja / Go renders vs. the JS reference), not
32
+ * here; this file pins the IR contract and the gate.
33
+ */
34
+
35
+ import { describe, test, expect, beforeAll } from 'bun:test'
36
+ import { analyzeComponent } from '../analyzer'
37
+ import { jsxToIR } from '../jsx-to-ir'
38
+ import { compileJSX } from '../compiler'
39
+ import { TestAdapter } from '../adapters/test-adapter'
40
+ import type { IRElement, IRLoop, IRNode } from '../types'
41
+
42
+ const ROWS = (preamble: string, row = `<li key={r.id} class={cls}>{r.label}</li>`) => `
43
+ 'use client'
44
+ import { createSignal } from '@barefootjs/client'
45
+ type Row = { id: number; label: string; done: boolean }
46
+ export function Rows(props: { rows: Row[] }) {
47
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
48
+ return (
49
+ <ul>
50
+ {rows().map(r => {
51
+ ${preamble}
52
+ return ${row}
53
+ })}
54
+ </ul>
55
+ )
56
+ }
57
+ `
58
+
59
+ function findLoop(node: IRNode | null): IRLoop | null {
60
+ if (!node) return null
61
+ if (node.type === 'loop') return node
62
+ const kids = (node as IRElement).children
63
+ if (!Array.isArray(kids)) return null
64
+ for (const c of kids) {
65
+ const hit = findLoop(c)
66
+ if (hit) return hit
67
+ }
68
+ return null
69
+ }
70
+
71
+ function loopOf(source: string): IRLoop {
72
+ const loop = findLoop(jsxToIR(analyzeComponent(source, 'Rows.tsx')))
73
+ if (!loop) throw new Error('no loop in IR')
74
+ return loop
75
+ }
76
+
77
+ /** Compile against a DSL-tier adapter (one that cannot run a callback body). */
78
+ function dslErrors(source: string): string[] {
79
+ const adapter = new TestAdapter()
80
+ adapter.acceptsCallbackBody = () => false
81
+ return compileJSX(source, 'Rows.tsx', { adapter })
82
+ .errors.filter(e => e.severity !== 'warning')
83
+ .map(e => `${e.code}: ${e.message}`)
84
+ }
85
+
86
+ // The first `analyzeComponent` in a process pays the TS program setup, which
87
+ // alone exceeds the default per-test timeout. Pay it once, outside a test.
88
+ beforeAll(() => {
89
+ analyzeComponent(ROWS(`const warm = r.label`), 'Warmup.tsx')
90
+ }, 60_000)
91
+
92
+ describe('preamble value declarations — carried as neutral IR', () => {
93
+ test('a single ternary declaration', () => {
94
+ const loop = loopOf(ROWS(`const cls = r.done ? 'done' : 'open'`))
95
+ expect(loop.preamble?.declarations).toEqual([
96
+ expect.objectContaining({ name: 'cls', raw: `r.done ? 'done' : 'open'` }),
97
+ ])
98
+ expect(loop.preamble?.declarations?.[0].valueParsed.kind).toBe('conditional')
99
+ })
100
+
101
+ test('declarations keep SOURCE order, so a later one can read an earlier one', () => {
102
+ const loop = loopOf(`
103
+ 'use client'
104
+ import { createSignal } from '@barefootjs/client'
105
+ type Row = { id: number; label: string; done: boolean }
106
+ export function Rows(props: { rows: Row[] }) {
107
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
108
+ return (
109
+ <ul>
110
+ {rows().map(r => {
111
+ const base = r.done ? 'done' : 'open'
112
+ const cls = base + ' row'
113
+ return <li key={r.id} class={cls}>{r.label}</li>
114
+ })}
115
+ </ul>
116
+ )
117
+ }
118
+ `)
119
+ expect(loop.preamble?.declarations?.map(d => d.name)).toEqual(['base', 'cls'])
120
+ })
121
+
122
+ test('several declarators in one statement each become a declaration', () => {
123
+ const loop = loopOf(ROWS(`const a = r.label, b = r.id`))
124
+ expect(loop.preamble?.declarations?.map(d => d.name)).toEqual(['a', 'b'])
125
+ })
126
+
127
+ test('the names are loop-bound for the body, like a loop param', async () => {
128
+ // `collectLoopBoundNames` is what stops a same-named module const from
129
+ // inlining over the local the loop just declared.
130
+ const { collectLoopBoundNames } = await import('../adapters/loop-bound-names.ts')
131
+ const ctx = analyzeComponent(ROWS(`const cls = r.done ? 'done' : 'open'`), 'Rows.tsx')
132
+ const root = jsxToIR(ctx)
133
+ expect(root).not.toBeNull()
134
+ const names = collectLoopBoundNames({ ...ctx.componentIR!, root: root! })
135
+ expect(names.has('cls')).toBe(true)
136
+ })
137
+ })
138
+
139
+ describe('preamble value declarations — all-or-nothing', () => {
140
+ // Each of these has SOMETHING declarable in it. Carrying just that part and
141
+ // dropping the rest would put the missing statement's effect nowhere, with
142
+ // no diagnostic — so the whole preamble must decline.
143
+ const notDeclarable: Array<[string, string]> = [
144
+ ['an assignment after a declaration', `let n = 0\n n = r.id`],
145
+ ['an imperative loop', `const out = []\n for (const c of r.label) out.push(c)`],
146
+ ['a destructuring binding', `const { label } = r`],
147
+ ['a declaration with no initializer', `let cls\n const other = r.label`],
148
+ [
149
+ 'an initializer the expression subset cannot model',
150
+ `const cls = (() => r.done)()`,
151
+ ],
152
+ ]
153
+
154
+ for (const [name, preamble] of notDeclarable) {
155
+ test(name, () => {
156
+ const loop = loopOf(ROWS(preamble, `<li key={r.id}>{r.label}</li>`))
157
+ expect(loop.preamble).toBeDefined()
158
+ expect(loop.preamble?.declarations).toBeUndefined()
159
+ })
160
+ }
161
+ })
162
+
163
+ describe('the DSL gate — loud, not an empty attribute', () => {
164
+ test('a declarable preamble compiles clean on a DSL target', () => {
165
+ expect(dslErrors(ROWS(`const cls = r.done ? 'done' : 'open'`))).toEqual([])
166
+ })
167
+
168
+ test('a non-declarable preamble refuses, with the /* @client */ escape', () => {
169
+ const errors = dslErrors(
170
+ ROWS(`let n = 0\n n = r.id`, `<li key={r.id}>{r.label}</li>`),
171
+ )
172
+ expect(errors.length).toBeGreaterThan(0)
173
+ expect(errors[0]).toMatch(/not a sequence of value declarations/)
174
+ })
175
+
176
+ test('a JS runtime runs the same preamble verbatim — no refusal', () => {
177
+ // The whole point of the per-backend fidelity model: the price is paid by
178
+ // the backend that genuinely cannot express the shape.
179
+ const r = compileJSX(
180
+ ROWS(`let n = 0\n n = r.id`, `<li key={r.id}>{r.label}</li>`),
181
+ 'Rows.tsx',
182
+ { adapter: new TestAdapter() },
183
+ )
184
+ expect(r.errors.filter(e => e.severity !== 'warning')).toEqual([])
185
+ })
186
+
187
+ test('/* @client */ silences the DSL refusal', () => {
188
+ const source = `
189
+ 'use client'
190
+ import { createSignal } from '@barefootjs/client'
191
+ type Row = { id: number; label: string; done: boolean }
192
+ export function Rows(props: { rows: Row[] }) {
193
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
194
+ return (
195
+ <ul>
196
+ {/* @client */ rows().map(r => {
197
+ let n = 0
198
+ n = r.id
199
+ return <li key={r.id}>{r.label}</li>
200
+ })}
201
+ </ul>
202
+ )
203
+ }
204
+ `
205
+ expect(dslErrors(source)).toEqual([])
206
+ })
207
+ })
@@ -102,6 +102,50 @@ describe('extractSsrDefaults', () => {
102
102
  expect(defaults?.n).toEqual({ value: 0 })
103
103
  })
104
104
 
105
+ test('aliased destructured prop (#2460): `propName` is the CALLER-facing key, not the local binding', () => {
106
+ // `{ n: count }` — the caller passes `n`, the template variable (and
107
+ // stash entry key) is the local binding `count`. The manifest
108
+ // consumer (`_derive_stash_from_defaults` in the Perl runtime) reads
109
+ // the caller's props by `propName`, so `propName` must be `n`, not
110
+ // `count` — using the local name here silently drops the caller's
111
+ // value (the stash entry falls back to the static `value`, `null`).
112
+ const metadata = metadataFor(`
113
+ function Badge({ text, n: count }: { text: string; n: number }) {
114
+ return <span>{text}:{count}</span>
115
+ }
116
+ `)
117
+
118
+ const defaults = extractSsrDefaults(metadata)
119
+ expect(defaults).toBeDefined()
120
+ // Keyed by the LOCAL binding (the template variable name)...
121
+ expect(defaults?.count).toEqual({ propName: 'n', value: null })
122
+ // ...but `propName` is the CALLER-facing key.
123
+ expect(defaults?.text).toEqual({ propName: 'text', value: null })
124
+ })
125
+
126
+ test('aliased destructured prop with a default (#2460): `propName` still resolves to the source key', () => {
127
+ const metadata = metadataFor(`
128
+ function Badge({ n: count = 7 }: { n?: number }) {
129
+ return <span>{count}</span>
130
+ }
131
+ `)
132
+
133
+ const defaults = extractSsrDefaults(metadata)
134
+ expect(defaults?.count).toEqual({ propName: 'n', value: 7 })
135
+ })
136
+
137
+ test('un-aliased destructured prop (regression): `propName` equals the local name', () => {
138
+ const metadata = metadataFor(`
139
+ function Badge({ text, n }: { text: string; n: number }) {
140
+ return <span>{text}:{n}</span>
141
+ }
142
+ `)
143
+
144
+ const defaults = extractSsrDefaults(metadata)
145
+ expect(defaults?.n).toEqual({ propName: 'n', value: null })
146
+ expect(defaults?.text).toEqual({ propName: 'text', value: null })
147
+ })
148
+
105
149
  test('memo derived from a signal evaluates through the chain', () => {
106
150
  const metadata = metadataFor(`
107
151
  'use client'
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whether a child component derives its `bf-s` scope id from its parent
3
+ * scope + mount slot (`${parentScopeId}_${slotId}`) rather than getting a
4
+ * freshly randomized one (`${name}_${random}`).
5
+ *
6
+ * A component derives from its slot unless it is the DIRECT root of a loop
7
+ * row (`IRComponent.loopItemRoot`, set once in `jsx-to-ir.ts`'s loop
8
+ * builder) — a row root owns its own per-row identity, matching Hono's
9
+ * reference behaviour (#2444). Every backend (Hono, each DSL adapter, and
10
+ * both CSR template emitters) MUST consult this single predicate instead of
11
+ * re-deriving the fact from a mutable "am I inside a loop" flag, which is
12
+ * what caused #2444: such flags stay true for an entire loop subtree and
13
+ * can't distinguish a row root from a component nested below it.
14
+ */
15
+
16
+ import type { IRComponent } from '../types.ts'
17
+
18
+ export function derivesScopeFromSlot(
19
+ comp: Pick<IRComponent, 'slotId' | 'loopItemRoot'>
20
+ ): boolean {
21
+ return comp.slotId != null && comp.loopItemRoot !== true
22
+ }
@@ -50,6 +50,12 @@ export function collectLoopBoundNames(ir: ComponentIR): Set<string> {
50
50
  // param (which may differ from the map callback's `param`) before
51
51
  // any rename to the loop param happens.
52
52
  if (node.filterPredicate) names.add(node.filterPredicate.param)
53
+ // A `.map()` callback preamble's declarations lower to per-row locals
54
+ // in the loop body on every DSL adapter (#2447), so a preamble name is
55
+ // bound in body scope exactly like a param — and must get the same
56
+ // exclusion, or a same-named module const would inline over the local
57
+ // the loop just declared.
58
+ for (const name of node.preamble?.declaredNames ?? []) names.add(name)
53
59
  for (const child of node.children) visit(child)
54
60
  if (node.childComponent) {
55
61
  for (const child of node.childComponent.children) visit(child)
@@ -448,6 +448,49 @@ export function typeNodeToTypeInfo(
448
448
  return { kind: 'array', raw, elementType: recurse(typeNode.elementType) }
449
449
  }
450
450
 
451
+ // Literal types (`'a'`, `42`, `-1`, `true`, `null`) — including as union
452
+ // members, which is where the absence of this arm hurt: `'a' | 'b'`
453
+ // members fell to `{kind:'unknown', raw:"'a'"}`, forcing every consumer
454
+ // that needed the member's family or value to re-parse `raw` with a
455
+ // regex (the Go adapter's literal-union collapse, #2324's
456
+ // `unionMemberLiteral`, `rich-type-evidence`'s nullish-arm match — all
457
+ // three carried one). A literal is structurally a primitive of its
458
+ // family carrying its value; consumers that only care about the family
459
+ // see a plain primitive, and the value rides along in `literalValue`
460
+ // for the ones that need it. `null` inside a union arrives as
461
+ // `LiteralTypeNode(NullKeyword)` — NOT the bare `NullKeyword` the
462
+ // switch above catches — which is exactly why nullish union arms were
463
+ // invisible before.
464
+ if (ts.isLiteralTypeNode(typeNode)) {
465
+ const lit = typeNode.literal
466
+ if (ts.isStringLiteral(lit) || ts.isNoSubstitutionTemplateLiteral(lit)) {
467
+ return { kind: 'primitive', raw, primitive: 'string', literalValue: lit.text }
468
+ }
469
+ if (ts.isNumericLiteral(lit)) {
470
+ return { kind: 'primitive', raw, primitive: 'number', literalValue: lit.text }
471
+ }
472
+ if (
473
+ ts.isPrefixUnaryExpression(lit) &&
474
+ lit.operator === ts.SyntaxKind.MinusToken &&
475
+ ts.isNumericLiteral(lit.operand)
476
+ ) {
477
+ return { kind: 'primitive', raw, primitive: 'number', literalValue: `-${lit.operand.text}` }
478
+ }
479
+ if (lit.kind === ts.SyntaxKind.TrueKeyword || lit.kind === ts.SyntaxKind.FalseKeyword) {
480
+ return {
481
+ kind: 'primitive',
482
+ raw,
483
+ primitive: 'boolean',
484
+ literalValue: lit.kind === ts.SyntaxKind.TrueKeyword ? 'true' : 'false',
485
+ }
486
+ }
487
+ if (lit.kind === ts.SyntaxKind.NullKeyword) {
488
+ return { kind: 'primitive', raw, primitive: 'null' }
489
+ }
490
+ // BigInt and anything newer: opaque, as before.
491
+ return { kind: 'unknown', raw }
492
+ }
493
+
451
494
  // Union types
452
495
  if (ts.isUnionTypeNode(typeNode)) {
453
496
  return { kind: 'union', raw, unionTypes: typeNode.types.map(recurse) }
package/src/analyzer.ts CHANGED
@@ -1201,10 +1201,21 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1201
1201
  const callExpr = node.initializer as ts.CallExpression
1202
1202
 
1203
1203
  const elements = pattern.elements
1204
+ // Getter-elided form: `const [, setActive] = createSignal(0)`. The first
1205
+ // element is a hole (ts.OmittedExpression), not a BindingElement.
1206
+ // Rejecting it here used to drop the whole declaration from the emitted
1207
+ // init while the setter stayed referenced by handlers — a guaranteed
1208
+ // ReferenceError (adapter-tests scope-gate, #2468 sweep). Accept it,
1209
+ // synthesize an internal getter name for the IR (never referenced by any
1210
+ // expression, so substitution/SSR consumers see an unused entry), and
1211
+ // mark `getterElided` so emit reproduces the source's `[, setter]` shape.
1212
+ const getterElided = elements.length === 2 && ts.isOmittedExpression(elements[0])
1204
1213
  if (
1205
1214
  elements.length < 1 || elements.length > 2 ||
1206
- !ts.isBindingElement(elements[0]) ||
1207
- !ts.isIdentifier(elements[0].name)
1215
+ (!getterElided && (
1216
+ !ts.isBindingElement(elements[0]) ||
1217
+ !ts.isIdentifier(elements[0].name)
1218
+ ))
1208
1219
  ) {
1209
1220
  return
1210
1221
  }
@@ -1216,10 +1227,11 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1216
1227
  return
1217
1228
  }
1218
1229
 
1219
- const getter = elements[0].name.text
1220
1230
  const setter = elements.length === 2 && ts.isBindingElement(elements[1]) && ts.isIdentifier(elements[1].name)
1221
1231
  ? elements[1].name.text
1222
1232
  : null
1233
+ if (getterElided && !setter) return
1234
+ const getter = getterElided ? `__bfGet_${setter}` : (elements[0] as ts.BindingElement & { name: ts.Identifier }).name.text
1223
1235
  const initialValue = callExpr.arguments[0] ? ctx.getJS(callExpr.arguments[0]) : ''
1224
1236
  const typedInitialValue = callExpr.arguments[0] ? callExpr.arguments[0].getText(ctx.sourceFile) : undefined
1225
1237
 
@@ -1257,6 +1269,7 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1257
1269
  ctx.signals.push({
1258
1270
  getter,
1259
1271
  setter,
1272
+ getterElided: getterElided || undefined,
1260
1273
  initialValue,
1261
1274
  typedInitialValue: typedInitialValue !== initialValue ? typedInitialValue : undefined,
1262
1275
  templateInitialValue,
@@ -1577,9 +1590,26 @@ function collectMemo(node: ts.VariableDeclaration, ctx: AnalyzerContext): void {
1577
1590
  const parsedBlockComplete =
1578
1591
  parsedBlock && blockBody ? parsedBlock.length === blockBody.statements.length : undefined
1579
1592
 
1593
+ // Destructured-arg components only — the memo twin of the signal
1594
+ // `templateInitialValue` rewrite (#2265) a few hundred lines up: a memo
1595
+ // body referencing a bare destructured prop (`createMemo(() => value *
1596
+ // 10)` with `{ value }: { value: number }`) is inlined into the CSR
1597
+ // `template:` arrow by `buildSignalMemoEnv`, and that arrow runs at
1598
+ // module scope — it is not a closure over `initXxx`'s `const value =
1599
+ // _p.value` extraction, so the bare ref must become `_p.value`
1600
+ // (adapter-tests scope-gate, #2468 sweep).
1601
+ let templateComputation: string | undefined
1602
+ if (!ctx.propsObjectName && callExpr.arguments[0]) {
1603
+ const propNames = new Set(ctx.propsParams.map(p => p.name))
1604
+ if (propNames.size > 0) {
1605
+ templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames)
1606
+ }
1607
+ }
1608
+
1580
1609
  ctx.memos.push({
1581
1610
  name,
1582
1611
  computation,
1612
+ templateComputation,
1583
1613
  parsedBlock,
1584
1614
  parsedBlockComplete,
1585
1615
  typedComputation: typedComputation !== computation ? typedComputation : undefined,
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
90
90
  export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
91
91
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
92
92
  export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
93
+ export { derivesScopeFromSlot } from './adapters/child-scope.ts'
93
94
  export { evaluateSignalInit, tryEvaluateSignalInit, type SignalInitEvalResult } from './signal-init-eval.ts'
94
95
  export { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from './static-literal.ts'
95
96
  export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, formatDateLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
@@ -328,10 +328,12 @@ export function collectInnerLoops(
328
328
  // `NestedLoop` because event delegation handles them through
329
329
  // the parent's bindings instead.
330
330
  const bindings: LoopChildBindings = emptyLoopChildBindings()
331
+ // Hoisted: one Set per loop, not one per child (Copilot review).
332
+ const innerPreambleNames = preambleNamesOf(n)
331
333
  if (ctx) {
332
334
  for (const child of n.children) {
333
335
  bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings))
334
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings))
336
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames))
335
337
  bindings.refs.push(...collectLoopChildRefs(child))
336
338
  }
337
339
  }
@@ -669,7 +671,7 @@ export function collectElements(
669
671
  const childHandlers: string[] = []
670
672
  const bindings = projectionInner
671
673
  ? emptyLoopChildBindings()
672
- : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
674
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l))
673
675
  if (!projectionInner) {
674
676
  for (const child of l.children) {
675
677
  childHandlers.push(...collectEventHandlersFromIR(child))
@@ -708,10 +710,12 @@ export function collectElements(
708
710
  // Loop-param refs use the raw destructured binding (no loopParams
709
711
  // passed), matching the plain-element path's `staticItemTemplate`.
710
712
  if (l.isStaticArray && l.children[0]) {
711
- // `insideLoop=true` so the component-emit drops the parent's slot
712
- // suffix each iteration owns a distinct scope identified by
713
- // `data-key`, mirroring the SSR template's renderChild emit.
714
- staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined, /* insideLoop */ true)
713
+ // `l.children[0]` is the loop's row-root component already
714
+ // tagged `loopItemRoot` in the IR, so `derivesScopeFromSlot`
715
+ // drops the parent's slot suffix automatically. Each iteration
716
+ // owns a distinct scope identified by `data-key`, mirroring the
717
+ // SSR template's renderChild emit.
718
+ staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined)
715
719
  }
716
720
  } else if (l.children[0] && !projectionInner) {
717
721
  // Pass loopParams so expressions are wrapped at generation time,
@@ -728,8 +732,8 @@ export function collectElements(
728
732
  // accessor wrap so the CSR materialize fallback (#1247) can clone
729
733
  // items inside the forEach body without rewriting the template.
730
734
  if (l.isStaticArray) {
731
- // Plain-element body: leave `insideLoop=false` so any nested
732
- // component nodes keep their parent-slot context in the
735
+ // Plain-element body: nested component nodes are never tagged
736
+ // `loopItemRoot`, so they keep their parent-slot context in the
733
737
  // per-iteration renderChild call. Per #1249, the
734
738
  // `outer-nested` static-array-child-init plan addresses
735
739
  // children by the (bf-h, bf-m) pair against the enclosing
@@ -1139,7 +1143,7 @@ function collectBranchLoops(
1139
1143
  // which caused reactive reads inside simple loop bodies to silently
1140
1144
  // no-op for existing items.
1141
1145
  const branchBindings = ctx && !projectionInner
1142
- ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings)
1146
+ ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n))
1143
1147
  : emptyLoopChildBindings()
1144
1148
 
1145
1149
  loops.push({
@@ -1296,12 +1300,49 @@ function collectBranchConditionals(
1296
1300
  * resolve child slot ids inside nested loops; the rest of the per-item
1297
1301
  * collectors don't read them but pass them through for symmetry.
1298
1302
  */
1303
+ /**
1304
+ * The names a loop's `.map()` callback preamble declares, or `undefined` when
1305
+ * there are none to consider — the input to the attribute classifier's
1306
+ * preamble-local check (#2447 follow-up). `undefined` rather than an empty set
1307
+ * so the classifier's fast path stays a single `!== undefined` for the
1308
+ * overwhelmingly common preamble-free loop.
1309
+ *
1310
+ * A STATIC-ARRAY loop returns `undefined` even when it has a preamble, and
1311
+ * this is load-bearing rather than an optimisation. Its items are emitted
1312
+ * through `static-array-child-init` — a `forEach` over the SSR-rendered
1313
+ * children, with no per-row body and therefore no preamble in scope. Wiring an
1314
+ * attribute there would emit `setAttribute('class', active ? … : …)` against a
1315
+ * free variable: a ReferenceError at init that takes the whole component's
1316
+ * hydration with it, including any nested child. (Caught exactly that way —
1317
+ * the gallery nav shells are static arrays whose rows read
1318
+ * `const active = item.key === currentRoute`, and the badge components mounted
1319
+ * inside them stopped appearing.)
1320
+ *
1321
+ * Nothing is lost by declining: a static array's items are rendered once at
1322
+ * SSR and never recreated from a signal, so there is no same-key update for a
1323
+ * patch to be missing from. The same reasoning gates `markPreambleAttrSlots`
1324
+ * and `preambleRegions` in Phase 1, and the two gates must agree — a slot
1325
+ * granted without wiring, or wiring without a slot, is a silent hole either
1326
+ * way.
1327
+ */
1328
+ function preambleNamesOf(loop: IRLoop): ReadonlySet<string> | undefined {
1329
+ if (loop.isStaticArray) return undefined
1330
+ const declared = loop.preamble?.declaredNames
1331
+ return declared && declared.length > 0 ? new Set(declared) : undefined
1332
+ }
1333
+
1299
1334
  export function collectLoopChildBindings(
1300
1335
  children: readonly IRNode[],
1301
1336
  ctx: ClientJsContext,
1302
1337
  siblingOffsets: Map<IRLoop, IRNode[]>,
1303
1338
  loopParam: string,
1304
1339
  loopParamBindings: readonly import('../types.ts').LoopParamBinding[] | undefined,
1340
+ /**
1341
+ * Names the loop's `.map()` callback preamble declares, so an attribute
1342
+ * reading one is classified reactive (#2447 follow-up) — see
1343
+ * `collectLoopChildReactiveAttrs`. Omitted for a loop with no preamble.
1344
+ */
1345
+ preambleNames?: ReadonlySet<string>,
1305
1346
  ): LoopChildBindings {
1306
1347
  const bindings = emptyLoopChildBindings()
1307
1348
  for (const child of children) {
@@ -1311,7 +1352,7 @@ export function collectLoopChildBindings(
1311
1352
  // `collectLoopChildConditionals`, which gives each its own insert() +
1312
1353
  // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1313
1354
  // `.reactiveTexts`) — descending into them here too would double-bind.
1314
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true))
1355
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true, preambleNames))
1315
1356
  bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true))
1316
1357
  bindings.refs.push(...collectLoopChildRefs(child))
1317
1358
  bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
@@ -58,7 +58,7 @@ export function buildBranchLoopPlan(
58
58
  const mapPreambleWrapped = loop.preamble
59
59
  ? renderPreamble(loop.preamble, {
60
60
  transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
61
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
61
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
62
62
  })
63
63
  : ''
64
64
  const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings)
@@ -48,7 +48,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
48
48
  mapPreambleWrapped: elem.preamble
49
49
  ? renderPreamble(elem.preamble, {
50
50
  transformJs: wrap,
51
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
51
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
52
52
  })
53
53
  : '',
54
54
  template: elem.template,
@@ -108,7 +108,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
108
108
  mapPreambleWrapped: loop.preamble
109
109
  ? renderPreamble(loop.preamble, {
110
110
  transformJs: wrap,
111
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
111
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
112
112
  })
113
113
  : '',
114
114
  template: loop.template,
@@ -44,7 +44,7 @@ export function buildDynamicLoopDelegationPlan(
44
44
  // which throws since `t` is a plain object in this scope.
45
45
  mapPreamble: elem.preamble
46
46
  ? renderPreamble(elem.preamble, {
47
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
47
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
48
48
  })
49
49
  : null,
50
50
  mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
@@ -76,7 +76,7 @@ export function buildBranchLoopDelegationPlan(
76
76
  // loopParams spec — leaf refs must stay in plain-object form here.
77
77
  mapPreamble: loop.preamble
78
78
  ? renderPreamble(loop.preamble, {
79
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
79
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
80
80
  })
81
81
  : null,
82
82
  mapPreambleDeclaredNames: loop.preamble?.declaredNames ?? [],
@@ -109,7 +109,7 @@ export function buildStaticArrayDelegationPlan(
109
109
  // loopParams spec — leaf refs must stay in plain-object form here.
110
110
  mapPreamble: elem.preamble
111
111
  ? renderPreamble(elem.preamble, {
112
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
112
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
113
113
  })
114
114
  : null,
115
115
  mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
@@ -243,7 +243,7 @@ function buildReactiveEmit(
243
243
  : [{ param: inner.param, bindings: inner.paramBindings }]
244
244
  preludeStatements.push(renderPreamble(inner.preamble, {
245
245
  transformJs: (t) => wrapInner(wrapOuter(t)),
246
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined, true),
246
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined),
247
247
  }))
248
248
  }
249
249
 
@@ -286,7 +286,7 @@ function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string
286
286
  // of the js segments above).
287
287
  if (inner.preamble) {
288
288
  preludeStatements.push(renderPreamble(inner.preamble, {
289
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
289
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
290
290
  }))
291
291
  }
292
292
  return {