@barefootjs/jsx 0.35.0 → 0.35.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 (72) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/index.js +731 -691
  3. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  4. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  7. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -2
  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/inner-loop.d.ts +24 -44
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -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/stringify/inner-loop.d.ts +6 -14
  19. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  21. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  23. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  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 +5 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  28. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/types.d.ts +18 -2
  33. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  34. package/dist/reactivity-checker.d.ts +18 -0
  35. package/dist/reactivity-checker.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +128 -10
  38. package/src/__tests__/binding-scope-ratchet.test.ts +26 -3
  39. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  40. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  41. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  42. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  43. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  44. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  45. package/src/__tests__/nested-loop-index-param.test.ts +27 -5
  46. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  47. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  48. package/src/__tests__/nested-loop-reactive-attrs.test.ts +12 -4
  49. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  50. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  51. package/src/analyzer-context.ts +12 -10
  52. package/src/ir-to-client-js/collect-elements.ts +110 -32
  53. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -0
  54. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +28 -51
  55. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +33 -6
  57. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +7 -1
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +18 -6
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  60. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  61. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  62. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  63. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +12 -15
  64. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  65. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  66. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  67. package/src/ir-to-client-js/emit-reactive.ts +88 -5
  68. package/src/ir-to-client-js/html-template.ts +9 -1
  69. package/src/ir-to-client-js/reactivity.ts +73 -27
  70. package/src/ir-to-client-js/types.ts +19 -3
  71. package/src/jsx-to-ir.ts +21 -4
  72. package/src/reactivity-checker.ts +60 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Codegen-shape pins for the loop-row branch-arm preamble gap (#2596/#2447
3
+ * follow-up, found while discussing #2869's aftermath). Companion to the
4
+ * behavioral test in
5
+ * `packages/client/__tests__/runtime/preamble-branch-attr-staleness.test.ts`,
6
+ * which is the one that actually proves the fix works end to end — this
7
+ * file pins the exact emitted shape so a future refactor of
8
+ * `stringifyLoopChildArm`/`stringifyBranchReactiveAttrs`/
9
+ * `stringifyLoopChildConditional` can't silently drop the preamble re-run
10
+ * again.
11
+ *
12
+ * Two sites were fixed together: a reactive ATTRIBUTE inside a loop row's
13
+ * branch conditional that reads a `.map()` preamble local (case a), and a
14
+ * NESTED conditional's own CONDITION expression doing the same (case b,
15
+ * #2596 one nesting level deeper than the already-fixed outer-conditional
16
+ * case pinned by `preamble-conditional-reactivity.test.ts`). Case c is the
17
+ * negative control: no preamble local in play, so nothing should be
18
+ * injected.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../compiler'
23
+ import { TestAdapter } from '../adapters/test-adapter'
24
+
25
+ const adapter = new TestAdapter()
26
+
27
+ function clientJsFor(source: string, filename: string): string {
28
+ const result = compileJSX(source, filename, { adapter })
29
+ expect(result.errors).toHaveLength(0)
30
+ const clientJs = result.files.find(f => f.type === 'clientJs')?.content
31
+ if (!clientJs) throw new Error('No client JS emitted')
32
+ return clientJs
33
+ }
34
+
35
+ describe('#2596/#2447 follow-up — preamble re-run inside a loop-row branch arm', () => {
36
+ test('(a) an attribute inside a branch arm re-runs the preamble before reading it', () => {
37
+ const source = `'use client'
38
+ import { createSignal } from '@barefootjs/client'
39
+ type Row = { id: number; tier: string }
40
+ export function List() {
41
+ const [visible] = createSignal(true)
42
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
43
+ return (
44
+ <ul>
45
+ {rows().map(row => {
46
+ const cls = 'tier-' + row.tier
47
+ return (
48
+ <li key={row.id}>
49
+ {visible() ? <span class={cls} id="target">{row.tier}</span> : <span>x</span>}
50
+ </li>
51
+ )
52
+ })}
53
+ </ul>
54
+ )
55
+ }
56
+ `
57
+ const js = clientJsFor(source, 'List.tsx')
58
+
59
+ // Pin the eager per-row mapArray path (not lazy-row, not relevant here
60
+ // but keeps this fixture stable if eligibility ever widens).
61
+ expect(js).toContain('mapArray(')
62
+
63
+ // The outer conditional's own getter is NOT preamble-guarded (`visible`
64
+ // is a plain signal, no preamble read) — stays bare.
65
+ expect(js).toContain("insert(__el, 's0', () => visible(), {")
66
+
67
+ // The arm's attr effect re-runs the preamble before reading `cls`.
68
+ const disposerStart = js.indexOf('const __disposers = []')
69
+ const disposerEnd = js.indexOf('return () => __disposers.forEach')
70
+ const arm = js.slice(disposerStart, disposerEnd)
71
+ const preambleAt = arm.indexOf("const cls = 'tier-' + row().tier;")
72
+ const readAt = arm.indexOf('const __x = cls')
73
+ expect(preambleAt).toBeGreaterThanOrEqual(0)
74
+ expect(readAt).toBeGreaterThan(preambleAt)
75
+ })
76
+
77
+ test('(b) a nested conditional inside a branch arm re-runs the preamble in its own condition getter', () => {
78
+ const source = `'use client'
79
+ import { createSignal } from '@barefootjs/client'
80
+ type Row = { id: number; tier: string }
81
+ export function List() {
82
+ const [visible] = createSignal(true)
83
+ const [selected] = createSignal(1)
84
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
85
+ return (
86
+ <ul>
87
+ {rows().map(row => {
88
+ const cls = 'tier-' + row.tier
89
+ const on = selected() === row.id
90
+ return (
91
+ <li key={row.id}>
92
+ {visible() ? <div><span>{cls}</span>{on ? <em id="yes">yes</em> : <em id="no">no</em>}</div> : <span>x</span>}
93
+ </li>
94
+ )
95
+ })}
96
+ </ul>
97
+ )
98
+ }
99
+ `
100
+ const js = clientJsFor(source, 'List2.tsx')
101
+
102
+ expect(js).toMatch(
103
+ /insert\(__branchScope, 's\d+', \(\) => \{ const cls = 'tier-' \+ row\(\)\.tier; const on = selected\(\) === row\(\)\.id;; return \(on\) \}, \{/,
104
+ )
105
+ })
106
+
107
+ test('(c) negative control — no preamble local in play, nothing injected', () => {
108
+ const source = `'use client'
109
+ import { createSignal } from '@barefootjs/client'
110
+ type Row = { id: number; tier: string }
111
+ export function List() {
112
+ const [visible] = createSignal(true)
113
+ const [rows] = createSignal<Row[]>([{ id: 1, tier: 'gold' }])
114
+ return (
115
+ <ul>
116
+ {rows().map(row => (
117
+ <li key={row.id}>
118
+ {visible() ? <span class={row.tier} id="target">{row.tier}</span> : <span>x</span>}
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )
123
+ }
124
+ `
125
+ const js = clientJsFor(source, 'List3.tsx')
126
+
127
+ expect(js).not.toContain('const cls =')
128
+ // The attr effect still exists and still writes — just with no
129
+ // preamble re-run ahead of it, since there is nothing to re-run.
130
+ const disposerStart = js.indexOf('const __disposers = []')
131
+ const disposerEnd = js.indexOf('return () => __disposers.forEach')
132
+ const arm = js.slice(disposerStart, disposerEnd)
133
+ expect(arm).toContain("setAttribute('class'")
134
+ })
135
+ })
@@ -113,7 +113,10 @@ describe('reactive CSS custom properties (#135)', () => {
113
113
 
114
114
  // Both reactive sinks live in the init body — one assigning the DOM
115
115
  // `disabled` property, the other writing the `aria-busy` attribute.
116
- expect(content).toMatch(/\.disabled\s*=\s*!!\(busy\(\)\)/)
116
+ // The write reads a dedup-guarded temp (`__x`, #2869), not `busy()`
117
+ // inline — the computed value is still `busy()`'s result either way.
118
+ expect(content).toMatch(/const __x = busy\(\)/)
119
+ expect(content).toMatch(/\.disabled\s*=\s*!!\(__x\)/)
117
120
  expect(content).toContain("setAttribute('aria-busy'")
118
121
  })
119
122
  })
@@ -26,6 +26,7 @@ import type {
26
26
  } from './types.ts'
27
27
  import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types.ts'
28
28
  import type { CallbackBodyAcceptor } from './adapters/interface.ts'
29
+ import { nodeContainsJsx } from './reactivity-checker.ts'
29
30
 
30
31
  /**
31
32
  * Deferred info for BF043 (props destructuring warning).
@@ -318,10 +319,17 @@ export function createAnalyzerContext(
318
319
  // travel as structured segments, never as raw text. Every getJS call
319
320
  // site shares this contract; the env gate keeps the subtree walk off
320
321
  // the production hot path (the trichotomy harness enables it). Scoped
321
- // to error-free compiles: a compile that already refused loudly may
322
- // take degraded fallback paths whose artifacts are gated by the error —
323
- // the invariant this trips on is the SILENT leak.
324
- if (process.env.BF_ASSERT_NO_JSX_IN_GETJS === '1' && this.errors.length === 0 && nodeContainsJsx(node)) {
322
+ // to compiles with no `error`-severity diagnostic: a compile that
323
+ // already refused loudly may take degraded fallback paths whose
324
+ // artifacts are gated by the error — the invariant this trips on is
325
+ // the SILENT leak. A `warning` (e.g. BF043 props-destructuring) does
326
+ // not gate anything, so it must not silently disarm this assertion too
327
+ // (#2867) — only `error` severity does.
328
+ if (
329
+ process.env.BF_ASSERT_NO_JSX_IN_GETJS === '1' &&
330
+ !this.errors.some((e) => e.severity === 'error') &&
331
+ nodeContainsJsx(node)
332
+ ) {
325
333
  throw new Error(
326
334
  'getJS() called on a JSX-bearing node — raw JSX must never be spliced ' +
327
335
  'into emitted output. Carry mixed content as structured segments ' +
@@ -336,12 +344,6 @@ export function createAnalyzerContext(
336
344
  }
337
345
  }
338
346
 
339
- /** Subtree JSX check for the test-gated getJS assertion above. */
340
- function nodeContainsJsx(node: ts.Node): boolean {
341
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true
342
- return ts.forEachChild(node, nodeContainsJsx) ?? false
343
- }
344
-
345
347
  // =============================================================================
346
348
  // Source Location Helper
347
349
  // =============================================================================
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp, pickAttrMetaFromIR } from '../types.ts'
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
- import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
7
+ import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM, type LoopParamSpec } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, jsxChildrenPropGetterExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { detectRootNamespaceWrapTag } from './control-flow/stringify/template-parse.ts'
@@ -12,8 +12,8 @@ import { expandDynamicPropValue, expandConstantForReactivity, resolveRestSpreadO
12
12
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
- import { identifierPattern } from '../identifier-pattern.ts'
16
15
  import { classifyDOMProp } from '@barefootjs/shared'
16
+ import type { BindingScope } from '../scope/binding-scope.ts'
17
17
 
18
18
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
19
19
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -272,7 +272,7 @@ export const branchInnerLoopOptions: CollectInnerLoopsOptions = {
272
272
  export function collectInnerLoops(
273
273
  nodes: IRNode[],
274
274
  siblingOffsets: Map<IRLoop, IRNode[]>,
275
- outerLoopParam?: string,
275
+ outerLoopParam?: string | LoopParamSpec,
276
276
  ctx?: ClientJsContext,
277
277
  options?: CollectInnerLoopsOptions,
278
278
  ): NestedLoop[] {
@@ -280,12 +280,29 @@ export function collectInnerLoops(
280
280
  parentSlotId: string | null
281
281
  depth: number
282
282
  insideCond: boolean
283
+ /**
284
+ * Every ancestor loop's own item/index binding, innermost-first
285
+ * (#2861) — `undefined` above the outermost loop. Threaded (not just
286
+ * captured once from `outerLoopParam`) so a THIRD-level-deep loop's row
287
+ * sees BOTH its grandparent's and its immediate parent's bindings, not
288
+ * only the outermost one: each `loop:` visit below pushes its own
289
+ * frame via `buildLoopRowScope(..., scope.bindingScope)` before
290
+ * descending into that loop's own children.
291
+ */
292
+ bindingScope: BindingScope | undefined
283
293
  }
284
294
  const result: NestedLoop[] = []
285
295
  const flat = options?.flatBranchMode === true
286
296
  const fixedDepth = options?.templateDepth
287
297
  const collectBindings = options?.collectItemBindings === true
288
- const initialScope: Scope = { parentSlotId: null, depth: 0, insideCond: false }
298
+ // Normalize to a spec regardless of which shape the caller passed (#2868
299
+ // callers always pass a full spec; a bare string carries no bindings/index
300
+ // of its own) — one shape feeds both the reactivity scope below and
301
+ // `loopParamsForTemplate` inside the `loop:` visitor.
302
+ const outerSpec: LoopParamSpec | undefined =
303
+ typeof outerLoopParam === 'string' ? { param: outerLoopParam } : outerLoopParam
304
+ const outerScope = buildLoopRowScope(outerSpec?.param, outerSpec?.bindings, undefined, outerSpec?.index)
305
+ const initialScope: Scope = { parentSlotId: null, depth: 0, insideCond: false, bindingScope: outerScope }
289
306
 
290
307
  for (const root of nodes) {
291
308
  walkIR<Scope>(root, initialScope, {
@@ -308,14 +325,10 @@ export function collectInnerLoops(
308
325
  // Pass loopParams so expressions are wrapped at generation time (not post-hoc regex).
309
326
  // Forward destructured bindings (#951) so inner-loop template
310
327
  // references to the destructured locals are rewritten.
311
- const loopParamsForTemplate = outerLoopParam
312
- ? [outerLoopParam, { param: n.param, bindings: n.paramBindings, index: n.index }]
328
+ const loopParamsForTemplate = outerSpec
329
+ ? [outerSpec, { param: n.param, bindings: n.paramBindings, index: n.index }]
313
330
  : undefined
314
331
  const template = n.children.map(c => irToPlaceholderTemplate(c, undefined, emitDepth, loopParamsForTemplate)).join('')
315
- // Check if array expression references the outer loop param
316
- const refsOuter = outerLoopParam
317
- ? identifierPattern(outerLoopParam).test(n.array)
318
- : false
319
332
  // Per-item bindings for inner loop body, collected uniformly when
320
333
  // ctx is available: reactiveTexts / reactiveAttrs / refs are each
321
334
  // classified against the loop's OWN param via `classifyReactivity`,
@@ -349,8 +362,8 @@ export function collectInnerLoops(
349
362
  // guarantees the branch's marker is mounted the moment this
350
363
  // effect first runs (issue-2706-nested-loop-conditional-slot
351
364
  // .test.ts's original repro).
352
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
353
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
365
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index, scope.bindingScope))
366
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index, scope.bindingScope))
354
367
  bindings.refs.push(...collectLoopChildRefs(child))
355
368
  }
356
369
  bindings.conditionals.push(...collectLoopChildConditionals(
@@ -361,6 +374,12 @@ export function collectInnerLoops(
361
374
  n.paramBindings,
362
375
  innerPreambleNames,
363
376
  n.index,
377
+ // Render a branch's HTML against the FULL ancestor chain (outer
378
+ // loop(s) + this loop's own param), not just this loop's own
379
+ // param — an inner-loop conditional arm can read an outer
380
+ // loop's item/index too (#2868).
381
+ loopParamsForTemplate,
382
+ scope.bindingScope,
364
383
  ))
365
384
  }
366
385
 
@@ -412,7 +431,6 @@ export function collectInnerLoops(
412
431
  containerSlotId: scope.parentSlotId,
413
432
  template,
414
433
  preamble: n.preamble,
415
- refsOuterParam: refsOuter,
416
434
  childComponents,
417
435
  insideConditional: !flat && scope.insideCond ? true : undefined,
418
436
  offset: flat ? undefined : resolveLoopOffset(siblingOffsets.get(n)),
@@ -420,7 +438,14 @@ export function collectInnerLoops(
420
438
  })
421
439
  // Branch-mode callers handle deeper nesting via their own collection paths.
422
440
  if (!flat) {
423
- descend({ ...scope, depth: scope.depth + 1 })
441
+ // #2861: push THIS loop's own item/index frame onto the scope
442
+ // stack before descending, so a loop found deeper still (depth 2+)
443
+ // sees every ancestor's bindings, not just the outermost one.
444
+ descend({
445
+ ...scope,
446
+ depth: scope.depth + 1,
447
+ bindingScope: buildLoopRowScope(n.param, n.paramBindings, innerPreambleNames, n.index, scope.bindingScope),
448
+ })
424
449
  }
425
450
  },
426
451
  // fragment / provider / async auto-descend with the same scope.
@@ -455,7 +480,12 @@ function decideLoopRendering(
455
480
  // loop. These only surface for static arrays (gated at the call site via
456
481
  // `isStaticArray && innerLoops.length`) so dynamic child-component loops —
457
482
  // which render through `createComponent` — are unaffected.
458
- const innerLoops = collectInnerLoops(loop.children, siblingOffsets, loop.param, ctx)
483
+ const innerLoops = collectInnerLoops(
484
+ loop.children,
485
+ siblingOffsets,
486
+ { param: loop.param, bindings: loop.paramBindings, index: loop.index },
487
+ ctx,
488
+ )
459
489
  const hasInnerLoops = (innerLoops?.length ?? 0) > 0
460
490
  const useElementReconciliation =
461
491
  !loop.childComponent && !loop.isStaticArray && (hasNestedComps || hasInnerLoops)
@@ -727,6 +757,7 @@ export function collectElements(
727
757
  : decideLoopRendering(l, siblingOffsets, ctx)
728
758
 
729
759
  let template = ''
760
+ let templateIndexed: string | undefined
730
761
  let staticItemTemplate: string | undefined
731
762
  let skeletonTemplate: string | undefined
732
763
  let skeletonPaths: SkeletonSlotPaths | undefined
@@ -771,6 +802,18 @@ export function collectElements(
771
802
  template = useElementReconciliation
772
803
  ? irToPlaceholderTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpec)
773
804
  : irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpec)
805
+ // Second structured render, WITH the index wrapped too — used by
806
+ // `build-plain-row.ts` once lazy eligibility rules out the lazy row
807
+ // plan (which needs `template` above, index-unwrapped, verbatim).
808
+ // Rendered here (IR level) rather than via a post-hoc regex pass
809
+ // over `template`, which can corrupt a tag name colliding with the
810
+ // index identifier (`<i>` -> `<i()>`, #2868).
811
+ if (l.index) {
812
+ const loopParamSpecIndexed = [{ param: l.param, bindings: l.paramBindings, index: l.index }]
813
+ templateIndexed = useElementReconciliation
814
+ ? irToPlaceholderTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpecIndexed)
815
+ : irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, loopParamSpecIndexed)
816
+ }
774
817
  // Static-array loops emit a `forEach((param, idx) => ...)` whose body
775
818
  // references the destructured param directly — `__bfItem()` is not in
776
819
  // scope there. Build a second template that skips the loop-param
@@ -827,6 +870,7 @@ export function collectElements(
827
870
  iterationShape: l.iterationShape,
828
871
  objectIteration: l.objectIteration,
829
872
  template,
873
+ templateIndexed,
830
874
  staticItemTemplate,
831
875
  skeletonTemplate,
832
876
  skeletonPaths,
@@ -1177,6 +1221,7 @@ function collectBranchLoops(
1177
1221
  // keeping the template consistent with reactive effect expressions that
1178
1222
  // use `param()` to read the current item value.
1179
1223
  let childTemplate: string
1224
+ let childTemplateIndexed: string | undefined
1180
1225
  // NOT `index: n.index` here — see the identical note on `loopParamSpec`
1181
1226
  // above; `BranchLoop.template` is reused verbatim by the lazy row plan
1182
1227
  // too (`callSite: 'branch-plain'`), and `buildBranchLoopPlan` applies
@@ -1189,6 +1234,15 @@ function collectBranchLoops(
1189
1234
  } else {
1190
1235
  childTemplate = n.children.map(c => irToHtmlTemplate(c, undefined, 0, branchLoopParamSpec)).join('')
1191
1236
  }
1237
+ // Second structured render, WITH the index wrapped too — see the
1238
+ // identical `templateIndexed` note on the top-level loop path above
1239
+ // (#2868).
1240
+ if (n.index && !projectionInner) {
1241
+ const branchLoopParamSpecIndexed = [{ param: n.param, bindings: n.paramBindings, index: n.index }]
1242
+ childTemplateIndexed = useElementReconciliation && n.children[0]
1243
+ ? irToPlaceholderTemplate(n.children[0], restNames, 0, branchLoopParamSpecIndexed)
1244
+ : n.children.map(c => irToHtmlTemplate(c, undefined, 0, branchLoopParamSpecIndexed)).join('')
1245
+ }
1192
1246
 
1193
1247
  // Collect per-item bindings (events, reactive attrs/texts, refs,
1194
1248
  // conditionals) for ALL branch loops — simple loops also need
@@ -1214,6 +1268,7 @@ function collectBranchLoops(
1214
1268
  iterationShape: n.iterationShape,
1215
1269
  objectIteration: n.objectIteration,
1216
1270
  template: childTemplate,
1271
+ templateIndexed: childTemplateIndexed,
1217
1272
  containerSlotId: containerSlot,
1218
1273
  preamble: n.preamble,
1219
1274
  preambleRegions: n.preambleRegions,
@@ -1441,24 +1496,41 @@ export function collectLoopChildConditionals(
1441
1496
  * `collectLoopChildBindings`'s doc comment (Copilot review on #2595).
1442
1497
  */
1443
1498
  loopIndex?: string | null,
1499
+ /**
1500
+ * The full ancestor loop-param chain to render branch HTML against
1501
+ * (outermost first), when known — e.g. `[outerSpec, innerSpec]` for a
1502
+ * conditional inside an inner `.map()`. Defaults to just
1503
+ * `[{ param: loopParam, bindings: loopParamBindings, index: loopIndex }]`
1504
+ * (this function's own single-loop scope) when omitted, which is correct
1505
+ * for every caller except `collectInnerLoops`'s inner-loop handler, which
1506
+ * passes the full chain so an inner loop's conditional arm can also
1507
+ * reference an OUTER loop's item/index (#2868). Rendering the branch's
1508
+ * HTML against the full chain at IR time — rather than re-wrapping the
1509
+ * assembled HTML string with a word-boundary regex afterward — is also
1510
+ * what makes it safe for a branch root tag name to collide with a
1511
+ * param/index identifier (`<i>`, `<b>`, …): `irToHtmlTemplate` only wraps
1512
+ * JS expression positions, never tag names.
1513
+ */
1514
+ loopParams?: ReadonlyArray<string | LoopParamSpec>,
1515
+ /** Enclosing loop's own scope, for a NESTED loop (#2861) — see `buildLoopRowScope`. */
1516
+ parentScope?: BindingScope,
1444
1517
  ): LoopChildConditional[] {
1445
1518
  const conditionals: LoopChildConditional[] = []
1446
- const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
1519
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex, parentScope)
1447
1520
 
1448
1521
  // Widen the source-level "references loop param" check so destructured
1449
1522
  // callbacks fire too — the pattern text `[, cfg]` never word-matches on
1450
1523
  // bare `cfg` but individual binding names do (#951). Consumes a
1451
1524
  // pre-computed `Set<string>` of free identifiers (#1267) rather than
1452
1525
  // running word-boundary regex on the expression text.
1453
- const refsAnyBindingViaFreeIds = (freeIds: ReadonlySet<string>): boolean => {
1454
- if (loopParamBindings && loopParamBindings.length > 0) {
1455
- for (const b of loopParamBindings) {
1456
- if (freeIds.has(b.name)) return true
1457
- }
1458
- return false
1459
- }
1460
- return loopParam ? freeIds.has(loopParam) : false
1461
- }
1526
+ //
1527
+ // #2861: also checks the loop's INDEX binding, not just item/destructure
1528
+ // — `scope.valueBoundNames()` already unions all three (the same query
1529
+ // Phase 1's `referencesLoopParam` reads to grant a slot at all), so a
1530
+ // condition referencing only the index (e.g. `{i % 2 === 0 ? … : null}`)
1531
+ // no longer dies at this pre-gate before `classifyReactivity` even runs.
1532
+ const refsAnyBindingViaFreeIds = (freeIds: ReadonlySet<string>): boolean =>
1533
+ scope !== undefined && anyNameIn(scope.valueBoundNames(), freeIds)
1462
1534
 
1463
1535
  walkIR(node, null, {
1464
1536
  // element / fragment / component / provider auto-descend with same scope.
@@ -1489,13 +1561,13 @@ export function collectLoopChildConditionals(
1489
1561
  preambleNames !== undefined &&
1490
1562
  preambleNames.size > 0 &&
1491
1563
  anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
1492
- // Loop-param conditionals are reactive via per-item signal accessors;
1493
- // classifyReactivity sees both paths (signal/memo/prop + loop-param).
1494
- if (!readsPreamble && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1564
+ // Loop-param/loop-index conditionals are reactive via per-item signal
1565
+ // accessors; classifyReactivity sees all three paths (signal/memo/prop
1566
+ // + loop-param + loop-index, #2861).
1567
+ if (!readsPreamble && classifyReactivity(expanded.expr, ctx, scope, expanded.freeIds).kind === 'none') return
1495
1568
 
1496
- const loopParamsForCond = loopParam
1497
- ? [{ param: loopParam, bindings: loopParamBindings, index: loopIndex }]
1498
- : undefined
1569
+ const loopParamsForCond =
1570
+ loopParams ?? (loopParam ? [{ param: loopParam, bindings: loopParamBindings, index: loopIndex }] : undefined)
1499
1571
  // `__slots` matches the closure variable emitted by
1500
1572
  // `stringifyLoopChildConditional` — Child-position interpolations
1501
1573
  // get wrapped in `__bfSlot(EXPR, __slots)` so live `Node` returns
@@ -1536,7 +1608,13 @@ function summarizeLoopChildBranch(
1536
1608
  /** Enclosing loop's index param name — see `collectLoopChildConditionals` (Copilot review on #2595). */
1537
1609
  loopIndex?: string | null,
1538
1610
  ): LoopChildBranchSummary {
1539
- const inner = collectInnerLoops([node], siblingOffsets, loopParam, ctx, branchInnerLoopOptions)
1611
+ const inner = collectInnerLoops(
1612
+ [node],
1613
+ siblingOffsets,
1614
+ loopParam ? { param: loopParam, bindings: loopParamBindings, index: loopIndex } : undefined,
1615
+ ctx,
1616
+ branchInnerLoopOptions,
1617
+ )
1540
1618
  return {
1541
1619
  childComponents: collectConditionalBranchChildComponents(node),
1542
1620
  innerLoops: inner.length > 0 ? inner : undefined,
@@ -60,6 +60,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
60
60
  parentElVar: '__el',
61
61
  outerLoopParam: elem.param,
62
62
  outerLoopParamBindings: elem.paramBindings,
63
+ outerLoopIndex: elem.index,
63
64
  }),
64
65
  loopParam: elem.param,
65
66
  loopParamBindings: elem.paramBindings,
@@ -122,6 +123,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
122
123
  parentElVar: '__el',
123
124
  outerLoopParam: loop.param,
124
125
  outerLoopParamBindings: loop.paramBindings,
126
+ outerLoopIndex: loop.index,
125
127
  }),
126
128
  loopParam: loop.param,
127
129
  loopParamBindings: loop.paramBindings,
@@ -7,8 +7,18 @@
7
7
  * - Sibling levels at the same depth are flattened into the top-level
8
8
  * list; deeper levels become `childLevels` of the immediately-preceding
9
9
  * ancestor.
10
- * - Choose `reactive` vs `static` mode by checking whether `inner.array`
11
- * references the *current* outer loop param (the O-8 narrowing fix).
10
+ * - Every level gets the full reactive `mapArray` emission (#2865
11
+ * the former "static forEach, setup-only" shortcut for a nested
12
+ * `.map()` whose array didn't textually mention the outer loop's item
13
+ * silently froze any OTHER reactivity the inner array or its rows
14
+ * depended on: a signal, a memo, a prop, an imported function call, a
15
+ * preamble local, a destructured outer param, an outer-index-derived
16
+ * array — none of those are "the outer item", so `refsParent` missed
17
+ * all of them, and the static emitter never wired a single
18
+ * `createEffect` for the row's own reactiveTexts/reactiveAttrs/
19
+ * conditionals either. A `mapArray` over a genuinely non-reactive
20
+ * array just subscribes to nothing and runs once — a strict superset
21
+ * of what the deleted static path did).
12
22
  * - Recurse with `inner.param` as the new outerLoopParam so deeper
13
23
  * levels see their immediate parent.
14
24
  */
@@ -28,7 +38,7 @@ import {
28
38
  wrapLoopParamAsAccessor,
29
39
  attrValueToString,
30
40
  } from '../../utils.ts'
31
- import { buildChildRefBindings, buildStaticChildRefBindings } from '../shared.ts'
41
+ import { buildChildRefBindings } from '../shared.ts'
32
42
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
33
43
  import { buildLoopChildConditionalsPlan } from './build-loop-child-arm.ts'
34
44
  import type { LoopChildConditionalPlan } from './loop-child-arm.ts'
@@ -67,7 +77,6 @@ import type {
67
77
  InnerLoopPlan,
68
78
  InnerLoopReactiveAttr,
69
79
  InnerLoopReactiveEmit,
70
- InnerLoopStaticEmit,
71
80
  InnerLoopText,
72
81
  InnerLoopsPlan,
73
82
  } from './inner-loop.ts'
@@ -77,6 +86,8 @@ export interface BuildInnerLoopsArgs {
77
86
  parentElVar: string
78
87
  outerLoopParam: string | undefined
79
88
  outerLoopParamBindings?: readonly LoopParamBinding[]
89
+ /** Outer loop's index param name, when present (#2861). */
90
+ outerLoopIndex?: string | null
80
91
  }
81
92
 
82
93
  /**
@@ -85,9 +96,9 @@ export interface BuildInnerLoopsArgs {
85
96
  * conditional branch are skipped (they're emitted by `stringifyBranchInnerLoops`).
86
97
  */
87
98
  export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
88
- const { levels, parentElVar, outerLoopParam, outerLoopParamBindings } = args
99
+ const { levels, parentElVar, outerLoopParam, outerLoopParamBindings, outerLoopIndex } = args
89
100
  const wrapOuter = outerLoopParam
90
- ? (expr: string) => wrapLoopParamAsAccessor(expr, outerLoopParam, outerLoopParamBindings)
101
+ ? (expr: string) => wrapLoopParamAsAccessor(expr, outerLoopParam, outerLoopParamBindings, outerLoopIndex)
91
102
  : (expr: string) => expr
92
103
 
93
104
  const plan: InnerLoopPlan[] = []
@@ -111,17 +122,13 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
111
122
  ? `qsa(${parentElVar}, '[bf="${inner.containerSlotId}"]')`
112
123
  : parentElVar
113
124
 
114
- // O-8 narrowing: at depth 2+, `inner.array` may reference the *immediate*
115
- // parent loop's param. Check against whatever outerLoopParam our caller
116
- // narrowed to the recursion passes `inner.param` for child levels.
117
- const refsParent = !!outerLoopParam && (inner.arrayFreeIdentifiers?.has(outerLoopParam) ?? false)
118
- const useReactive = refsParent && !!inner.template
125
+ // #2865: every nested loop gets the full reactive `mapArray` emission,
126
+ // regardless of whether its array references the outer loop's item —
127
+ // see the module docstring for why the old `refsParent`-gated static
128
+ // shortcut was unsound.
129
+ const emit: InnerLoopReactiveEmit = buildReactiveEmit(inner, level, wrapOuter, uidSuffix, outerLoopParam, outerLoopParamBindings, outerLoopIndex)
119
130
 
120
- const emit: InnerLoopReactiveEmit | InnerLoopStaticEmit = useReactive
121
- ? buildReactiveEmit(inner, level, wrapOuter, uidSuffix, outerLoopParam, outerLoopParamBindings)
122
- : buildStaticEmit(inner, level, uidSuffix)
123
-
124
- const arrayExpr = useReactive ? wrapOuter(inner.array) : inner.array
131
+ const arrayExpr = wrapOuter(inner.array)
125
132
 
126
133
  const childLevelsPlan = childLevels.length > 0
127
134
  ? buildInnerLoopsPlan({
@@ -129,6 +136,7 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
129
136
  parentElVar: `__innerEl${uidSuffix}`,
130
137
  outerLoopParam: inner.param,
131
138
  outerLoopParamBindings: inner.paramBindings,
139
+ outerLoopIndex: inner.index,
132
140
  })
133
141
  : []
134
142
 
@@ -146,6 +154,7 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
146
154
  childLevels: childLevelsPlan,
147
155
  outerLoopParam,
148
156
  outerLoopParamBindings,
157
+ outerLoopIndex,
149
158
  })
150
159
 
151
160
  i = j
@@ -160,6 +169,7 @@ function buildReactiveEmit(
160
169
  uidSuffix: string,
161
170
  outerLoopParam?: string,
162
171
  outerLoopParamBindings?: readonly LoopParamBinding[],
172
+ outerLoopIndex?: string | null,
163
173
  ): InnerLoopReactiveEmit {
164
174
  const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings, inner.index)
165
175
  const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings, inner.index)
@@ -240,7 +250,7 @@ function buildReactiveEmit(
240
250
  // wrapInner(wrapOuter(...)) applied to the js text.
241
251
  const leafLoopParams = outerLoopParam
242
252
  ? [
243
- { param: outerLoopParam, bindings: outerLoopParamBindings },
253
+ { param: outerLoopParam, bindings: outerLoopParamBindings, index: outerLoopIndex },
244
254
  { param: inner.param, bindings: inner.paramBindings, index: inner.index },
245
255
  ]
246
256
  : [{ param: inner.param, bindings: inner.paramBindings, index: inner.index }]
@@ -265,6 +275,7 @@ function buildReactiveEmit(
265
275
  wrap: wrapBoth,
266
276
  loopParam: inner.param,
267
277
  loopParamBindings: inner.paramBindings,
278
+ loopIndex: inner.index,
268
279
  })
269
280
 
270
281
  return {
@@ -283,37 +294,3 @@ function buildReactiveEmit(
283
294
  childRefs,
284
295
  }
285
296
  }
286
-
287
- function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string): InnerLoopStaticEmit {
288
- // Static `forEach` iterates with the literal item as its first param, so
289
- // no signal-accessor rewrite is needed — emit the preamble verbatim
290
- // before the component/event setup so prop getters and event handlers
291
- // can resolve the locals (#1064). Refs follow the same contract:
292
- // wrapping the callback would rewrite `s.x` to `s().x` and throw at
293
- // runtime when the callback closes over the static inner param (#1244,
294
- // PR #1352 Copilot review).
295
- //
296
- // The index alias (#2218) lands first, same rationale as the reactive
297
- // path: `forEach`'s second param is the synthetic `__innerIdx<uid>`, not
298
- // the user's index name, so a preamble/prop/event that reads it needs the
299
- // alias bound before anything else runs.
300
- const preludeStatements: string[] = []
301
- const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, inner.param, level.comps, level.events)
302
- if (indexAlias) preludeStatements.push(indexAlias)
303
- // Static `forEach` receives the literal item, so neither the js text nor a
304
- // leaf's param reads get accessor-wrapped (mirrors the verbatim emission
305
- // of the js segments above).
306
- if (inner.preamble) {
307
- preludeStatements.push(renderPreamble(inner.preamble, {
308
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
309
- }))
310
- }
311
- return {
312
- mode: 'static',
313
- rawKey: inner.key ?? null,
314
- preludeStatements,
315
- components: level.comps,
316
- events: level.events,
317
- childRefs: buildStaticChildRefBindings(inner.bindings.refs),
318
- }
319
- }