@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
@@ -11,14 +11,13 @@
11
11
  */
12
12
 
13
13
  import type { BranchLoop } from '../../types.ts'
14
- import { buildChainedArrayExpr, varSlotId, wrapLoopParamAsAccessor } from '../../utils.ts'
14
+ import { buildChainedArrayExpr, varSlotId } from '../../utils.ts'
15
15
  import { buildBranchCompositePlan } from './build-composite-loop.ts'
16
16
  import { buildBranchLoopDelegationPlan } from './build-event-delegation.ts'
17
17
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
18
- import { buildLazyRowPlan } from './build-lazy-row.ts'
18
+ import { buildPlainRowCore } from './build-plain-row.ts'
19
19
  import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
20
- import { destructureLoopParam, loopKeyFn, buildChildRefBindings, buildPreambleRegionPlans } from '../shared.ts'
21
- import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
20
+ import { loopKeyFn, buildChildRefBindings } from '../shared.ts'
22
21
  import type {
23
22
  BranchCompositeLoopPlan,
24
23
  BranchLoopPlan,
@@ -44,7 +43,6 @@ export function buildBranchLoopPlan(
44
43
  return composite
45
44
  }
46
45
 
47
- const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(loop.param, loop.paramBindings)
48
46
  const hasReactiveEffects = loop.bindings.reactiveAttrs.length > 0
49
47
  || loop.bindings.reactiveTexts.length > 0
50
48
  || loop.bindings.conditionals.length > 0
@@ -54,14 +52,18 @@ export function buildBranchLoopPlan(
54
52
  const arrayExpr = fm
55
53
  ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
56
54
  : buildChainedArrayExpr(loop)
57
- const indexParam = loop.index || '__idx'
58
- const mapPreambleWrapped = loop.preamble
59
- ? renderPreamble(loop.preamble, {
60
- transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
61
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
62
- })
63
- : ''
64
- const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings)
55
+
56
+ // Shared "wrap item → decide lazy → wrap index" core (#2859 follow-up) —
57
+ // see `build-plain-row.ts`'s module docstring for the full rationale.
58
+ const core = buildPlainRowCore({
59
+ loop,
60
+ arrayExpr,
61
+ callSite: 'branch-plain',
62
+ flatMapLeafItem: Boolean(fm),
63
+ anchored: false,
64
+ scope: lazyScope,
65
+ })
66
+ const { paramHead, paramUnwrap, indexParam, preambleRegions, lazyRow } = core
65
67
  const plan: BranchPlainLoopPlan = {
66
68
  kind: 'plain',
67
69
  rowConstruction: 'string-template',
@@ -78,21 +80,10 @@ export function buildBranchLoopPlan(
78
80
  indexParam,
79
81
  // Wrap loop-param references to signal-accessor form so the preamble
80
82
  // matches the template literal's already-wrapped reads (#1065).
81
- mapPreambleWrapped,
83
+ mapPreambleWrapped: core.mapPreambleWrapped,
82
84
  // Lazy row graph (§9, L3) — undefined for every ineligible loop.
83
- lazyRow: buildLazyRowPlan({
84
- loop,
85
- arrayExpr,
86
- indexParam,
87
- paramUnwrap,
88
- mapPreambleWrapped,
89
- preambleRegionCount: preambleRegions.length,
90
- callSite: 'branch-plain',
91
- flatMapLeafItem: Boolean(fm),
92
- anchored: false,
93
- scope: lazyScope,
94
- }) ?? undefined,
95
- template: loop.template,
85
+ lazyRow,
86
+ template: core.template,
96
87
  reactiveEffects: hasReactiveEffects
97
88
  ? buildReactiveEffectsPlan({
98
89
  attrs: loop.bindings.reactiveAttrs,
@@ -100,11 +91,12 @@ export function buildBranchLoopPlan(
100
91
  conditionals: loop.bindings.conditionals,
101
92
  loopParam: loop.param,
102
93
  loopParamBindings: loop.paramBindings,
94
+ loopIndex: loop.index,
103
95
  profileComponentName,
104
96
  })
105
97
  : null,
106
98
  eventDelegation: buildBranchLoopDelegationPlan(loop, cv, profileComponentName),
107
- childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
99
+ childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings, loop.index),
108
100
  preambleRegions,
109
101
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
110
102
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${containerSlotId}` : undefined,
@@ -34,8 +34,8 @@ import { internalInvariant } from '../../../errors.ts'
34
34
  /** @internal — prefer `buildLoopPlan`. */
35
35
  export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?: string): ComponentLoopPlan {
36
36
  const { name } = elem.childComponent!
37
- const propsExpr = buildComponentPropsExpr(elem.childComponent!, elem.param)
38
- const keyExpr = wrapLoopParamAsAccessor(elem.key || '__idx', elem.param, elem.paramBindings)
37
+ const propsExpr = buildComponentPropsExpr(elem.childComponent!, elem.param, undefined, elem.index)
38
+ const keyExpr = wrapLoopParamAsAccessor(elem.key || '__idx', elem.param, elem.paramBindings, elem.index)
39
39
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
40
40
 
41
41
  // A component-root loop's preamble is JS-only by construction: Phase 1
@@ -47,7 +47,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
47
47
  // that let a JSX-bearing preamble through is missing, not this line.
48
48
  const mapPreambleWrapped = elem.preamble
49
49
  ? renderPreamble(elem.preamble, {
50
- transformJs: text => wrapLoopParamAsAccessor(text, elem.param, elem.paramBindings),
50
+ transformJs: text => wrapLoopParamAsAccessor(text, elem.param, elem.paramBindings, elem.index),
51
51
  renderLeaf: () => {
52
52
  internalInvariant(false, 'component-root loop received a JSX-bearing preamble — Phase 1 should have refused it')
53
53
  },
@@ -62,13 +62,14 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
62
62
  : false
63
63
  const rawChildrenExpr = isTextOnly ? irChildrenToJsExpr(comp.children!) : null
64
64
  const childrenFreeIds = isTextOnly && comp.children ? irChildrenFreeIds(comp.children) : undefined
65
- const childrenRefsLoop = rawChildrenExpr != null && childrenFreeIds != null && childrenFreeIds.has(elem.param)
65
+ const childrenRefsLoop = rawChildrenExpr != null && childrenFreeIds != null
66
+ && (childrenFreeIds.has(elem.param) || (!!elem.index && childrenFreeIds.has(elem.index)))
66
67
  return {
67
68
  componentName: comp.name,
68
69
  selector: buildCompSelector(comp),
69
- propsExpr: buildComponentPropsExpr(comp, elem.param),
70
+ propsExpr: buildComponentPropsExpr(comp, elem.param, undefined, elem.index),
70
71
  childrenTextEffect: childrenRefsLoop
71
- ? { wrappedChildren: wrapLoopParamAsAccessor(rawChildrenExpr!, elem.param, elem.paramBindings) }
72
+ ? { wrappedChildren: wrapLoopParamAsAccessor(rawChildrenExpr!, elem.param, elem.paramBindings, elem.index) }
72
73
  : null,
73
74
  }
74
75
  })
@@ -98,7 +99,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
98
99
  // the per-item factory has no `__el` handle to invoke. Still required
99
100
  // by the type so the structural invariant (every variant has a
100
101
  // `childRefs`) is preserved; populated as empty.
101
- childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
102
+ childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings, elem.index),
102
103
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${elem.slotId}` : undefined,
103
104
  childConditionalEffects: hasChildConds
104
105
  ? buildReactiveEffectsPlan({
@@ -107,6 +108,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
107
108
  conditionals: elem.bindings.conditionals,
108
109
  loopParam: elem.param,
109
110
  loopParamBindings: elem.paramBindings,
111
+ loopIndex: elem.index,
110
112
  profileComponentName,
111
113
  })
112
114
  : null,
@@ -31,7 +31,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
31
31
  const nestedComps = elem.nestedComponents!
32
32
  const depthLevels = buildDepthLevels(elem.innerLoops ?? [], nestedComps, elem.bindings.events)
33
33
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
34
- const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, elem.param, elem.paramBindings)
34
+ const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, elem.param, elem.paramBindings, elem.index)
35
35
 
36
36
  const outerCompsByDepth = nestedComps.filter(c => !c.loopDepth || c.loopDepth === 0)
37
37
 
@@ -48,13 +48,13 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
48
48
  mapPreambleWrapped: elem.preamble
49
49
  ? renderPreamble(elem.preamble, {
50
50
  transformJs: wrap,
51
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
51
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings, index: elem.index }], undefined),
52
52
  })
53
53
  : '',
54
54
  template: elem.template,
55
55
  outerComps: filterCondCompsOut(outerCompsByDepth, elem.bindings.conditionals),
56
56
  outerEvents: elem.bindings.events.filter(ev => ev.nestedLoops.length === 0),
57
- childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
57
+ childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings, elem.index),
58
58
  innerLoops: buildInnerLoopsPlan({
59
59
  levels: depthLevels,
60
60
  parentElVar: '__el',
@@ -63,6 +63,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
63
63
  }),
64
64
  loopParam: elem.param,
65
65
  loopParamBindings: elem.paramBindings,
66
+ loopIndex: elem.index,
66
67
  reactiveEffects: hasReactive(elem)
67
68
  ? buildReactiveEffectsPlan({
68
69
  attrs: elem.bindings.reactiveAttrs,
@@ -70,6 +71,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
70
71
  conditionals: elem.bindings.conditionals,
71
72
  loopParam: elem.param,
72
73
  loopParamBindings: elem.paramBindings,
74
+ loopIndex: elem.index,
73
75
  profileComponentName,
74
76
  })
75
77
  : null,
@@ -88,7 +90,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
88
90
  const childEvents = loop.bindings.events
89
91
  const depthLevels = buildDepthLevels(innerLoops, nestedComps, childEvents)
90
92
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(loop.param, loop.paramBindings)
91
- const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loop.param, loop.paramBindings)
93
+ const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loop.param, loop.paramBindings, loop.index)
92
94
 
93
95
  const outerCompsByDepth = nestedComps.filter(c => !c.loopDepth || c.loopDepth === 0)
94
96
 
@@ -108,13 +110,13 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
108
110
  mapPreambleWrapped: loop.preamble
109
111
  ? renderPreamble(loop.preamble, {
110
112
  transformJs: wrap,
111
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
113
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings, index: loop.index }], undefined),
112
114
  })
113
115
  : '',
114
116
  template: loop.template,
115
117
  outerComps: filterCondCompsOut(outerCompsByDepth, loop.bindings.conditionals),
116
118
  outerEvents: childEvents.filter(ev => ev.nestedLoops.length === 0),
117
- childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
119
+ childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings, loop.index),
118
120
  innerLoops: buildInnerLoopsPlan({
119
121
  levels: depthLevels,
120
122
  parentElVar: '__el',
@@ -123,6 +125,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
123
125
  }),
124
126
  loopParam: loop.param,
125
127
  loopParamBindings: loop.paramBindings,
128
+ loopIndex: loop.index,
126
129
  reactiveEffects: hasReactiveBranch(loop)
127
130
  ? buildReactiveEffectsPlan({
128
131
  attrs: loop.bindings.reactiveAttrs,
@@ -130,6 +133,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
130
133
  conditionals: loop.bindings.conditionals,
131
134
  loopParam: loop.param,
132
135
  loopParamBindings: loop.paramBindings,
136
+ loopIndex: loop.index,
133
137
  profileComponentName,
134
138
  })
135
139
  : null,
@@ -161,11 +161,11 @@ function buildReactiveEmit(
161
161
  outerLoopParam?: string,
162
162
  outerLoopParamBindings?: readonly LoopParamBinding[],
163
163
  ): InnerLoopReactiveEmit {
164
- const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
165
- const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings)
164
+ const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings, inner.index)
165
+ const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings, inner.index)
166
166
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
167
167
  const wrappedKey = inner.key
168
- ? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings)
168
+ ? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings, inner.index)
169
169
  : null
170
170
 
171
171
  // Inner-wrap children IR recursively so nested component props (e.g.,
@@ -202,12 +202,12 @@ function buildReactiveEmit(
202
202
 
203
203
  const reactiveTexts: InnerLoopText[] = inner.bindings.reactiveTexts.map(text => ({
204
204
  slotId: text.slotId,
205
- wrappedExpression: wrapLoopParamAsAccessor(wrapOuter(text.expression), inner.param, inner.paramBindings),
205
+ wrappedExpression: wrapLoopParamAsAccessor(wrapOuter(text.expression), inner.param, inner.paramBindings, inner.index),
206
206
  insideConditional: !!text.insideConditional,
207
207
  }))
208
208
 
209
209
  const reactiveAttrs: InnerLoopReactiveAttr[] = inner.bindings.reactiveAttrs.map(attr => {
210
- const wrapped = wrapLoopParamAsAccessor(wrapOuter(attr.expression), inner.param, inner.paramBindings)
210
+ const wrapped = wrapLoopParamAsAccessor(wrapOuter(attr.expression), inner.param, inner.paramBindings, inner.index)
211
211
  return {
212
212
  slotId: attr.childSlotId,
213
213
  attrName: attr.attrName,
@@ -241,16 +241,16 @@ function buildReactiveEmit(
241
241
  const leafLoopParams = outerLoopParam
242
242
  ? [
243
243
  { param: outerLoopParam, bindings: outerLoopParamBindings },
244
- { param: inner.param, bindings: inner.paramBindings },
244
+ { param: inner.param, bindings: inner.paramBindings, index: inner.index },
245
245
  ]
246
- : [{ param: inner.param, bindings: inner.paramBindings }]
246
+ : [{ param: inner.param, bindings: inner.paramBindings, index: inner.index }]
247
247
  preludeStatements.push(renderPreamble(inner.preamble, {
248
248
  transformJs: (t) => wrapInner(wrapOuter(t)),
249
249
  renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined),
250
250
  }))
251
251
  }
252
252
 
253
- const childRefs = buildChildRefBindings(inner.bindings.refs, inner.param, inner.paramBindings)
253
+ const childRefs = buildChildRefBindings(inner.bindings.refs, inner.param, inner.paramBindings, inner.index)
254
254
 
255
255
  // Per-item conditionals inside THIS loop's own row (#2706) — same
256
256
  // insert()-parity treatment the top-level loop's row conditionals
@@ -156,6 +156,17 @@ export interface LazyRowPlanData {
156
156
  * both parsed once per loop and clones on a flip.
157
157
  */
158
158
  conditionals: readonly LazyRowConditionalBinding[]
159
+ /**
160
+ * At least one binding, condition, or preamble-substituted dependency reads
161
+ * the loop's index parameter (#2859 follow-up — `ClassifiedLazyBinding.
162
+ * referencesIndex`, which also forces `readsItem: true` for that binding).
163
+ * The stringifier binds `const <indexParam> = __e.index` at the top of
164
+ * `applyItem` and inside `applyOuter`'s per-entry loop only when this is
165
+ * true, and emits `indexDriven: true` on the plan object literal so the
166
+ * runtime (`mapArrayLazy`) also calls `applyItem` on a pure reorder — a
167
+ * position change with no item change — not just an item change.
168
+ */
169
+ readsIndex: boolean
159
170
  }
160
171
 
161
172
  export interface BuildLazyRowArgs {
@@ -198,7 +209,7 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
198
209
  // bodies BEFORE classifying, because a binding that reads a declared local
199
210
  // inherits the preamble's dependencies rather than its own literal names.
200
211
  const primableNames = new Set<string>([...scope.signals.keys(), ...scope.memos])
201
- const preambleAnalysis = analyzeLazyPreamble(loop.preamble, args.indexParam, primableNames)
212
+ const preambleAnalysis = analyzeLazyPreamble(loop.preamble, primableNames)
202
213
 
203
214
  // §9.5 conditional widening: a row conditional whose arms are wiring-free
204
215
  // static elements is driven from the apply bodies instead of a per-row
@@ -207,7 +218,7 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
207
218
  const condFacts: LazyConditionalFacts[] = []
208
219
  let conditionalRefusal: string | null = null
209
220
  for (const cond of rawConditionals) {
210
- const verdict = analyzeLazyConditional(cond, args.indexParam, {
221
+ const verdict = analyzeLazyConditional(cond, {
211
222
  whenTrueHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
212
223
  whenFalseHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
213
224
  })
@@ -374,6 +385,11 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
374
385
  itemNeedsPreamble: [...attrs, ...texts, ...conditionals].some(b => b.readsItem && b.readsPreamble),
375
386
  outerNeedsPreamble: [...attrs, ...texts, ...conditionals].some(b => b.readsOuter && b.readsPreamble),
376
387
  conditionals,
388
+ // From the RAW classification, not the final lists: a binding that
389
+ // reads the index is always `readsItem` (see `classifyLazyBinding`),
390
+ // so checking the final lists would say the same thing — raw is just
391
+ // the more direct source of truth.
392
+ readsIndex: classified.some(c => c.referencesIndex),
377
393
  },
378
394
  decision,
379
395
  }
@@ -234,8 +234,8 @@ export function buildBranchInnerLoopsPlan(
234
234
  const inner = innerLoops[i]
235
235
  if (!inner.refsOuterParam || !inner.template) continue
236
236
 
237
- const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
238
- const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings)
237
+ const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings, inner.index)
238
+ const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings, inner.index)
239
239
 
240
240
  const csl = inner.containerSlotId
241
241
  // Inner loop's container: host-side `bf="<slot>"` slot marker first,
@@ -251,7 +251,7 @@ export function buildBranchInnerLoopsPlan(
251
251
 
252
252
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
253
253
  const wrappedKey = inner.key
254
- ? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings)
254
+ ? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings, inner.index)
255
255
  : null
256
256
 
257
257
  // Inner-wrap children IR recursively so nested component props (e.g.
@@ -28,17 +28,14 @@ import {
28
28
  buildLoopChildIndexExpr,
29
29
  setIntersects,
30
30
  varSlotId,
31
- wrapLoopParamAsAccessor,
32
31
  } from '../../utils.ts'
33
32
  import {
34
33
  loopKeyFn,
35
- destructureLoopParam,
36
34
  buildChildRefBindings,
37
35
  buildStaticChildRefBindings,
38
- buildPreambleRegionPlans,
39
36
  } from '../shared.ts'
40
37
  import { buildLoopReactiveEffectsPlan } from './build-reactive-effects.ts'
41
- import { buildLazyRowPlan } from './build-lazy-row.ts'
38
+ import { buildPlainRowCore } from './build-plain-row.ts'
42
39
  import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
43
40
  import { buildComponentLoopPlan } from './build-component-loop.ts'
44
41
  import { buildTopLevelCompositePlan } from './build-composite-loop.ts'
@@ -99,8 +96,6 @@ export function buildPlainLoopPlan(
99
96
  profileComponentName?: string,
100
97
  lazyScope?: LazyRowScopeInfo,
101
98
  ): PlainLoopPlan {
102
- const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, elem.param, elem.paramBindings)
103
- const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
104
99
  const hasReactive = elem.bindings.reactiveAttrs.length > 0
105
100
  || elem.bindings.reactiveTexts.length > 0
106
101
  || elem.bindings.conditionals.length > 0
@@ -140,14 +135,26 @@ export function buildPlainLoopPlan(
140
135
  }
141
136
 
142
137
  const arrayExpr = buildChainedArrayExpr(elem)
143
- const indexParam = elem.index || '__idx'
144
- const mapPreambleWrapped = elem.preamble
145
- ? renderPreamble(elem.preamble, {
146
- transformJs: wrap,
147
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
148
- })
149
- : ''
150
- const preambleRegions = buildPreambleRegionPlans(elem.preambleRegions, elem.param, elem.paramBindings)
138
+
139
+ // Shared "wrap item → decide lazy → wrap index" core (#2859 follow-up) —
140
+ // see `build-plain-row.ts`'s module docstring for why this single
141
+ // implementation matters: a lazy-eligible loop's `LazyRowPlanData` embeds
142
+ // `mapPreambleWrapped` and clones from `elem.template` verbatim, and
143
+ // `mapArrayLazy`'s `createRow(entry, index)` hands the row a plain index
144
+ // NUMBER, never an accessor — wrapping unconditionally would call a number
145
+ // there. Lazy-row eligibility only refuses a REACTIVE binding that
146
+ // references the index; a non-reactive use (e.g. a `data-key` built from
147
+ // the index) is not gated the same way, so "lazy-eligible" does not imply
148
+ // "no index in the template" either.
149
+ const core = buildPlainRowCore({
150
+ loop: elem,
151
+ arrayExpr,
152
+ callSite: 'plain',
153
+ flatMapLeafItem: false,
154
+ anchored: elem.bodyIsItemConditional ?? false,
155
+ scope: lazyScope,
156
+ })
157
+ const { paramHead, paramUnwrap, indexParam, preambleRegions, lazyRow, wrapItem: wrap } = core
151
158
 
152
159
  return {
153
160
  kind: 'plain',
@@ -160,29 +167,21 @@ export function buildPlainLoopPlan(
160
167
  paramHead,
161
168
  paramUnwrap,
162
169
  indexParam,
163
- // Lazy row graph (§9, L3). `null` for every ineligible loop, which then
164
- // keeps the eager emission below byte-for-byte.
165
- lazyRow: buildLazyRowPlan({
166
- loop: elem,
167
- arrayExpr,
168
- indexParam,
169
- paramUnwrap,
170
- mapPreambleWrapped,
171
- preambleRegionCount: preambleRegions.length,
172
- callSite: 'plain',
173
- flatMapLeafItem: false,
174
- anchored: elem.bodyIsItemConditional ?? false,
175
- scope: lazyScope,
176
- }) ?? undefined,
170
+ lazyRow,
177
171
  // Stage 3 / D4 — js segments get the loop-param accessor wrap; jsx leaves
178
172
  // render as HTML-string templates under this loop's param context so a
179
173
  // leaf that reads the item (`r`) becomes `r()`.
180
- mapPreambleWrapped,
181
- template: elem.template,
174
+ mapPreambleWrapped: core.mapPreambleWrapped,
175
+ template: core.template,
182
176
  skeletonTemplate: elem.skeletonTemplate,
183
177
  skeletonPaths: elem.skeletonPaths,
184
178
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
185
- childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
179
+ // `elem.index` (#2859 follow-up): a ref callback closing over the loop
180
+ // index (`ref={el => refs[i] = el}`) must see it wrapped the same way
181
+ // every other reference in this row is — a row with any ref is always
182
+ // lazy-ineligible (`lazy-row-eligibility.ts`'s `childRefCount` gate), so
183
+ // there is no shared-string hazard here, unlike `mapPreambleWrapped`.
184
+ childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings, elem.index),
186
185
  preambleRegions,
187
186
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
188
187
  anchored: elem.bodyIsItemConditional ?? false,
@@ -190,9 +189,10 @@ export function buildPlainLoopPlan(
190
189
  // conditional without a key is a BF023 error, but the emitted client JS
191
190
  // must still parse — an empty `anchorKeyExpr` would produce
192
191
  // `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
193
- // bundle). `elem.index || '__idx'` matches `indexParam` above, so the
194
- // anchor value stays consistent with the renderItem's own index param.
195
- anchorKeyExpr: elem.key ? wrap(elem.key) : (elem.index || '__idx'),
192
+ // bundle). `indexParam` matches the renderItem head built above, and
193
+ // (#2859) is bound to an INDEX ACCESSOR at runtime, not a plain number —
194
+ // call it, same as every other reference to it in this row's body.
195
+ anchorKeyExpr: elem.key ? wrap(elem.key) : `${indexParam}()`,
196
196
  }
197
197
  }
198
198
 
@@ -269,7 +269,7 @@ function buildStaticLoopMaterialize(
269
269
  itemTemplate: elem.staticItemTemplate,
270
270
  mapPreamble: elem.preamble
271
271
  ? renderPreamble(elem.preamble, {
272
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
272
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings, index: elem.index }], undefined),
273
273
  })
274
274
  : '',
275
275
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared "wrap item → decide lazy → wrap index" core for a plain loop row.
3
+ *
4
+ * `buildPlainLoopPlan` (`build-loop.ts`, `TopLevelLoop`) and
5
+ * `buildBranchLoopPlan` (`build-branch-loop.ts`, `BranchLoop`) both build a
6
+ * row from a `.map()` callback the SAME way:
7
+ *
8
+ * 1. Wrap the row's JS with ITEM-ONLY accessor rewriting (no index) — this
9
+ * string must stay index-unwrapped because it is reused VERBATIM by the
10
+ * lazy row plan below, which reads the index differently (a live
11
+ * per-call value off `entry.index`/`createRow`'s parameter, never a
12
+ * `.map()`-body call).
13
+ * 2. Ask `buildLazyRowPlan` for a verdict.
14
+ * 3. Only once lazy is ruled out (`!lazyRow`), wrap index references too,
15
+ * for the eager path — `mapArrayLazy`'s `createRow` hands a row a plain
16
+ * index NUMBER, so wrapping unconditionally would call a number.
17
+ *
18
+ * Before this extraction, the sequence was duplicated verbatim (mod `elem`/`loop` naming)
19
+ * between the two builders — exactly the "one decision, two implementations"
20
+ * pattern this repo's CLAUDE.md warns against, and the shape of the original
21
+ * #2859 CI regression (the eager index-wrap leaking into the lazy-shared
22
+ * string). One implementation now, called from both sites, so the decision
23
+ * cannot silently diverge again.
24
+ */
25
+
26
+ import type { BranchLoop, TopLevelLoop } from '../../types.ts'
27
+ import { wrapLoopParamAsAccessor, wrapIndexParamAsAccessor } from '../../utils.ts'
28
+ import { destructureLoopParam, buildPreambleRegionPlans } from '../shared.ts'
29
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
30
+ import { buildLazyRowPlan, type LazyRowPlanData } from './build-lazy-row.ts'
31
+ import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
32
+ import type { PreambleRegionPlan } from './loop.ts'
33
+
34
+ export interface PlainRowInputs {
35
+ loop: TopLevelLoop | BranchLoop
36
+ /** Fully-chained array expression as the CALLER emits it (branch-loop's flatMap form differs from plain's). */
37
+ arrayExpr: string
38
+ callSite: 'plain' | 'branch-plain'
39
+ flatMapLeafItem: boolean
40
+ anchored: boolean
41
+ scope: LazyRowScopeInfo | undefined
42
+ }
43
+
44
+ export interface PlainRowCore {
45
+ indexParam: string
46
+ paramHead: string
47
+ paramUnwrap: string
48
+ preambleRegions: readonly PreambleRegionPlan[]
49
+ lazyRow: LazyRowPlanData | undefined
50
+ /** Item-wrapped, and index-wrapped too IFF the row is NOT lazy-eligible. */
51
+ mapPreambleWrapped: string
52
+ /** Same rule as `mapPreambleWrapped`, applied to `loop.template`. */
53
+ template: string
54
+ /** Item-only wrap — exposed for the few expressions (e.g. `anchorKeyExpr`) built outside this core. */
55
+ wrapItem: (expr: string) => string
56
+ }
57
+
58
+ export function buildPlainRowCore(inputs: PlainRowInputs): PlainRowCore {
59
+ const { loop, arrayExpr, callSite, flatMapLeafItem, anchored, scope } = inputs
60
+
61
+ const wrapItem = (expr: string) => wrapLoopParamAsAccessor(expr, loop.param, loop.paramBindings)
62
+ const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(loop.param, loop.paramBindings)
63
+ const indexParam = loop.index || '__idx'
64
+
65
+ const mapPreambleWrapped = loop.preamble
66
+ ? renderPreamble(loop.preamble, {
67
+ transformJs: wrapItem,
68
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
69
+ })
70
+ : ''
71
+ const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings, loop.index)
72
+
73
+ // Lazy row graph (§9, L3). `undefined` for every ineligible loop, which
74
+ // then keeps the eager emission below byte-for-byte. Decided from the
75
+ // item-only-wrapped `mapPreambleWrapped` above — eligibility itself reads
76
+ // `loop.preamble` (raw), not this string, so which wrap it carries doesn't
77
+ // change the verdict.
78
+ const lazyRow = buildLazyRowPlan({
79
+ loop,
80
+ arrayExpr,
81
+ indexParam,
82
+ paramUnwrap,
83
+ mapPreambleWrapped,
84
+ preambleRegionCount: preambleRegions.length,
85
+ callSite,
86
+ flatMapLeafItem,
87
+ anchored,
88
+ scope,
89
+ }) ?? undefined
90
+
91
+ // Only once lazy is ruled out is it safe to ALSO wrap index references
92
+ // (#2859 follow-up) — see the module docstring.
93
+ const mapPreambleWrappedFinal = !lazyRow && loop.index
94
+ ? wrapIndexParamAsAccessor(mapPreambleWrapped, loop.index)
95
+ : mapPreambleWrapped
96
+ const templateFinal = !lazyRow && loop.index
97
+ ? wrapIndexParamAsAccessor(loop.template, loop.index)
98
+ : loop.template
99
+
100
+ return {
101
+ indexParam,
102
+ paramHead,
103
+ paramUnwrap,
104
+ preambleRegions,
105
+ lazyRow,
106
+ mapPreambleWrapped: mapPreambleWrappedFinal,
107
+ template: templateFinal,
108
+ wrapItem,
109
+ }
110
+ }
@@ -52,6 +52,8 @@ export interface BuildReactiveEffectsArgs {
52
52
  conditionals: readonly LoopChildConditional[] | undefined
53
53
  loopParam: string
54
54
  loopParamBindings?: readonly LoopParamBinding[]
55
+ /** This loop's index param name, when it declares one (#2859). */
56
+ loopIndex?: string | null
55
57
  /** Owning component name in profile mode (#1690, SR3) — else undefined. */
56
58
  profileComponentName?: string
57
59
  }
@@ -60,8 +62,8 @@ export interface BuildReactiveEffectsArgs {
60
62
  export function buildReactiveEffectsPlan(
61
63
  args: BuildReactiveEffectsArgs,
62
64
  ): ReactiveEffectsPlan {
63
- const { attrs, texts, conditionals, loopParam, loopParamBindings, profileComponentName } = args
64
- const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings)
65
+ const { attrs, texts, conditionals, loopParam, loopParamBindings, loopIndex, profileComponentName } = args
66
+ const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex)
65
67
 
66
68
  // 1. Group attrs by slot, preserving declaration order (Map insertion order
67
69
  // matches the legacy iteration that produced byte-identical output).
@@ -175,6 +177,7 @@ export function buildLoopReactiveEffectsPlan(elem: TopLevelLoop, profileComponen
175
177
  conditionals: elem.bindings.conditionals,
176
178
  loopParam: elem.param,
177
179
  loopParamBindings: elem.paramBindings,
180
+ loopIndex: elem.index,
178
181
  profileComponentName,
179
182
  })
180
183
  }
@@ -47,9 +47,11 @@
47
47
  * defeating the hoist. (A literal `${` in authored TEXT would also refuse
48
48
  * here; that is the safe direction — a false refusal costs the eager
49
49
  * fallback, a false accept would ship a frozen arm.)
50
- * 4. **The condition does not read the loop INDEX.** `applyItem` and
51
- * `applyOuter` have no index parameter, the same reason
52
- * `ClassifiedLazyBinding.referencesIndex` refuses a binding.
50
+ * 4. (#2859 follow-up) A condition MAY read the loop index it is
51
+ * classified like any other binding (`classifyLazyBinding` in
52
+ * `lazy-row-eligibility.ts`), which forces it into `applyItem` so a
53
+ * reorder (a position change the reconciler tracks on `entry.index`)
54
+ * re-evaluates it, not just an item change.
53
55
  *
54
56
  * Everything refused carries a specific reason, which `lazyRowEligibility`
55
57
  * passes through unchanged.
@@ -99,13 +101,9 @@ function wiringOn(branch: LoopChildBranchSummary): string[] {
99
101
 
100
102
  /**
101
103
  * Decide whether `cond` can be driven from the loop-level apply bodies.
102
- *
103
- * `indexParam` is the loop's index parameter name as the emitter uses it
104
- * (`elem.index || '__idx'`); a condition reading it is refused.
105
104
  */
106
105
  export function analyzeLazyConditional(
107
106
  cond: LoopChildConditional,
108
- indexParam: string,
109
107
  arms: PreparedArms,
110
108
  ): LazyConditionalAnalysis {
111
109
  for (const [label, branch] of [['true', cond.whenTrue], ['false', cond.whenFalse]] as const) {
@@ -135,9 +133,12 @@ export function analyzeLazyConditional(
135
133
  if (!cond.conditionFreeIdentifiers) {
136
134
  return NO(`conditional on slot ${cond.slotId}: condition has no analyzable identifier set`)
137
135
  }
138
- if (cond.conditionFreeIdentifiers.has(indexParam)) {
139
- return NO(`conditional on slot ${cond.slotId}: condition reads the loop index parameter '${indexParam}'`)
140
- }
136
+ // A condition reading the loop index (#2859 follow-up, widening from a hard
137
+ // refusal) is no longer refused here: the caller classifies the condition
138
+ // like any other binding (`classifyLazyBinding` in `lazy-row-eligibility.ts`),
139
+ // which forces `readsItem: true` for an index reference — the reconciler
140
+ // delivers the row's current position on `entry.index` the same way it
141
+ // delivers the item, so the condition is re-evaluated on a reorder too.
141
142
 
142
143
  return {
143
144
  lazySafe: true,