@barefootjs/jsx 0.34.0 → 0.35.0

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 (101) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +344 -215
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  28. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  37. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  38. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  39. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  40. package/dist/ir-to-client-js/utils.d.ts +34 -1
  41. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  42. package/dist/jsx-to-ir.d.ts.map +1 -1
  43. package/dist/prop-rewrite.d.ts +8 -3
  44. package/dist/prop-rewrite.d.ts.map +1 -1
  45. package/dist/props-binding.d.ts +22 -1
  46. package/dist/props-binding.d.ts.map +1 -1
  47. package/dist/ssr-defaults.d.ts.map +1 -1
  48. package/dist/types.d.ts +5 -10
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
  52. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  53. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  55. package/src/__tests__/flatmap-segments.test.ts +1 -1
  56. package/src/__tests__/lazy-conditional.test.ts +52 -12
  57. package/src/__tests__/lazy-preamble.test.ts +11 -11
  58. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  59. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  60. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  61. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  62. package/src/__tests__/nested-loop-index-param.test.ts +14 -3
  63. package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
  64. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  65. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  66. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  67. package/src/__tests__/ssr-defaults.test.ts +59 -0
  68. package/src/analyzer.ts +4 -0
  69. package/src/index.ts +8 -2
  70. package/src/ir-to-client-js/collect-elements.ts +18 -2
  71. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  72. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  73. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
  74. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
  75. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  78. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
  79. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  80. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  81. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  82. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  84. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  85. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  86. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
  87. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  88. package/src/ir-to-client-js/emit-reactive.ts +4 -1
  89. package/src/ir-to-client-js/emit-registration.ts +2 -4
  90. package/src/ir-to-client-js/html-template.ts +58 -244
  91. package/src/ir-to-client-js/imports.ts +3 -0
  92. package/src/ir-to-client-js/index.ts +4 -4
  93. package/src/ir-to-client-js/markup-slots.ts +25 -0
  94. package/src/ir-to-client-js/safe-html.ts +243 -0
  95. package/src/ir-to-client-js/utils.ts +58 -10
  96. package/src/jsx-to-ir.ts +115 -20
  97. package/src/prop-rewrite.ts +8 -4
  98. package/src/props-binding.ts +37 -1
  99. package/src/ssr-defaults.ts +45 -0
  100. package/src/types.ts +5 -10
  101. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -51,13 +51,19 @@ const armsOf = (c: LoopChildConditional) => ({
51
51
  describe('analyzeLazyConditional — accepted', () => {
52
52
  test('two wiring-free static element arms', () => {
53
53
  const c = cond()
54
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
54
+ const r = analyzeLazyConditional(c, armsOf(c))
55
55
  expect(r.lazySafe).toBe(true)
56
56
  if (r.lazySafe) {
57
57
  expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
58
58
  expect(r.facts.condition).toBe('row.done')
59
59
  }
60
60
  })
61
+
62
+ test('a condition reading the loop index is accepted (#2859 follow-up) — the caller classifies it like any other binding', () => {
63
+ const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
64
+ const r = analyzeLazyConditional(c, armsOf(c))
65
+ expect(r.lazySafe).toBe(true)
66
+ })
61
67
  })
62
68
 
63
69
  describe('analyzeLazyConditional — refusals', () => {
@@ -73,7 +79,7 @@ describe('analyzeLazyConditional — refusals', () => {
73
79
  for (const [name, over, reason] of wiring) {
74
80
  test(`the true arm owns ${name}`, () => {
75
81
  const c = cond({ whenTrue: bare(over) })
76
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
82
+ const r = analyzeLazyConditional(c, armsOf(c))
77
83
  expect(r.lazySafe).toBe(false)
78
84
  if (!r.lazySafe) expect(r.reason).toMatch(reason)
79
85
  })
@@ -81,14 +87,14 @@ describe('analyzeLazyConditional — refusals', () => {
81
87
 
82
88
  test('the FALSE arm is checked too, not just the true one', () => {
83
89
  const c = cond({ whenFalse: bare({ events: [{} as never] }) })
84
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
90
+ const r = analyzeLazyConditional(c, armsOf(c))
85
91
  expect(r.lazySafe).toBe(false)
86
92
  if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
87
93
  })
88
94
 
89
95
  test('a fragment conditional has no single node to replace', () => {
90
96
  const c = cond()
91
- const r = analyzeLazyConditional(c, '__idx', {
97
+ const r = analyzeLazyConditional(c, {
92
98
  whenTrueHtml: asFragment('text only'),
93
99
  whenFalseHtml: asElement(c.whenFalseHtml),
94
100
  })
@@ -98,24 +104,18 @@ describe('analyzeLazyConditional — refusals', () => {
98
104
 
99
105
  test('an arm that interpolates a value cannot be hoisted once per loop', () => {
100
106
  const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
101
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
107
+ const r = analyzeLazyConditional(c, armsOf(c))
102
108
  expect(r.lazySafe).toBe(false)
103
109
  if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
104
110
  })
105
111
 
106
112
  test('a condition with no analyzable identifier set refuses rather than assumes', () => {
107
113
  const c = cond({ conditionFreeIdentifiers: undefined })
108
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
114
+ const r = analyzeLazyConditional(c, armsOf(c))
109
115
  expect(r.lazySafe).toBe(false)
110
116
  if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
111
117
  })
112
118
 
113
- test('a condition reading the loop index refuses — the apply bodies have none', () => {
114
- const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
115
- const r = analyzeLazyConditional(c, '__idx', armsOf(c))
116
- expect(r.lazySafe).toBe(false)
117
- if (!r.lazySafe) expect(r.reason).toMatch(/reads the loop index parameter '__idx'/)
118
- })
119
119
  })
120
120
 
121
121
  // --- emission ---------------------------------------------------------------
@@ -175,6 +175,46 @@ describe('lazy row emission — wiring-free row conditional', () => {
175
175
  })
176
176
  })
177
177
 
178
+ describe('lazy row emission — a conditional reading the loop index (#2859 follow-up)', () => {
179
+ // Mixed item+index condition — a PURE index-only reactive condition/attr is
180
+ // a separate, pre-existing, broader gap (the analyzer never classifies an
181
+ // expression that reads ONLY the loop index as reactive AT ALL, in either
182
+ // the eager or lazy path — filed separately, not this widening's job to
183
+ // fix). `row.done` is what makes the analyzer treat this condition as
184
+ // reactive in the first place; `i % 2 === 0` then rides along, which is
185
+ // exactly what this widening needs to prove.
186
+ const source = `
187
+ 'use client'
188
+ import { createSignal } from '@barefootjs/client'
189
+ type Row = { id: number; label: string; done: boolean }
190
+ export function CondRowsIndex(props: { rows: Row[] }) {
191
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
192
+ return (
193
+ <ul>
194
+ {rows().map((row, i) => (
195
+ <li key={row.id}>{(row.done || i % 2 === 0) ? <span class="even">even</span> : <em class="odd">odd</em>}</li>
196
+ ))}
197
+ </ul>
198
+ )
199
+ }
200
+ `
201
+ const js = clientJs(source, 'CondRowsIndex.tsx')
202
+
203
+ test('the loop is still lazy — the index reference no longer forces eager', () => {
204
+ expect(js).toContain('mapArrayLazy(')
205
+ })
206
+
207
+ test('the condition is emitted into applyItem, reading the live entry.index', () => {
208
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
209
+ expect(applyItem).toMatch(/const i = __e\.index/)
210
+ expect(applyItem).toContain('i % 2 === 0')
211
+ })
212
+
213
+ test('the plan is marked indexDriven so a pure reorder re-evaluates it', () => {
214
+ expect(js).toMatch(/indexDriven:\s*true/)
215
+ })
216
+ })
217
+
178
218
  describe('lazy row emission — conditionals that keep the eager path', () => {
179
219
  const cases: Array<[string, string]> = [
180
220
  ['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
@@ -33,11 +33,11 @@ const SIGNALS: ReadonlySet<string> = new Set(['selected', 'filter'])
33
33
 
34
34
  describe('analyzeLazyPreamble — accepted', () => {
35
35
  test('no preamble at all', () => {
36
- expect(analyzeLazyPreamble(undefined, '__idx', SIGNALS).lazySafe).toBe(true)
36
+ expect(analyzeLazyPreamble(undefined, SIGNALS).lazySafe).toBe(true)
37
37
  })
38
38
 
39
39
  test('const declarations reading only the item', () => {
40
- const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']), '__idx', SIGNALS)
40
+ const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']), SIGNALS)
41
41
  expect(r.lazySafe).toBe(true)
42
42
  if (r.lazySafe) expect([...r.facts.declaredNames]).toEqual(['full'])
43
43
  })
@@ -45,17 +45,22 @@ describe('analyzeLazyPreamble — accepted', () => {
45
45
  test('a zero-arg signal read is allowed — the krausest shape', () => {
46
46
  const r = analyzeLazyPreamble(
47
47
  preamble("const cls = selected() === row.id ? 'danger' : '';", ['cls']),
48
- '__idx',
49
48
  SIGNALS,
50
49
  )
51
50
  expect(r.lazySafe).toBe(true)
52
51
  })
53
52
 
54
53
  test('a destructuring const contributes every bound name, not the property keys', () => {
55
- const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']), '__idx', SIGNALS)
54
+ const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']), SIGNALS)
56
55
  expect(r.lazySafe).toBe(true)
57
56
  if (r.lazySafe) expect([...r.facts.declaredNames].sort()).toEqual(['a', 'c'])
58
57
  })
58
+
59
+ test('a preamble reading the loop index is accepted (#2859 follow-up) — the name survives into freeNames', () => {
60
+ const r = analyzeLazyPreamble(preamble('const n = __idx + 1;', ['n']), SIGNALS)
61
+ expect(r.lazySafe).toBe(true)
62
+ if (r.lazySafe) expect(r.facts.freeNames.has('__idx')).toBe(true)
63
+ })
59
64
  })
60
65
 
61
66
  describe('analyzeLazyPreamble — refusals', () => {
@@ -112,16 +117,11 @@ describe('analyzeLazyPreamble — refusals', () => {
112
117
  preamble('const selected = row.sel;', ['selected']),
113
118
  /shadows the signal\/memo getter 'selected'/,
114
119
  ],
115
- [
116
- 'a preamble reading the loop index',
117
- preamble('const n = __idx + 1;', ['n']),
118
- /reads the loop index parameter '__idx'/,
119
- ],
120
120
  ]
121
121
 
122
122
  for (const [name, p, reason] of cases) {
123
123
  test(name, () => {
124
- const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
124
+ const r = analyzeLazyPreamble(p, SIGNALS)
125
125
  expect(r.lazySafe).toBe(false)
126
126
  if (!r.lazySafe) expect(r.reason).toMatch(reason)
127
127
  })
@@ -134,7 +134,7 @@ describe('analyzeLazyPreamble — refusals', () => {
134
134
  declaredNames: [],
135
135
  builderNames: [],
136
136
  }
137
- const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
137
+ const r = analyzeLazyPreamble(p, SIGNALS)
138
138
  expect(r.lazySafe).toBe(false)
139
139
  if (!r.lazySafe) expect(r.reason).toMatch(/JSX leaf/)
140
140
  })
@@ -152,12 +152,17 @@ describe('lazyRowEligibility — profile mode', () => {
152
152
  })
153
153
 
154
154
  describe('lazyRowEligibility — binding refusals', () => {
155
- test('a binding referencing the loop index parameter is refused', () => {
155
+ // #2859 follow-up, LIFTED: a binding referencing the loop index no longer
156
+ // sinks the loop. `classifyLazyBinding` forces `readsItem: true` for it
157
+ // (the row's position is delivered on `entry.index` exactly like the item
158
+ // is on `entry.item`), so the row stays eligible and the binding lands in
159
+ // `applyItem`. Inverted from the refusal it replaces so a gate that starts
160
+ // refusing again fails here by name.
161
+ test('a binding referencing the loop index parameter is ELIGIBLE (index tracked via entry.index)', () => {
156
162
  const decision = lazyRowEligibility(args({
157
163
  bindings: [{ ...itemBinding(), referencesIndex: true }],
158
164
  }))
159
- expect(decision.eligible).toBe(false)
160
- expect((decision as { reason: string }).reason).toMatch(/index parameter/)
165
+ expect(decision.eligible).toBe(true)
161
166
  })
162
167
 
163
168
  // §9.3a, LIFTED: an outer name the emitter cannot prime no longer sinks
@@ -36,6 +36,23 @@
36
36
  * `collectLoopChildBindings` / `collectLoopChildConditionals` /
37
37
  * `summarizeLoopChildBranch` / `collectLoopChildReactiveAttrs` /
38
38
  * `collectLoopChildReactiveTexts` into `buildLoopRowScope`.
39
+ *
40
+ * #2859: `i` is now ALSO rewritten to `i()` (`wrapLoopParamAsAccessor`,
41
+ * `ir-to-client-js/utils.ts`) on the EAGER path — `mapArray` hands
42
+ * `renderItem` an index ACCESSOR (mirroring the item accessor) and pushes
43
+ * each row's current position through it on every same-key reconcile, so
44
+ * a row surviving a reorder keeps reading its live index instead of the
45
+ * value frozen at creation. The #2595 fix above only prevented the
46
+ * module-const shadow; it still left `i` reading a plain, never-updated
47
+ * closure variable.
48
+ *
49
+ * #2859 follow-on (lazy-row widening): this exact shape has no ref/child
50
+ * component/inner loop, so it is now LAZY-eligible — the index reference
51
+ * no longer forces eager on its own. On the lazy path there is no `i()`
52
+ * accessor call at all: `applyItem` binds `const i = __e.index` (a plain
53
+ * value read off the reconciler-tracked entry) and the attribute reads
54
+ * that bare `i`, which is exactly as live across a reorder as the eager
55
+ * accessor was.
39
56
  */
40
57
 
41
58
  import { describe, test, expect } from 'bun:test'
@@ -99,9 +116,14 @@ describe('loop-child reactive attr vs a loop INDEX param shadowing a module cons
99
116
  }
100
117
  `)
101
118
 
102
- // The per-item attribute effect must read the row's OWN index
103
- // closure variable never the outer module const's baked literal.
104
- expect(js).toContain("const __v = i;")
119
+ // The per-item attribute effect must read the row's OWN, LIVE index
120
+ // (#2859 follow-on: `entry.index`, tracked by the lazy-row reconciler
121
+ // the same way it tracks `entry.item`) never the outer module
122
+ // const's baked literal, and never a frozen closure variable that
123
+ // would go stale after a reorder.
124
+ expect(js).toContain('const i = __e.index')
125
+ expect(js).toContain('const __x = i')
105
126
  expect(js).not.toContain("const __v = 'MODULE_CONST';")
127
+ expect(js).not.toContain("const __x = 'MODULE_CONST'")
106
128
  })
107
129
  })
@@ -71,9 +71,11 @@ describe('#1665 — keyless whole-item conditional emits valid JS (BF023)', () =
71
71
  const { js, errorCodes } = clientJsFor('sel() === t.id && <li>{t.id}</li>', /* withKey */ false)
72
72
  expect(errorCodes).toContain(ErrorCodes.MISSING_KEY_IN_LIST)
73
73
  // No empty template-literal interpolation, and the anchor falls back to the
74
- // iteration index so the comment value is well-formed.
74
+ // iteration index so the comment value is well-formed. `__idx` is bound to
75
+ // an index ACCESSOR at runtime (#2859), so the fallback calls it like
76
+ // every other reference to the loop's index parameter.
75
77
  expect(js).not.toContain('${}')
76
- expect(js).toContain('bf-loop-i:${__idx}')
78
+ expect(js).toContain('bf-loop-i:${__idx()}')
77
79
  // The whole client module must parse.
78
80
  const body = js.replace(/^import[^\n]*\n/gm, '').replace(/^export /gm, '')
79
81
  expect(() => new Function(body)).not.toThrow()
@@ -60,7 +60,7 @@ describe('.map() array-builder body — JS-runtime verbatim (Stage 3 / D4)', ()
60
60
  // Byte-level parity is pinned by the map-array-builder-escaping fixture.
61
61
  const r = compile(arrayBuilder, false)
62
62
  const cj = r.files.find(f => f.type === 'clientJs')!.content
63
- expect(cj).toMatch(/out\.push\(`<td>\$\{escapeText\(\(c\)\)\}<\/td>`\)/)
63
+ expect(cj).toMatch(/out\.push\(`<td>\$\{escapeText\(c\)\}<\/td>`\)/)
64
64
  })
65
65
 
66
66
  test('the element-array child {out} is joined into the row', () => {
@@ -63,7 +63,11 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
63
63
  // The renderItem body binds the alias before using it.
64
64
  expect(content).toContain('const i = __innerIdx')
65
65
  const aliasIdx = content.indexOf('const i = __innerIdx')
66
- const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i))`)
66
+ // `i` is bound to an index ACCESSOR at runtime (#2859)`mapArray` hands
67
+ // every renderItem an index accessor (mirroring the item accessor) and
68
+ // pushes the row's current position through it on reorder, so `i()` reads
69
+ // the live value instead of a plain number frozen at row creation.
70
+ const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i()))`)
67
71
  expect(aliasIdx).toBeGreaterThanOrEqual(0)
68
72
  expect(setAttrIdx).toBeGreaterThan(aliasIdx)
69
73
  // The alias must land before the clone IIFE too — the cloned template's
@@ -156,7 +160,12 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
156
160
  `)
157
161
 
158
162
  expect(content).toContain('const i = __innerIdx')
159
- expect(content).toContain('setClicked(i)')
163
+ // A direct (non-delegated) listener on a nested-loop row reads the row's
164
+ // LIVE index accessor (#2859) — unlike the top-level delegated click
165
+ // dispatcher (`event-delegation-index-param.test.ts`), this handler is
166
+ // attached once at row creation, so without this it would keep firing
167
+ // with the index the row was first created at after a same-key reorder.
168
+ expect(content).toContain('setClicked(i())')
160
169
  })
161
170
 
162
171
  test('byte-stability: index declared but never referenced emits no alias', () => {
@@ -278,7 +287,9 @@ describe('nested .map() index param referenced in key/text/attr (#2218)', () =>
278
287
  expect(content).toContain('(item, i) => String(i)')
279
288
  expect(content).toContain('const i = __bidx')
280
289
  const aliasIdx = content.indexOf('const i = __bidx')
281
- const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i))`)
290
+ // `i` reads the live index accessor (#2859) — see the sibling assertion
291
+ // in the 'key={i}' test above.
292
+ const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i()))`)
282
293
  expect(aliasIdx).toBeGreaterThanOrEqual(0)
283
294
  expect(setAttrIdx).toBeGreaterThan(aliasIdx)
284
295
  })
@@ -394,8 +394,10 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
394
394
  // The helper call must appear inside a createEffect alongside the
395
395
  // textContent write — `labelAt(` also appears in the static template
396
396
  // clone, so asserting it independently would pass even with the
397
- // effect missing (the exact regression here).
398
- expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(labelAt\(pi\)\)\)/)
397
+ // effect missing (the exact regression here). `pi` reads the row's
398
+ // live index accessor (#2859), so `labelAt(pi())` also stays correct
399
+ // if this innermost loop's rows ever survive a same-key reorder.
400
+ expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?__bfw_\w+\('s\d+', String\(labelAt\(pi\(\)\)\)\)/)
399
401
  expect(content).toContain("setAttribute('class'")
400
402
  })
401
403
  })
@@ -266,17 +266,20 @@ describe('reactive attributes inside .map() callbacks', () => {
266
266
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
267
267
 
268
268
  const clientJs = result.files.find(f => f.type === 'clientJs')!.content
269
- // The per-item style binding must be wired through a createEffect that
270
- // re-reads the helper and writes the style attribute, so it tracks the
271
- // `items()` signal and updates after hydration. Pin the helper call
272
- // *inside* the createEffect alongside the style write `widthAt(` also
269
+ // The per-item style binding must be wired reactively re-reading the
270
+ // helper and writing the style attribute, so it tracks the `items()`
271
+ // signal and updates after hydration, rather than being baked into the
272
+ // item template once. This row has no ref/child component/inner loop,
273
+ // so it is lazy-eligible (#2859 follow-on): `widthAt(i)` reads the
274
+ // index (forcing `applyItem`) and closes over `items()` opaquely
275
+ // (forcing `applyOuter` too, via the re-subscribe seam), so BOTH apply
276
+ // bodies re-evaluate it, not a `createEffect`. Pin the helper call
277
+ // *inside* `applyItem` alongside the style write — `widthAt(` also
273
278
  // appears in the static template clone, so asserting it independently
274
- // could pass even if the effect was missing (the exact regression here).
275
- // The index resolves to the loop's renderItem index param, in scope
276
- // inside the factory.
277
- expect(clientJs).toMatch(
278
- /createEffect\(\(\)\s*=>\s*\{[\s\S]*?widthAt\([\s\S]*?setAttribute\('style'/,
279
- )
279
+ // could pass even if the reactive wiring were missing (the exact
280
+ // regression here).
281
+ const applyItem = clientJs.slice(clientJs.indexOf('applyItem:'), clientJs.indexOf('applyOuter:'))
282
+ expect(applyItem).toMatch(/widthAt\([\s\S]*?setAttribute\('style'/)
280
283
  })
281
284
 
282
285
  test('keyed loop: `key` prop is not emitted as a reactive DOM attribute', () => {
@@ -124,3 +124,40 @@ export { Comp }
124
124
  expect(js).toContain("items.map((tag) => tag.a).join(',') + _p.tag")
125
125
  })
126
126
  })
127
+
128
+ // #2828 follow-up (pullfrog review on #2853): `collectBranchLocalPropRefsViaSubstitution`
129
+ // (jsx-to-ir.ts) is a second, independent AST walk that decides whether a
130
+ // branch-local variable's TRANSITIVE prop dependency gets unioned into a
131
+ // reactive expression's rewrite — same "is this a value reference" question
132
+ // #2828 fixed in `collectAstPropRefs`, but this walk had its own,
133
+ // un-fixed `isShorthand` exclusion. A branch-local whose own initializer
134
+ // reads a prop, referenced via object-literal shorthand inside a REACTIVE
135
+ // expression, had its prop dependency silently dropped: the emitted
136
+ // module-scope hydrate template lambda kept a bare, unprefixed reference
137
+ // to the prop name instead of `_p.<prop>`.
138
+ describe('branch-local transitive prop-dep via shorthand reference (#2828 follow-up)', () => {
139
+ test('a branch-local read from a prop, referenced via shorthand, still resolves to _p.<prop> in the hydrate template', () => {
140
+ const source = `
141
+ "use client"
142
+ import { createSignal } from '@barefootjs/client'
143
+ export function Comp({ tag }: { tag: string }) {
144
+ const [n] = createSignal(0)
145
+ if (n() > 0) {
146
+ const local = tag
147
+ return <div data-x={JSON.stringify({ local }) + n()}>{n()}</div>
148
+ }
149
+ return <div>none</div>
150
+ }
151
+ `
152
+ const js = compileClientJs(source)
153
+ // Pre-fix: the hydrate template's module-scope lambda kept a bare
154
+ // `tag` reference (no binding at that scope -- ReferenceError at pure
155
+ // CSR mount). Post-fix: it resolves through `_p.tag` like every other
156
+ // prop reference. (The surrounding `{ (_p.tag) }` shape -- the branch-
157
+ // local substitution dropping the shorthand's own key -- is a
158
+ // separate, pre-existing defect tracked independently; this test pins
159
+ // only the prop-dependency question #2828's follow-up fixed.)
160
+ expect(js).not.toContain('{ (tag) }')
161
+ expect(js).toContain('{ (_p.tag) }')
162
+ })
163
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Direct unit tests for `spliceChildValue`'s arm order (#2795 follow-up).
3
+ *
4
+ * The door's arm order changed during the #2795 migration:
5
+ * `node.joinArrayChild` is now checked BEFORE `cx.branchSlotsVar`. Before,
6
+ * `irToHtmlTemplate` wrapped an array in `__bfSlot` first and joined after —
7
+ * for an element-array child (`{out}`, built by an arbitrary `.map()`
8
+ * preamble) rendered INSIDE a conditional branch, that would have handed
9
+ * `__bfSlot` the whole array-join expression, which stringifies an array
10
+ * argument via `String(...)`-style coercion internally rather than joining
11
+ * compiled-leaf HTML — corrupting already-escaped leaf markup. Joining first
12
+ * (the current order) hands `__bfSlot` a single already-assembled HTML
13
+ * string instead.
14
+ *
15
+ * PR #2858 shipped this reorder without a test exercising the co-occurrence
16
+ * (join-array-child AND branch-slot at once) — this file is that test,
17
+ * added directly against the door function rather than a full JSX compile,
18
+ * since the arm order is entirely `spliceChildValue`'s own concern.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { spliceChildValue } from '../ir-to-client-js/safe-html'
23
+
24
+ describe('spliceChildValue arm order', () => {
25
+ test('joinArrayChild wins over branchSlotsVar — the array is joined, not handed to __bfSlot', () => {
26
+ const result = spliceChildValue(
27
+ { expr: 'out', slotId: null, joinArrayChild: true },
28
+ 'out',
29
+ { branchSlotsVar: 'slots' },
30
+ )
31
+ expect(result).toBe(`Array.isArray(out) ? out.join('') : (out ?? '')`)
32
+ expect(result).not.toContain('__bfSlot')
33
+ })
34
+
35
+ test('branchSlotsVar alone (no joinArrayChild) still routes through __bfSlot', () => {
36
+ const result = spliceChildValue(
37
+ { expr: 'value', slotId: null, joinArrayChild: false },
38
+ 'value',
39
+ { branchSlotsVar: 'slots' },
40
+ )
41
+ expect(result).toBe('__bfSlot(value, slots)')
42
+ })
43
+
44
+ test('neither joinArrayChild nor branchSlotsVar nor slotId nor children — plain escapeText default', () => {
45
+ const result = spliceChildValue(
46
+ { expr: 'label', slotId: null, joinArrayChild: false },
47
+ 'label',
48
+ {},
49
+ )
50
+ expect(result).toBe('escapeText(label)')
51
+ })
52
+
53
+ test('a markup-claimed slotId still escapes-or-unwraps even with joinArrayChild absent', () => {
54
+ const result = spliceChildValue(
55
+ { expr: 'title', slotId: 's0', joinArrayChild: false },
56
+ '(title)',
57
+ { markupSlotIds: new Set(['s0']) },
58
+ )
59
+ expect(result).toBe('escapeTextOrMarkup((title))')
60
+ })
61
+ })
@@ -499,6 +499,65 @@ describe('extractSsrDefaults', () => {
499
499
  // it does not abort the whole computation.
500
500
  expect(defaults?.x).toEqual({ value: [1, null] })
501
501
  })
502
+
503
+ // #2813 (pullfrog nitpick on the PR fixing it): a bare local-const alias
504
+ // of a signal getter must be seeded under its OWN name too, alongside
505
+ // the getter's — a template-stash adapter's `.map()` loop over
506
+ // `items__alias()` looks up the stash entry by that name, which the
507
+ // getter-name-only seeding above never creates.
508
+ test('a bare local-const alias of a signal getter is seeded under the alias name too', () => {
509
+ const metadata = metadataFor(`
510
+ 'use client'
511
+ import { createSignal } from '@barefootjs/client'
512
+ function C() {
513
+ const [items] = createSignal([1, 2, 3])
514
+ const items__alias = items
515
+ return <ul>{items__alias().map(n => <li key={n}>{n}</li>)}</ul>
516
+ }
517
+ `)
518
+
519
+ const defaults = extractSsrDefaults(metadata)
520
+ expect(defaults?.items).toEqual({ value: [1, 2, 3] })
521
+ expect(defaults?.items__alias).toEqual({ value: [1, 2, 3] })
522
+ })
523
+
524
+ test('a multi-hop local-const alias chain resolves through every link', () => {
525
+ const metadata = metadataFor(`
526
+ 'use client'
527
+ import { createSignal } from '@barefootjs/client'
528
+ function C() {
529
+ const [items] = createSignal([1, 2, 3])
530
+ const hop1 = items
531
+ const hop2 = hop1
532
+ return <ul>{hop2().map(n => <li key={n}>{n}</li>)}</ul>
533
+ }
534
+ `)
535
+
536
+ const defaults = extractSsrDefaults(metadata)
537
+ expect(defaults?.items).toEqual({ value: [1, 2, 3] })
538
+ expect(defaults?.hop1).toEqual({ value: [1, 2, 3] })
539
+ expect(defaults?.hop2).toEqual({ value: [1, 2, 3] })
540
+ })
541
+
542
+ test('a getter alias shares the ORIGIN entry object, including a self-derivation-collision propName shape', () => {
543
+ // The origin signal's OWN name collides with the prop its initializer
544
+ // derives from (#2669), so its entry is a propName-carrying prop
545
+ // entry, not a plain `{ value }` — the alias must share that exact
546
+ // shape (same object reference), not re-derive a plain value entry.
547
+ const metadata = metadataFor(`
548
+ 'use client'
549
+ import { createSignal } from '@barefootjs/client'
550
+ function C(props: { label?: string }) {
551
+ const [label] = createSignal(props.label ?? 'Default')
552
+ const labelAlias = label
553
+ return <p>{labelAlias()}</p>
554
+ }
555
+ `)
556
+
557
+ const defaults = extractSsrDefaults(metadata)
558
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
559
+ expect(defaults?.labelAlias).toBe(defaults?.label)
560
+ })
502
561
  })
503
562
 
504
563
  // TS twin of the Ruby/Python/PHP/Perl/Rust `derive*FromDefaults` runtime
package/src/analyzer.ts CHANGED
@@ -1998,6 +1998,9 @@ const CLIENT_EXPORTS = new Set([
1998
1998
  'forwardProps', 'unwrap', '__slot',
1999
1999
  'createContext', 'useContext', 'provideContext',
2000
2000
  'createPortal', 'isSSRPortal', 'findSiblingSlot', 'cleanupPortalPlaceholder',
2001
+ // Floating-element position tracking (#2848) — same runtime-only shape
2002
+ // as the portal entries above.
2003
+ 'trackPosition',
2001
2004
  // Request-scoped environment signal factory (router v0.5) — `createSignal`-
2002
2005
  // shaped, recognised structurally (#2057) so its getter is just a signal
2003
2006
  // getter; the compiler lowers the reader value per adapter via the signal's
@@ -4221,6 +4224,7 @@ export const BROWSER_ONLY_CLIENT_APIS = new Set([
4221
4224
  'isSSRPortal',
4222
4225
  'findSiblingSlot',
4223
4226
  'cleanupPortalPlaceholder',
4227
+ 'trackPosition',
4224
4228
  ])
4225
4229
 
4226
4230
  function importsBrowserOnlyClientApi(ctx: AnalyzerContext): boolean {
package/src/index.ts CHANGED
@@ -11,10 +11,16 @@ export type { CompileResult, CompileOptions, CompileOptionsWithAdapter, FileOutp
11
11
  // SSR template-variable defaults (manifest seeds for stash-based adapters)
12
12
  export { extractSsrDefaults, deriveStashFromDefaults } from './ssr-defaults.ts'
13
13
 
14
- // Shared props-destructure binding + alias-map helpers (#2524)
15
- export { propsDestructureBinding, buildPropAliasMap, isIdentifierName } from './props-binding.ts'
14
+ // Shared props-destructure binding + alias-map helpers (#2524, #2788)
15
+ export { propsDestructureBinding, buildPropAliasMap, isIdentifierName, resolveBodyDestructuredPropAliases } from './props-binding.ts'
16
16
  export type { SsrDefault } from './ssr-defaults.ts'
17
17
 
18
+ // Bare local-const alias-hop resolution for a signal/memo getter name
19
+ // (`const items__alias = items`) — #2778's CSR-template fix, reused (not
20
+ // re-walked) by SSR-side alias resolution (#2813: DSL stash seeding,
21
+ // Go's zero-arg-call field routing).
22
+ export { resolveGetterAliases, collectAliasableGetterNames } from './ir-to-client-js/csr-substitute.ts'
23
+
18
24
  // Import-alias resolution (#2777 client-JS registry key; #2822 SSR cross-template
19
25
  // call names) — a local alias -> declared/exported name map built from
20
26
  // `ir.metadata.imports`. DSL adapters build this once per compile and look up
@@ -309,7 +309,7 @@ export function collectInnerLoops(
309
309
  // Forward destructured bindings (#951) so inner-loop template
310
310
  // references to the destructured locals are rewritten.
311
311
  const loopParamsForTemplate = outerLoopParam
312
- ? [outerLoopParam, { param: n.param, bindings: n.paramBindings }]
312
+ ? [outerLoopParam, { param: n.param, bindings: n.paramBindings, index: n.index }]
313
313
  : undefined
314
314
  const template = n.children.map(c => irToPlaceholderTemplate(c, undefined, emitDepth, loopParamsForTemplate)).join('')
315
315
  // Check if array expression references the outer loop param
@@ -755,6 +755,18 @@ export function collectElements(
755
755
  // avoiding post-hoc regex wrapping that corrupts literal attribute values.
756
756
  // Forward destructured bindings (#951) so references like `cfg.color`
757
757
  // in the emitted template literal are rewritten to `__bfItem()[1].color`.
758
+ // NOT `index: l.index` here (#2859 follow-up): this exact template
759
+ // string is reused verbatim by the LAZY row plan when this loop is
760
+ // lazy-eligible (`LazyRowPlanData`'s clone source, and — via
761
+ // `mapPreambleWrapped` — its `preambleStatements`) — `mapArrayLazy`'s
762
+ // `createRow(entry, index)` still hands the row a plain NUMBER
763
+ // (map-array-lazy.ts is untouched by #2859), so baking `i()` in here
764
+ // would call a number. Lazy-row eligibility only refuses a REACTIVE
765
+ // binding that references the index — a non-reactive use like a
766
+ // `data-key` built from the index is not gated the same way, so this
767
+ // can't assume "lazy-eligible ⇒ no index in the template" either.
768
+ // `buildPlainLoopPlan`/`buildBranchLoopPlan` apply the index wrap
769
+ // themselves, AFTER lazy eligibility is decided, only when it's `null`.
758
770
  const loopParamSpec = [{ param: l.param, bindings: l.paramBindings }]
759
771
  template = useElementReconciliation
760
772
  ? irToPlaceholderTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpec)
@@ -1165,6 +1177,10 @@ function collectBranchLoops(
1165
1177
  // keeping the template consistent with reactive effect expressions that
1166
1178
  // use `param()` to read the current item value.
1167
1179
  let childTemplate: string
1180
+ // NOT `index: n.index` here — see the identical note on `loopParamSpec`
1181
+ // above; `BranchLoop.template` is reused verbatim by the lazy row plan
1182
+ // too (`callSite: 'branch-plain'`), and `buildBranchLoopPlan` applies
1183
+ // the index wrap itself once lazy eligibility is known (#2859 follow-up).
1168
1184
  const branchLoopParamSpec = [{ param: n.param, bindings: n.paramBindings }]
1169
1185
  if (projectionInner) {
1170
1186
  childTemplate = '' // descriptor renderItem builds from d.h, not a row template
@@ -1478,7 +1494,7 @@ export function collectLoopChildConditionals(
1478
1494
  if (!readsPreamble && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1479
1495
 
1480
1496
  const loopParamsForCond = loopParam
1481
- ? [{ param: loopParam, bindings: loopParamBindings }]
1497
+ ? [{ param: loopParam, bindings: loopParamBindings, index: loopIndex }]
1482
1498
  : undefined
1483
1499
  // `__slots` matches the closure variable emitted by
1484
1500
  // `stringifyLoopChildConditional` — Child-position interpolations