@barefootjs/jsx 0.18.5 → 0.19.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 (96) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +77 -0
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts +22 -0
  4. package/dist/adapters/loop-bound-names.d.ts.map +1 -0
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/expression-parser.d.ts +8 -0
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1002 -284
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  21. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  32. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  34. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/imports.d.ts +2 -2
  36. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/phases/props-extraction.d.ts +0 -1
  38. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  40. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  43. package/dist/ir-to-client-js/types.d.ts +21 -0
  44. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  45. package/dist/jsx-to-ir.d.ts.map +1 -1
  46. package/dist/signal-init-eval.d.ts +82 -0
  47. package/dist/signal-init-eval.d.ts.map +1 -0
  48. package/dist/static-literal.d.ts +45 -0
  49. package/dist/static-literal.d.ts.map +1 -0
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  52. package/src/__tests__/client-js-generation.test.ts +40 -0
  53. package/src/__tests__/create-selector.test.ts +110 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  55. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  56. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  57. package/src/__tests__/loop-bound-names.test.ts +102 -0
  58. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  59. package/src/__tests__/map-function-reference.test.ts +303 -0
  60. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  61. package/src/__tests__/props-destructuring.test.ts +61 -8
  62. package/src/__tests__/signal-init-eval.test.ts +138 -0
  63. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  64. package/src/__tests__/static-literal.test.ts +126 -0
  65. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  66. package/src/adapters/dangerous-inner-html.ts +186 -0
  67. package/src/adapters/loop-bound-names.ts +80 -0
  68. package/src/adapters/parsed-expr-emitter.ts +9 -0
  69. package/src/analyzer.ts +36 -22
  70. package/src/expression-parser.ts +33 -0
  71. package/src/index.ts +20 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  74. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  76. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  77. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  78. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  79. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  80. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  81. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  82. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  83. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  85. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  86. package/src/ir-to-client-js/html-template.ts +235 -2
  87. package/src/ir-to-client-js/imports.ts +1 -0
  88. package/src/ir-to-client-js/phases/props-extraction.ts +5 -9
  89. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  90. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  91. package/src/ir-to-client-js/reactivity.ts +6 -0
  92. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  93. package/src/ir-to-client-js/types.ts +21 -0
  94. package/src/jsx-to-ir.ts +230 -39
  95. package/src/signal-init-eval.ts +165 -0
  96. package/src/static-literal.ts +128 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * A NESTED (inner) `.map()` callback closing over its own `.map((item, i) =>
3
+ * ...)` index param (#2218). Sibling of `event-delegation-index-param.test.ts`
4
+ * (#2189), which fixed the same class of bug for delegated event handlers —
5
+ * this covers the emit path #2189 did NOT touch: the nested-loop `key`,
6
+ * reactive text/attr, and template-interpolation emit path in
7
+ * `control-flow/plan/build-inner-loop.ts` / `stringify/inner-loop.ts` (and
8
+ * the conditional-branch-arm sibling in `build-loop-child-arm.ts` /
9
+ * `stringify/loop-child-arm.ts`).
10
+ *
11
+ * Root cause: `NestedLoop` never carried an `index` field, so `loopKeyFn`
12
+ * dropped the index param from the keyFn's own parameter list, and the
13
+ * renderItem body only ever received the index as a synthetic
14
+ * `__innerIdx<uid>` positional param — the user's index name (e.g. `i`) was
15
+ * never bound to it, so any reference threw `ReferenceError: i is not
16
+ * defined` and the inner loop rendered nothing.
17
+ *
18
+ * Gating (byte-for-byte stability, same philosophy as #2189): the alias
19
+ * `const i = __innerIdx<uid>` is only emitted when something in the inner
20
+ * loop body actually references the index — see `nestedLoopReferencesIndex`
21
+ * in `control-flow/shared.ts`.
22
+ */
23
+
24
+ import { describe, test, expect } from 'bun:test'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ const adapter = new TestAdapter()
29
+
30
+ function clientJsFor(source: string): string {
31
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
32
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
33
+ const clientJs = result.files.find(f => f.type === 'clientJs')
34
+ expect(clientJs).toBeDefined()
35
+ return clientJs!.content
36
+ }
37
+
38
+ describe('nested .map() index param referenced in key/text/attr (#2218)', () => {
39
+ test('key={i}: keyFn includes the index param and renderItem binds the alias', () => {
40
+ const content = clientJsFor(`
41
+ 'use client'
42
+ import { createSignal } from '@barefootjs/client'
43
+ interface Item { id: number; text: string }
44
+ interface Group { id: number; items: Item[] }
45
+ export function Repro() {
46
+ const [outer] = createSignal<Group[]>([])
47
+ return (
48
+ <div>
49
+ {outer().map((o) => (
50
+ <div key={o.id}>
51
+ {o.items.map((item, i) => (
52
+ <span key={i}>{item.text}</span>
53
+ ))}
54
+ </div>
55
+ ))}
56
+ </div>
57
+ )
58
+ }
59
+ `)
60
+
61
+ // keyFn threads the index param, mirroring TopLevelLoop/BranchLoop.
62
+ expect(content).toContain('(item, i) => String(i)')
63
+ // The renderItem body binds the alias before using it.
64
+ expect(content).toContain('const i = __innerIdx')
65
+ const aliasIdx = content.indexOf('const i = __innerIdx')
66
+ const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i))`)
67
+ expect(aliasIdx).toBeGreaterThanOrEqual(0)
68
+ expect(setAttrIdx).toBeGreaterThan(aliasIdx)
69
+ // The alias must land before the clone IIFE too — the cloned template's
70
+ // `data-key-1="${i}"` interpolation reads it.
71
+ const cloneIdx = content.indexOf('__t.innerHTML')
72
+ expect(cloneIdx).toBeGreaterThan(aliasIdx)
73
+ })
74
+
75
+ test('index referenced in a template text interpolation is bound and rendered', () => {
76
+ const content = clientJsFor(`
77
+ 'use client'
78
+ import { createSignal } from '@barefootjs/client'
79
+ interface Item { id: number; text: string }
80
+ interface Group { id: number; items: Item[] }
81
+ export function Repro() {
82
+ const [outer] = createSignal<Group[]>([])
83
+ return (
84
+ <div>
85
+ {outer().map((o) => (
86
+ <div key={o.id}>
87
+ {o.items.map((item, i) => (
88
+ <span key={item.id}>{i}: {item.text}</span>
89
+ ))}
90
+ </div>
91
+ ))}
92
+ </div>
93
+ )
94
+ }
95
+ `)
96
+
97
+ expect(content).toContain('const i = __innerIdx')
98
+ // The template's `${i}` interpolation is emitted (escaped for text).
99
+ expect(content).toContain('escapeText(i)')
100
+ const aliasIdx = content.indexOf('const i = __innerIdx')
101
+ // The outer element's own clone IIFE also bakes the initial nested HTML
102
+ // via a plain (unrelated, arrow-scoped) `.map((item, i) => ...)` — so
103
+ // `escapeText(i)` appears once there too. Look for the occurrence
104
+ // *after* the alias, inside the inner renderItem's own clone template.
105
+ const templateIdx = content.indexOf('escapeText(i)', aliasIdx)
106
+ expect(templateIdx).toBeGreaterThan(aliasIdx)
107
+ })
108
+
109
+ test('index referenced in a reactive attribute expression is bound', () => {
110
+ const content = clientJsFor(`
111
+ 'use client'
112
+ import { createSignal } from '@barefootjs/client'
113
+ interface Item { id: number; text: string; active: boolean }
114
+ interface Group { id: number; items: Item[] }
115
+ export function Repro() {
116
+ const [outer] = createSignal<Group[]>([])
117
+ return (
118
+ <div>
119
+ {outer().map((o) => (
120
+ <div key={o.id}>
121
+ {o.items.map((item, i) => (
122
+ <span key={item.id} className={i % 2 === 0 ? 'even' : 'odd'}>{item.text}</span>
123
+ ))}
124
+ </div>
125
+ ))}
126
+ </div>
127
+ )
128
+ }
129
+ `)
130
+
131
+ expect(content).toContain('const i = __innerIdx')
132
+ expect(content).toContain(`i % 2 === 0 ? 'even' : 'odd'`)
133
+ })
134
+
135
+ test('index referenced only in an event handler inside the inner loop is bound', () => {
136
+ const content = clientJsFor(`
137
+ 'use client'
138
+ import { createSignal } from '@barefootjs/client'
139
+ interface Item { id: number; text: string }
140
+ interface Group { id: number; items: Item[] }
141
+ export function Repro() {
142
+ const [outer] = createSignal<Group[]>([])
143
+ const [clicked, setClicked] = createSignal(-1)
144
+ return (
145
+ <div>
146
+ {outer().map((o) => (
147
+ <div key={o.id}>
148
+ {o.items.map((item, i) => (
149
+ <span key={item.id} onClick={() => setClicked(i)}>{item.text}</span>
150
+ ))}
151
+ </div>
152
+ ))}
153
+ </div>
154
+ )
155
+ }
156
+ `)
157
+
158
+ expect(content).toContain('const i = __innerIdx')
159
+ expect(content).toContain('setClicked(i)')
160
+ })
161
+
162
+ test('byte-stability: index declared but never referenced emits no alias', () => {
163
+ const content = clientJsFor(`
164
+ 'use client'
165
+ import { createSignal } from '@barefootjs/client'
166
+ interface Item { id: number; text: string }
167
+ interface Group { id: number; items: Item[] }
168
+ export function Repro() {
169
+ const [outer] = createSignal<Group[]>([])
170
+ return (
171
+ <div>
172
+ {outer().map((o) => (
173
+ <div key={o.id}>
174
+ {o.items.map((item, i) => (
175
+ <span key={item.id}>{item.text}</span>
176
+ ))}
177
+ </div>
178
+ ))}
179
+ </div>
180
+ )
181
+ }
182
+ `)
183
+
184
+ expect(content).not.toContain('const i = __innerIdx')
185
+ // The keyFn also has no reason to thread `i` — the key doesn't use it.
186
+ expect(content).toContain('(item, i) => String(item.id)')
187
+ })
188
+
189
+ test('index named like a property is not bound when only the property is used', () => {
190
+ // Mirrors the #2189 test of the same name: gating is AST-based (free
191
+ // identifiers), so `item.id` (a property access) never false-matches
192
+ // an index param literally named `id`.
193
+ const content = clientJsFor(`
194
+ 'use client'
195
+ import { createSignal } from '@barefootjs/client'
196
+ interface Item { id: number; text: string }
197
+ interface Group { id: number; items: Item[] }
198
+ export function Repro() {
199
+ const [outer] = createSignal<Group[]>([])
200
+ return (
201
+ <div>
202
+ {outer().map((o) => (
203
+ <div key={o.id}>
204
+ {o.items.map((item, id) => (
205
+ <span key={item.id}>{item.text}</span>
206
+ ))}
207
+ </div>
208
+ ))}
209
+ </div>
210
+ )
211
+ }
212
+ `)
213
+
214
+ expect(content).not.toContain('const id = __innerIdx')
215
+ })
216
+
217
+ test('static inner array (no signal dependency) also binds the index alias', () => {
218
+ // Sibling of the reactive-path test above, exercising `emitStatic` in
219
+ // `stringify/inner-loop.ts` (the `forEach` renderItem path used when the
220
+ // inner array does not reference the outer loop param reactively).
221
+ const content = clientJsFor(`
222
+ 'use client'
223
+ import { createSignal } from '@barefootjs/client'
224
+ interface Item { id: number; n: number }
225
+ const SHARED: Item[] = [{ id: 1, n: 7 }]
226
+ function MyChild(props: { tag: string }) {
227
+ return <span>{props.tag}</span>
228
+ }
229
+ export function Repro() {
230
+ const [rows, setRows] = createSignal([{ id: 1, key: 'a' }])
231
+ return (
232
+ <div onClick={() => setRows(prev => [...prev])}>
233
+ {rows().map((row) => (
234
+ <div key={row.id}>
235
+ {SHARED.map((cell, i) => (
236
+ <MyChild key={cell.id} tag={\`\${i}-\${cell.n}\`} />
237
+ ))}
238
+ </div>
239
+ ))}
240
+ </div>
241
+ )
242
+ }
243
+ `)
244
+
245
+ expect(content).toMatch(/SHARED\.forEach\(\(cell, __innerIdx[^)]+\) => \{[\s\S]*?const\s+i\s*=\s*__innerIdx/)
246
+ })
247
+
248
+ test('conditional-branch-arm inner loop (loop -> conditional -> inner loop) binds the index alias', () => {
249
+ // Exercises `build-loop-child-arm.ts::buildBranchInnerLoopsPlan` /
250
+ // `stringify/loop-child-arm.ts::stringifyBranchInnerLoops` — the sibling
251
+ // emit path for an inner loop that lives inside a reactive conditional
252
+ // branch which is itself inside an outer loop's item body.
253
+ const content = clientJsFor(`
254
+ 'use client'
255
+ import { createSignal } from '@barefootjs/client'
256
+ interface Item { id: number; text: string }
257
+ interface Group { id: number; flag: boolean; items: Item[] }
258
+ export function Repro() {
259
+ const [outer] = createSignal<Group[]>([])
260
+ return (
261
+ <div>
262
+ {outer().map((o) => (
263
+ <div key={o.id}>
264
+ {o.flag ? (
265
+ <div>
266
+ {o.items.map((item, i) => (
267
+ <span key={i}>{i}: {item.text}</span>
268
+ ))}
269
+ </div>
270
+ ) : null}
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )
275
+ }
276
+ `)
277
+
278
+ expect(content).toContain('(item, i) => String(i)')
279
+ expect(content).toContain('const i = __bidx')
280
+ const aliasIdx = content.indexOf('const i = __bidx')
281
+ const setAttrIdx = content.indexOf(`setAttribute('data-key-1', String(i))`)
282
+ expect(aliasIdx).toBeGreaterThanOrEqual(0)
283
+ expect(setAttrIdx).toBeGreaterThan(aliasIdx)
284
+ })
285
+ })
@@ -356,21 +356,74 @@ describe('Destructured props keep their declared types (BF043 fixture #2150)', (
356
356
  expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
357
357
  })
358
358
 
359
- // Deliberate scope: only required primitives are resolved. Optional and
360
- // non-primitive members stay `unknown` so typed adapters keep their existing
361
- // interface{}-based lowering (attribute omission, bf_flat/spread/bf_json).
362
- test('leaves OPTIONAL members as unknown (preserves nillable omission)', () => {
359
+ // #2259: optional primitives resolve type AND optionality, same as the
360
+ // props-object path pre-#2259 they were skipped wholesale so typed
361
+ // adapters kept a nillable interface{} field; #2252's nullish-flip
362
+ // machinery now supplies the absent representation where it matters.
363
+ test('resolves OPTIONAL primitive members with optional: true (#2259)', () => {
363
364
  const source = `
364
- type Props = { value?: number }
365
+ type Props = { label: string; size?: number; on?: boolean }
365
366
 
366
- export function Component({ value }: Props) {
367
- return <div>{value}</div>
367
+ export function Component({ label, size, on }: Props) {
368
+ return <div>{label}{size ?? 0}{on ? 'y' : 'n'}</div>
368
369
  }
369
370
  `
370
371
 
371
372
  const ctx = analyzeComponent(source, 'Component.tsx')
372
373
 
373
- expect(typeOf(ctx, 'value')).toEqual({ kind: 'unknown', raw: 'unknown' })
374
+ const param = (name: string) => ctx.propsParams.find((p) => p.name === name)
375
+ expect(param('label')).toMatchObject({
376
+ type: { kind: 'primitive', primitive: 'string' },
377
+ optional: false,
378
+ })
379
+ expect(param('size')).toMatchObject({
380
+ type: { kind: 'primitive', primitive: 'number' },
381
+ optional: true,
382
+ })
383
+ expect(param('on')).toMatchObject({
384
+ type: { kind: 'primitive', primitive: 'boolean' },
385
+ optional: true,
386
+ })
387
+ })
388
+
389
+ // A non-primitive optional keeps `unknown` (interface{}-based lowering)
390
+ // but still reports the type's `?` — the adversarial catalogue derives
391
+ // absent points from `optional` alone.
392
+ test('marks OPTIONAL non-primitive members optional while keeping unknown type (#2259)', () => {
393
+ const source = `
394
+ type Todo = { id: number }
395
+ type Props = { items?: Todo[] }
396
+
397
+ export function Component({ items }: Props) {
398
+ return <div>{(items ?? []).length}</div>
399
+ }
400
+ `
401
+
402
+ const ctx = analyzeComponent(source, 'Component.tsx')
403
+
404
+ expect(ctx.propsParams.find((p) => p.name === 'items')).toMatchObject({
405
+ type: { kind: 'unknown', raw: 'unknown' },
406
+ optional: true,
407
+ })
408
+ })
409
+
410
+ // A destructure default and the type's `?` both mean "caller may omit";
411
+ // the default keeps `defaultValue` so adapters bake it (and the Go
412
+ // nullish flip keeps excluding defaulted props).
413
+ test('keeps defaultValue alongside optional for `{ size = 5 }` (#2259)', () => {
414
+ const source = `
415
+ export function Component({ size = 5 }: { size?: number }) {
416
+ return <div>{size}</div>
417
+ }
418
+ `
419
+
420
+ const ctx = analyzeComponent(source, 'Component.tsx')
421
+
422
+ expect(ctx.propsParams.find((p) => p.name === 'size')).toMatchObject({
423
+ type: { kind: 'primitive', primitive: 'number' },
424
+ optional: true,
425
+ defaultValue: '5',
426
+ })
374
427
  })
375
428
 
376
429
  test('leaves NON-primitive members (arrays/objects) as unknown', () => {
@@ -0,0 +1,138 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { evaluateSignalInit, tryEvaluateSignalInit } from '../signal-init-eval'
3
+
4
+ // #2209: replaces 7 near-duplicate regex-based `evaluateSignalInit`
5
+ // evaluators (one per template-string adapter's test-render.ts harness)
6
+ // with a single sandboxed real-JS evaluator. Absorbs and supersedes
7
+ // `packages/adapter-mojolicious/src/__tests__/evaluate-signal-init.test.ts`
8
+ // (#1672's pins) plus the actual #2209 repro shape.
9
+ describe('evaluateSignalInit (#2209)', () => {
10
+ test('the #2209 repro: (props.x ?? []).map(t => ({ ...t, editing: false }))', () => {
11
+ const expr = `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`
12
+ expect(evaluateSignalInit(expr, { initialTodos: [{ id: 1, done: false }] })).toEqual([
13
+ { id: 1, done: false, editing: false },
14
+ ])
15
+ expect(evaluateSignalInit(expr, undefined)).toEqual([])
16
+ expect(evaluateSignalInit(expr, {})).toEqual([])
17
+ })
18
+
19
+ test('an object-spread override applies after the spread (matches JS semantics)', () => {
20
+ const expr = `(props.items ?? []).map(t => ({ ...t, done: true }))`
21
+ expect(evaluateSignalInit(expr, { items: [{ id: 1, done: false }] })).toEqual([
22
+ { id: 1, done: true },
23
+ ])
24
+ })
25
+
26
+ // #1672 pins, absorbed from the deleted mojolicious-local test file.
27
+ test('parses an inline object-array initial value', () => {
28
+ expect(evaluateSignalInit(`[{ id: 'a' }, { id: 'b' }, { id: 'c' }]`)).toEqual([
29
+ { id: 'a' },
30
+ { id: 'b' },
31
+ { id: 'c' },
32
+ ])
33
+ })
34
+
35
+ test('parses scalar and mixed arrays, including nested objects', () => {
36
+ expect(evaluateSignalInit(`['x', 'y']`)).toEqual(['x', 'y'])
37
+ expect(evaluateSignalInit(`[1, 2, 3]`)).toEqual([1, 2, 3])
38
+ expect(evaluateSignalInit(`[{ id: 'a', n: 1, ok: true }]`)).toEqual([
39
+ { id: 'a', n: 1, ok: true },
40
+ ])
41
+ })
42
+
43
+ test('still parses scalars, empty array, and props passthrough', () => {
44
+ expect(evaluateSignalInit(`'b'`)).toBe('b')
45
+ expect(evaluateSignalInit(`5`)).toBe(5)
46
+ expect(evaluateSignalInit(`[]`)).toEqual([])
47
+ expect(evaluateSignalInit(`props.value`, { value: 42 })).toBe(42)
48
+ })
49
+
50
+ test('bails to null for arrays with non-literal, unbound elements', () => {
51
+ expect(evaluateSignalInit(`[foo(), bar]`)).toBeNull()
52
+ })
53
+
54
+ // Additional shapes the old regex evaluators supported.
55
+ test('props.x ?? default with x present, absent, or explicitly null', () => {
56
+ expect(evaluateSignalInit(`props.x ?? 7`, { x: 3 })).toBe(3)
57
+ expect(evaluateSignalInit(`props.x ?? 7`, {})).toBe(7)
58
+ expect(evaluateSignalInit(`props.x ?? 7`, undefined)).toBe(7)
59
+ // Fix over the old regex evaluators: JS `??` also falls through on an
60
+ // explicit `null`/`undefined` prop value — the old regex only checked
61
+ // `propName in props` and returned the raw (null) value, which was
62
+ // actually a bug relative to real JS `??` semantics.
63
+ expect(evaluateSignalInit(`props.x ?? 7`, { x: null })).toBe(7)
64
+ })
65
+
66
+ test('negative and decimal number literals', () => {
67
+ expect(evaluateSignalInit(`-7.6`)).toBe(-7.6)
68
+ expect(evaluateSignalInit(`42`)).toBe(42)
69
+ })
70
+
71
+ test('booleans', () => {
72
+ expect(evaluateSignalInit(`true`)).toBe(true)
73
+ expect(evaluateSignalInit(`false`)).toBe(false)
74
+ })
75
+
76
+ test('an explicit null initializer maps to null (the "skip" outcome)', () => {
77
+ expect(evaluateSignalInit(`null`)).toBeNull()
78
+ })
79
+
80
+ // New coverage for #2209's sandboxing contract.
81
+ test('blocked globals fail deterministically and fall back to null', () => {
82
+ expect(evaluateSignalInit(`Date.now()`)).toBeNull()
83
+ expect(evaluateSignalInit(`Math.random()`)).toBeNull()
84
+ expect(evaluateSignalInit(`typeof window`)).not.toBe('object')
85
+ })
86
+
87
+ test('a TS-only remnant that fails to parse as an expression falls back to null', () => {
88
+ expect(evaluateSignalInit(`foo as Bar`)).toBeNull()
89
+ })
90
+
91
+ test('a non-transportable value (class instance) falls back to null', () => {
92
+ expect(evaluateSignalInit(`new Set([1, 2])`)).toBeNull()
93
+ })
94
+
95
+ test('a bare destructured-prop identifier (not props.x) is unbound and falls back to null', () => {
96
+ expect(evaluateSignalInit(`count`, { count: 5 })).toBeNull()
97
+ })
98
+
99
+ test('tryEvaluateSignalInit distinguishes an explicit undefined from a real ok:false', () => {
100
+ expect(tryEvaluateSignalInit(`undefined`)).toEqual({ ok: true, value: undefined })
101
+ expect(tryEvaluateSignalInit(`foo()`)).toEqual({ ok: false })
102
+ // evaluateSignalInit's wrapper collapses both to null:
103
+ expect(evaluateSignalInit(`undefined`)).toBeNull()
104
+ expect(evaluateSignalInit(`foo()`)).toBeNull()
105
+ })
106
+
107
+ // Copilot review (#2229): `Function` is now shadowed too — closes the
108
+ // most direct bypass of the blocked-globals list.
109
+ test('the Function constructor is shadowed', () => {
110
+ expect(evaluateSignalInit(`Function('return 1')()`)).toBeNull()
111
+ })
112
+
113
+ // Copilot review (#2229): `isTransportable` must reject a sparse array
114
+ // (a hole is not the same as an element that's explicitly `undefined` —
115
+ // `Array.prototype.every` silently skips holes, which previously let
116
+ // one through undetected).
117
+ test('a sparse array (a hole, not an explicit undefined) falls back to null', () => {
118
+ // biome-ignore lint: intentional sparse-array literal for the hole test
119
+ expect(evaluateSignalInit(`[1, , 3]`)).toBeNull()
120
+ })
121
+
122
+ // Copilot review (#2229): the same object appearing twice in a
123
+ // non-cyclic shape (shared by reference, not by cycle) is transportable
124
+ // — JSON-equivalent behavior would just duplicate it — and must NOT be
125
+ // rejected by an overly-global "seen" set.
126
+ test('a shared (non-cyclic) reference is transportable', () => {
127
+ expect(evaluateSignalInit(`(() => { const shared = { id: 1 }; return [shared, shared] })()`)).toEqual([
128
+ { id: 1 },
129
+ { id: 1 },
130
+ ])
131
+ })
132
+
133
+ test('a genuine cycle still falls back to null', () => {
134
+ expect(
135
+ evaluateSignalInit(`(() => { const o = { id: 1 }; o.self = o; return o })()`),
136
+ ).toBeNull()
137
+ })
138
+ })
@@ -0,0 +1,180 @@
1
+ /**
2
+ * A FULLY-STATIC nested loop's inner `.map((item, i) => ...)` index param
3
+ * referenced in a child-component prop (#2231). Follow-up to #2218, which
4
+ * fixed the reactive (`mapArray`) and branch-arm paths — this covers the
5
+ * static-array child-init family (`plan/build-static-array-child-init.ts` /
6
+ * `stringify/static-array-child-init.ts`), which those fixes didn't reach
7
+ * because it never goes through `loopKeyFn` or the renderItem alias.
8
+ *
9
+ * Root cause: the `inner-loop-nested` shape hardcoded the synthetic
10
+ * `__innerIdx` as the inner `forEach`'s index param, and the
11
+ * `component-rooted-inner-loop` shape (#1725) declared no index params at
12
+ * all — so a prop getter like `get label() { return i }` threw
13
+ * `ReferenceError: i is not defined` on `initChild`'s first prop read at
14
+ * hydration.
15
+ *
16
+ * Fix: emit the user's declared index name directly as the `forEach` index
17
+ * param — the same idiom the outer loop has always used (`elem.index ||
18
+ * '__idx'`). No alias binding is needed because `forEach` supplies the real
19
+ * array index positionally. Loops that declare no index keep byte-identical
20
+ * output (`__innerIdx` fallback for `inner-loop-nested`, bare single-param
21
+ * heads for `component-rooted-inner-loop`).
22
+ */
23
+
24
+ import { describe, test, expect } from 'bun:test'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ const adapter = new TestAdapter()
29
+
30
+ function clientJsFor(source: string): string {
31
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
32
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
33
+ const clientJs = result.files.find(f => f.type === 'clientJs')
34
+ expect(clientJs).toBeDefined()
35
+ return clientJs!.content
36
+ }
37
+
38
+ describe('static inner-loop index param in child-component props (#2231)', () => {
39
+ test('inner-loop-nested: the inner forEach binds the user index name', () => {
40
+ const content = clientJsFor(`
41
+ 'use client'
42
+ import { Cell } from './cell'
43
+ const OUTER = [
44
+ { id: 1, subs: [{ id: 11 }, { id: 12 }] },
45
+ { id: 2, subs: [{ id: 21 }] },
46
+ ]
47
+ export function Repro() {
48
+ return (
49
+ <div>
50
+ {OUTER.map((o, oi) => (
51
+ <div key={o.id}>
52
+ {o.subs.map((sub, i) => (
53
+ <Cell key={sub.id} label={i} outerPos={oi} />
54
+ ))}
55
+ </div>
56
+ ))}
57
+ </div>
58
+ )
59
+ }
60
+ `)
61
+
62
+ // The inner forEach's index param is the user's name, so the prop
63
+ // getters `return i` / `return oi` resolve.
64
+ expect(content).toContain('o.subs.forEach((sub, i) => {')
65
+ // The inner element offset indexes with the same (renamed) param.
66
+ expect(content).toContain('__ic.children[i]')
67
+ expect(content).toContain('get label() { return i }')
68
+ expect(content).toContain('get outerPos() { return oi }')
69
+ expect(content).not.toContain('__innerIdx')
70
+ })
71
+
72
+ test('inner-loop-nested: no declared index keeps the synthetic __innerIdx (byte stability)', () => {
73
+ const content = clientJsFor(`
74
+ 'use client'
75
+ import { Cell } from './cell'
76
+ const OUTER = [
77
+ { id: 1, subs: [{ id: 11 }, { id: 12 }] },
78
+ ]
79
+ export function Repro() {
80
+ return (
81
+ <div>
82
+ {OUTER.map((o) => (
83
+ <div key={o.id}>
84
+ {o.subs.map((sub) => (
85
+ <Cell key={sub.id} label={sub.id} />
86
+ ))}
87
+ </div>
88
+ ))}
89
+ </div>
90
+ )
91
+ }
92
+ `)
93
+
94
+ expect(content).toContain('o.subs.forEach((sub, __innerIdx) => {')
95
+ expect(content).toContain('__ic.children[__innerIdx]')
96
+ })
97
+
98
+ test('component-rooted-inner-loop: outer AND inner index params are bound', () => {
99
+ const content = clientJsFor(`
100
+ 'use client'
101
+ import { Card, Cell } from './cell'
102
+ const OUTER = [
103
+ { id: 1, subs: [{ id: 11 }, { id: 12 }] },
104
+ ]
105
+ export function Repro2() {
106
+ return (
107
+ <div>
108
+ {OUTER.map((o, oi) => (
109
+ <Card key={o.id}>
110
+ {o.subs.map((sub, i) => (
111
+ <Cell key={sub.id} label={i} outerPos={oi} />
112
+ ))}
113
+ </Card>
114
+ ))}
115
+ </div>
116
+ )
117
+ }
118
+ `)
119
+
120
+ // Document-order zip: both forEach heads carry the declared index names.
121
+ expect(content).toContain('OUTER.forEach((o, oi) => {')
122
+ expect(content).toContain('o.subs.forEach((sub, i) => {')
123
+ expect(content).toContain('get label() { return i }')
124
+ expect(content).toContain('get outerPos() { return oi }')
125
+ })
126
+
127
+ test('component-rooted-inner-loop: no declared index keeps bare single-param heads (byte stability)', () => {
128
+ const content = clientJsFor(`
129
+ 'use client'
130
+ import { Card, Cell } from './cell'
131
+ const OUTER = [
132
+ { id: 1, subs: [{ id: 11 }] },
133
+ ]
134
+ export function Repro2() {
135
+ return (
136
+ <div>
137
+ {OUTER.map((o) => (
138
+ <Card key={o.id}>
139
+ {o.subs.map((sub) => (
140
+ <Cell key={sub.id} label={sub.id} />
141
+ ))}
142
+ </Card>
143
+ ))}
144
+ </div>
145
+ )
146
+ }
147
+ `)
148
+
149
+ expect(content).toContain('OUTER.forEach((o) => {')
150
+ expect(content).toContain('o.subs.forEach((sub) => {')
151
+ })
152
+
153
+ test('index named like a property access is not confused (item.i stays untouched)', () => {
154
+ const content = clientJsFor(`
155
+ 'use client'
156
+ import { Cell } from './cell'
157
+ const OUTER = [
158
+ { id: 1, subs: [{ id: 11, i: 'a' }] },
159
+ ]
160
+ export function Repro() {
161
+ return (
162
+ <div>
163
+ {OUTER.map((o) => (
164
+ <div key={o.id}>
165
+ {o.subs.map((sub) => (
166
+ <Cell key={sub.id} label={sub.i} />
167
+ ))}
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )
172
+ }
173
+ `)
174
+
175
+ // No index declared → synthetic param; the `sub.i` property access is
176
+ // untouched (this fix renames the forEach param, never rewrites bodies).
177
+ expect(content).toContain('o.subs.forEach((sub, __innerIdx) => {')
178
+ expect(content).toContain('get label() { return sub.i }')
179
+ })
180
+ })