@barefootjs/jsx 0.34.0 → 0.35.1

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 (148) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1072 -903
  6. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  19. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -2
  21. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  23. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  25. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  27. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  30. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  32. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  34. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  37. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  39. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  41. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  46. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  48. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  51. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/imports.d.ts +2 -2
  53. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  56. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  57. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  58. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  59. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  60. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  61. package/dist/ir-to-client-js/types.d.ts +18 -2
  62. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/utils.d.ts +34 -1
  64. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  65. package/dist/jsx-to-ir.d.ts.map +1 -1
  66. package/dist/prop-rewrite.d.ts +8 -3
  67. package/dist/prop-rewrite.d.ts.map +1 -1
  68. package/dist/props-binding.d.ts +22 -1
  69. package/dist/props-binding.d.ts.map +1 -1
  70. package/dist/reactivity-checker.d.ts +18 -0
  71. package/dist/reactivity-checker.d.ts.map +1 -1
  72. package/dist/ssr-defaults.d.ts.map +1 -1
  73. package/dist/types.d.ts +5 -10
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +2 -2
  76. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
  77. package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
  78. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  79. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  80. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  81. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  82. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  83. package/src/__tests__/flatmap-segments.test.ts +1 -1
  84. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  85. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  86. package/src/__tests__/lazy-conditional.test.ts +52 -12
  87. package/src/__tests__/lazy-preamble.test.ts +11 -11
  88. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  89. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  90. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  91. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  92. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  93. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  94. package/src/__tests__/nested-loop-index-param.test.ts +41 -8
  95. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  96. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  97. package/src/__tests__/nested-loop-reactive-attrs.test.ts +16 -6
  98. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  99. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  100. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  101. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  102. package/src/__tests__/ssr-defaults.test.ts +59 -0
  103. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  104. package/src/analyzer-context.ts +12 -10
  105. package/src/analyzer.ts +4 -0
  106. package/src/index.ts +8 -2
  107. package/src/ir-to-client-js/collect-elements.ts +126 -32
  108. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  109. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  110. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
  111. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
  112. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
  113. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
  114. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  115. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
  116. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
  117. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  118. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  119. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  120. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  121. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  122. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  123. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  124. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  125. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  126. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  127. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +22 -15
  128. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  129. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  130. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  131. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  132. package/src/ir-to-client-js/emit-reactive.ts +92 -6
  133. package/src/ir-to-client-js/emit-registration.ts +2 -4
  134. package/src/ir-to-client-js/html-template.ts +67 -245
  135. package/src/ir-to-client-js/imports.ts +3 -0
  136. package/src/ir-to-client-js/index.ts +4 -4
  137. package/src/ir-to-client-js/markup-slots.ts +25 -0
  138. package/src/ir-to-client-js/reactivity.ts +73 -27
  139. package/src/ir-to-client-js/safe-html.ts +243 -0
  140. package/src/ir-to-client-js/types.ts +19 -3
  141. package/src/ir-to-client-js/utils.ts +58 -10
  142. package/src/jsx-to-ir.ts +136 -24
  143. package/src/prop-rewrite.ts +8 -4
  144. package/src/props-binding.ts +37 -1
  145. package/src/reactivity-checker.ts +60 -0
  146. package/src/ssr-defaults.ts +45 -0
  147. package/src/types.ts +5 -10
  148. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -12,8 +12,9 @@
12
12
  * from `LoopChildBranchSummary.reactiveAttrs` / `.reactiveTexts` —
13
13
  * collected per-branch, recursively, so a doubly-nested conditional's
14
14
  * arm carries its own bindings instead of an outer scope reaching in.
15
- * 4. Apply `addCondAttrToTemplate` to the wrapped branch HTML so the
16
- * stringifier emits a ready-to-interpolate template literal.
15
+ * 4. Apply `addCondAttrToTemplate` to each branch's HTML (already
16
+ * loop-param-wrapped at IR render time, #2868) so the stringifier
17
+ * emits a ready-to-interpolate template literal.
17
18
  * 5. Recurse into the per-arm sub-plans via `LoopChildArmPlan` —
18
19
  * events, child component inits, inner loops, nested conditionals,
19
20
  * attrs, texts. No legacy passthrough remains.
@@ -52,6 +53,8 @@ export interface BuildReactiveEffectsArgs {
52
53
  conditionals: readonly LoopChildConditional[] | undefined
53
54
  loopParam: string
54
55
  loopParamBindings?: readonly LoopParamBinding[]
56
+ /** This loop's index param name, when it declares one (#2859). */
57
+ loopIndex?: string | null
55
58
  /** Owning component name in profile mode (#1690, SR3) — else undefined. */
56
59
  profileComponentName?: string
57
60
  }
@@ -60,8 +63,8 @@ export interface BuildReactiveEffectsArgs {
60
63
  export function buildReactiveEffectsPlan(
61
64
  args: BuildReactiveEffectsArgs,
62
65
  ): ReactiveEffectsPlan {
63
- const { attrs, texts, conditionals, loopParam, loopParamBindings, profileComponentName } = args
64
- const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings)
66
+ const { attrs, texts, conditionals, loopParam, loopParamBindings, loopIndex, profileComponentName } = args
67
+ const wrap = (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex)
65
68
 
66
69
  // 1. Group attrs by slot, preserving declaration order (Map insertion order
67
70
  // matches the legacy iteration that produced byte-identical output).
@@ -108,10 +111,17 @@ export function buildReactiveEffectsPlan(
108
111
  conditionalPlans.push({
109
112
  slotId: cond.slotId,
110
113
  wrappedCondition: wrap(cond.condition),
111
- whenTrueTemplateHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
112
- whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
113
- whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
114
- whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
114
+ // whenTrueHtml/whenFalseHtml are already loop-param-wrapped at IR
115
+ // render time (`irToHtmlTemplate`'s `loopParams`, collect-elements.ts)
116
+ // do NOT re-wrap the rendered HTML string here. A word-boundary
117
+ // regex over already-assembled markup matches a bare tag name that
118
+ // collides with the param/index identifier (`<i>` -> `<i()>`) just
119
+ // as readily as a real reference, since `<`/`>` are non-word
120
+ // characters (#2868).
121
+ whenTrueTemplateHtml: addCondAttrToTemplate(cond.whenTrueHtml, cond.slotId),
122
+ whenFalseTemplateHtml: addCondAttrToTemplate(cond.whenFalseHtml, cond.slotId),
123
+ whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, loopIndex, cond.slotId, profileComponentName),
124
+ whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, loopIndex, cond.slotId, profileComponentName),
115
125
  ...(cond.readsPreamble && { readsPreamble: true }),
116
126
  })
117
127
  }
@@ -130,6 +140,8 @@ function buildOuterArm(
130
140
  wrap: (expr: string) => string,
131
141
  loopParam: string,
132
142
  loopParamBindings: readonly LoopParamBinding[] | undefined,
143
+ /** This loop's index param name, when it declares one (#2861). */
144
+ loopIndex: string | null | undefined,
133
145
  /** The conditional's own slot id — threaded to `buildBranchInnerLoopsPlan`'s `condSlotId` (#2705). */
134
146
  condSlotId: string,
135
147
  profileComponentName?: string,
@@ -150,6 +162,7 @@ function buildOuterArm(
150
162
  condSlotId,
151
163
  outerLoopParam: loopParam,
152
164
  outerLoopParamBindings: loopParamBindings,
165
+ outerLoopIndex: loopIndex,
153
166
  wrapOuter: wrap,
154
167
  }),
155
168
  nestedConditionals: buildLoopChildConditionalsPlan({
@@ -158,6 +171,7 @@ function buildOuterArm(
158
171
  wrap,
159
172
  loopParam,
160
173
  loopParamBindings,
174
+ loopIndex,
161
175
  }),
162
176
  attrs: buildArmAttrsPlan(branch.reactiveAttrs, wrap),
163
177
  texts: buildArmTextsPlan(branch.reactiveTexts, wrap),
@@ -175,6 +189,7 @@ export function buildLoopReactiveEffectsPlan(elem: TopLevelLoop, profileComponen
175
189
  conditionals: elem.bindings.conditionals,
176
190
  loopParam: elem.param,
177
191
  loopParamBindings: elem.paramBindings,
192
+ loopIndex: elem.index,
178
193
  profileComponentName,
179
194
  })
180
195
  }
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * Plan types for inner loops emitted inside a composite loop's renderItem
3
- * body. Each `InnerLoopPlan` is one level (= one `mapArray` for reactive
4
- * inner loops, one `forEach` for static ones); `childLevels` recurses for
5
- * deeper nestings.
3
+ * body. Each `InnerLoopPlan` is one level (= one `mapArray`); `childLevels`
4
+ * recurses for deeper nestings.
6
5
  *
7
6
  * Replaces the legacy `emitInnerLoopSetup` + `DepthLevel` walk. The
8
- * builder pre-resolves every per-level decision (mode, container query,
9
- * wrapped array / template / key, narrowed-outer-param children) so the
7
+ * builder pre-resolves every per-level decision (container query, wrapped
8
+ * array / template / key, narrowed-outer-param children) so the
10
9
  * stringifier is a deterministic walk.
10
+ *
11
+ * Every level gets the reactive `mapArray` shape (#2865) — there used to
12
+ * be a second "static forEach, setup-only" shape for a nested loop whose
13
+ * array didn't reference the outer item, but that gate answered the wrong
14
+ * question (it never checked whether the array was reactive at all, only
15
+ * whether it mentioned the outer item's name) and silently froze any other
16
+ * reactivity in the row. See `build-inner-loop.ts`'s module docstring.
11
17
  */
12
18
 
13
19
  import type { LoopChildEvent } from '../../types.ts'
@@ -64,20 +70,9 @@ export interface InnerLoopReactiveAttr {
64
70
  }
65
71
 
66
72
  /**
67
- * One inner loop level inside a composite renderItem.
68
- *
69
- * The `mode` discriminator selects the emission shape:
70
- *
71
- * - `reactive`: full `mapArray(...)` with renderItem (template clone,
72
- * key attr, components/events with inner-wrapped props, reactive text
73
- * effects, recursive childLevels).
74
- * - `static`: `forEach(...)` for setup-only (children are rendered
75
- * into the SSR HTML); components/events use the raw param, no
76
- * wrap, no template clone.
77
- *
78
- * Choosing the mode requires checking whether `inner.array` references
79
- * the outer loop param at *this* level (legacy O-8 fix). The builder
80
- * does that check; the stringifier just dispatches on `mode`.
73
+ * One inner loop level inside a composite renderItem: a full `mapArray(...)`
74
+ * with renderItem (template clone, key attr, components/events with
75
+ * inner-wrapped props, reactive text/attr effects, recursive childLevels).
81
76
  */
82
77
  export interface InnerLoopPlan {
83
78
  /** Unique suffix used in `__ic` / `__innerEl` / `__innerIdx` var names. */
@@ -101,21 +96,25 @@ export interface InnerLoopPlan {
101
96
  * `// Initialize <array> loop components and events` comments.
102
97
  */
103
98
  arraySrc: string
104
- /** Inner loop parameter identifier (used by static forEach). */
99
+ /** Inner loop parameter identifier. */
105
100
  param: string
106
101
  /** Depth used by `keyAttrName(...)`. Same as the IR's `inner.depth`. */
107
102
  keyDepth: number
108
- /**
109
- * Per-mode emission data. Discriminator keeps each shape's required fields
110
- * narrowed and prevents accidental cross-talk.
111
- */
112
- emit: InnerLoopReactiveEmit | InnerLoopStaticEmit
103
+ /** Emission data for this level's `mapArray` renderItem. */
104
+ emit: InnerLoopReactiveEmit
113
105
  /** Recursive child levels — narrowed-outer-param at build time. */
114
106
  childLevels: readonly InnerLoopPlan[]
115
107
  /** Outer loop param threaded into emitComponentAndEventSetup. */
116
108
  outerLoopParam: string | undefined
117
109
  /** Outer loop param destructuring metadata. */
118
110
  outerLoopParamBindings?: readonly LoopParamBinding[]
111
+ /**
112
+ * Outer loop's index param name, when present (#2861) — threaded into
113
+ * `emitComponentAndEventSetup` so a component prop / event handler in
114
+ * this row that closes over the OUTER loop's index reads it through the
115
+ * live accessor instead of a value frozen at row-creation time.
116
+ */
117
+ outerLoopIndex?: string | null
119
118
  }
120
119
 
121
120
  export interface InnerLoopReactiveEmit {
@@ -164,24 +163,4 @@ export interface InnerLoopReactiveEmit {
164
163
  childRefs: readonly LoopChildRefBinding[]
165
164
  }
166
165
 
167
- export interface InnerLoopStaticEmit {
168
- mode: 'static'
169
- /** Raw key expression (used as-is in setAttribute) — null when no key. */
170
- rawKey: string | null
171
- /**
172
- * Body-entry statements (see `PreludeStatements`) emitted at the top of
173
- * the static `forEach(...)` body, after the existence guard. Holds the
174
- * inner `.map()` callback preamble locals (#1064). Emitted unwrapped
175
- * because the forEach param is the literal item, not a signal accessor —
176
- * no accessor rewrite is needed.
177
- */
178
- preludeStatements: PreludeStatements
179
- /** Raw components (no inner-wrap; the static body has no signal accessor). */
180
- components: readonly IRLoopChildComponent[]
181
- /** Raw events (no inner-wrap). */
182
- events: readonly LoopChildEvent[]
183
- /** Imperative ref callbacks (unwrapped — static body has no signal accessor). */
184
- childRefs: readonly LoopChildRefBinding[]
185
- }
186
-
187
166
  export type InnerLoopsPlan = readonly InnerLoopPlan[]
@@ -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,
@@ -68,9 +68,10 @@
68
68
  * markup, which is `preambleRegions` territory (refused separately).
69
69
  * - No `builderNames`. Same shape as above, pinned explicitly so the reason
70
70
  * names it rather than surfacing as a confusing "contains a call".
71
- * - The preamble does not read the loop's INDEX parameter. `applyItem` and
72
- * `applyOuter` have no index to give it the same reason
73
- * `ClassifiedLazyBinding.referencesIndex` refuses a binding.
71
+ * - (#2859 follow-up) The preamble MAY read the loop's index parameter it
72
+ * survives into `freeNames` below like any other read, so a binding that
73
+ * names this preamble local substitutes through to it and is classified
74
+ * the same way a direct index reference is (forced into `applyItem`).
74
75
  *
75
76
  * Member access is deliberately NOT banned even though a getter could in
76
77
  * principle run code: a binding expression like `class={row.cls}` is already
@@ -130,9 +131,6 @@ const NO = (reason: string): LazyPreambleAnalysis => ({ lazySafe: false, reason
130
131
  * Decide whether `preamble` may be re-executed inside a lazy row's apply
131
132
  * bodies, and if so what it declares and reads.
132
133
  *
133
- * `indexParam` is the loop's index parameter name as the emitter uses it
134
- * (`elem.index || '__idx'`); a preamble reading it is refused.
135
- *
136
134
  * `primableNames` are the component's signal getters and memos — the only
137
135
  * callees a preamble initializer may invoke (see the module docstring). Pass
138
136
  * the same names `LazyRowScopeInfo` carries, so "primable here" and "primable
@@ -140,7 +138,6 @@ const NO = (reason: string): LazyPreambleAnalysis => ({ lazySafe: false, reason
140
138
  */
141
139
  export function analyzeLazyPreamble(
142
140
  preamble: MapCallbackPreamble | undefined,
143
- indexParam: string,
144
141
  primableNames: ReadonlySet<string>,
145
142
  ): LazyPreambleAnalysis {
146
143
  if (!preamble) return NO_PREAMBLE
@@ -196,13 +193,14 @@ export function analyzeLazyPreamble(
196
193
  }
197
194
  }
198
195
 
199
- // The index parameter does not exist in `applyItem` / `applyOuter`, and
200
- // `createRow` receives it but the preamble must read the same values in
201
- // every body it runs in. Same reason `referencesIndex` refuses a binding.
196
+ // A preamble reading the loop index (#2859 follow-up, widening from a hard
197
+ // refusal) is no longer refused here: `indexParam` survives into
198
+ // `freeNames` below, and any binding that names this preamble local
199
+ // substitutes through to it (`classifyLazyBinding`'s preamble handling),
200
+ // which classifies a free `indexParam` the same way a direct reference
201
+ // would — forcing the binding into `applyItem` so a reorder re-evaluates
202
+ // it via `entry.index`.
202
203
  const readNames = extractFreeIdentifiersFromStatementText(text)
203
- if (readNames.has(indexParam) && !declaredNames.has(indexParam)) {
204
- return NO(`map-callback preamble reads the loop index parameter '${indexParam}'`)
205
- }
206
204
 
207
205
  const freeNames = new Set(readNames)
208
206
  for (const declared of declaredNames) freeNames.delete(declared)
@@ -284,9 +284,15 @@ export function lazyRowEligibility(args: LazyRowEligibilityArgs): LazyRowEligibi
284
284
 
285
285
  // Per-binding gates.
286
286
  for (const b of bindings) {
287
- if (b.referencesIndex) {
288
- return NO(`binding on slot ${b.slotId} references the loop index parameter`)
289
- }
287
+ // A binding that references the loop index (#2859 follow-up, widening
288
+ // from a hard refusal): `applyItem`/`applyOuter` have no index PARAMETER,
289
+ // but the reconciler tracks each entry's current position on `entry.index`
290
+ // (`LazyRowEntry.index`, `map-array-lazy.ts`) exactly like it tracks
291
+ // `entry.item` — a reorder is a position change the reconciler already
292
+ // detects, the same way an item change is. `classifyLazyBinding` forces
293
+ // `readsItem: true` for any binding with `referencesIndex`, so it is
294
+ // ALWAYS emitted into `applyItem`, and the runtime calls `applyItem` on a
295
+ // pure reorder too when `plan.indexDriven` (`LazyRowPlanData.readsIndex`).
290
296
  // An identifier set we could not compute at all is different in kind
291
297
  // from a name we CAN see but cannot prime. With `free === null` the
292
298
  // classifier knows nothing — not the outer reads, and not whether the
@@ -445,7 +451,11 @@ export function classifyLazyBinding(args: {
445
451
  return
446
452
  }
447
453
  if (name === indexParam) {
454
+ // The row's current position (#2859 follow-up) — the reconciler
455
+ // delivers it on `entry.index` exactly like it delivers the item on
456
+ // `entry.item`, so a binding that reads it is item-driven too.
448
457
  referencesIndex = true
458
+ readsItem = true
449
459
  return
450
460
  }
451
461
  if (INERT_BINDING_GLOBALS.has(name)) return
@@ -146,6 +146,13 @@ export interface BranchInnerLoop {
146
146
  outerLoopParam: string
147
147
  /** Outer loop param destructuring metadata. */
148
148
  outerLoopParamBindings?: readonly import('../../../types.ts').LoopParamBinding[]
149
+ /**
150
+ * Outer loop's index param name, when present (#2861) — threaded into
151
+ * `emitComponentAndEventSetup`'s own outer-wrap pass alongside
152
+ * `outerLoopParam`/`outerLoopParamBindings`, mirroring `InnerLoopPlan`'s
153
+ * field of the same name.
154
+ */
155
+ outerLoopIndex?: string | null
149
156
  }
150
157
 
151
158
  export type BranchInnerLoopsPlan = readonly BranchInnerLoop[]
@@ -206,4 +213,11 @@ export interface LoopChildConditionalPlan {
206
213
  whenFalseTemplateHtml: string
207
214
  whenTrueArm: LoopChildArmPlan
208
215
  whenFalseArm: LoopChildArmPlan
216
+ /**
217
+ * `wrappedCondition` reads a `.map()` callback preamble local (#2596,
218
+ * same field as `NestedConditionalPlan.readsPreamble` one level up) —
219
+ * the stringifier must run the row's preamble inside the getter passed
220
+ * to `insert()`; the local isn't otherwise in scope there.
221
+ */
222
+ readsPreamble?: boolean
209
223
  }
@@ -23,6 +23,7 @@ import type {
23
23
  TopLevelLoop,
24
24
  } from '../../types.ts'
25
25
  import type { IRLoopChildComponent } from '../../../types.ts'
26
+ import type { SafeHtml } from '../../safe-html.ts'
26
27
  import type { SkeletonSlotPaths } from '../../html-template.ts'
27
28
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
28
29
  import type { InnerLoopsPlan } from './inner-loop.ts'
@@ -83,15 +84,16 @@ interface DynamicLoopCommon extends LoopPlanCommon {
83
84
 
84
85
  /**
85
86
  * A resolved preamble-patched region (#2389), ready to emit: `valueExpr` is
86
- * already wrapped with the loop-param accessor and, for a `joinArrayChild`
87
- * source, already carries the array-join ternary — the exact value
88
- * `irToHtmlTemplate` computes for the same node in the row template, so a
89
- * same-key update's re-patch stays byte-identical to what a fresh mount
90
- * would render.
87
+ * already wrapped with the loop-param accessor and already went through the
88
+ * child-splice door (`safe-html.ts`) — the exact value `irToHtmlTemplate`
89
+ * computes for the same node in the row template, so a same-key update's
90
+ * re-patch stays byte-identical to what a fresh mount would render. Typed
91
+ * `SafeHtml` because the region writer is an `innerHTML` write: only a
92
+ * value the door (or a named producer) vouched for may reach it.
91
93
  */
92
94
  export interface PreambleRegionPlan {
93
95
  slotId: string
94
- valueExpr: string
96
+ valueExpr: SafeHtml
95
97
  }
96
98
 
97
99
  /** Per-item ref callback resolved against a child slot for emission (#1244). */
@@ -237,6 +239,8 @@ interface CompositeLoopVariant extends DynamicLoopCommon {
237
239
  loopParam: string
238
240
  /** Destructured-binding metadata for the loop param. */
239
241
  loopParamBindings: TopLevelLoop['paramBindings']
242
+ /** This loop's index param name, when it declares one (#2859). */
243
+ loopIndex: TopLevelLoop['index']
240
244
  /** Reactive effects rendered after the SSR/CSR split. */
241
245
  reactiveEffects: ReactiveEffectsPlan | null
242
246
  /**
@@ -17,6 +17,7 @@ import type { IRLoopChildComponent, LoopParamBinding } from '../../types.ts'
17
17
  import { preambleAnalysisText } from '../../types.ts'
18
18
  import { quotePropName, wrapLoopParamAsAccessor, irChildrenFreeIds, attrValueToString } from '../utils.ts'
19
19
  import { irChildrenToJsExpr, jsxChildrenPropGetterExpr } from '../html-template.ts'
20
+ import { spliceChildValue } from '../safe-html.ts'
20
21
  import { emitListenerBlock } from './stringify/event-listener.ts'
21
22
  import { nameForRegistryRef } from '../component-scope.ts'
22
23
  import { BF_SCOPE, BF_HOST, BF_AT } from '@barefootjs/shared'
@@ -147,11 +148,12 @@ export function buildChildRefBindings(
147
148
  refs: readonly LoopChildRef[],
148
149
  loopParam: string,
149
150
  loopParamBindings: readonly LoopParamBinding[] | undefined,
151
+ loopIndex?: string | null,
150
152
  ): readonly LoopChildRefBinding[] {
151
153
  if (refs.length === 0) return []
152
154
  return refs.map(r => ({
153
155
  childSlotId: r.childSlotId,
154
- callback: wrapLoopParamAsAccessor(r.callback, loopParam, loopParamBindings),
156
+ callback: wrapLoopParamAsAccessor(r.callback, loopParam, loopParamBindings, loopIndex),
155
157
  }))
156
158
  }
157
159
 
@@ -241,23 +243,27 @@ export function destructureLoopParam(
241
243
  /**
242
244
  * Resolve `IRLoop.preambleRegions` (#2389) into ready-to-emit
243
245
  * `PreambleRegionPlan`s: wrap each source expression with the loop-param
244
- * accessor and, for a `joinArrayChild` source, apply the same array-join
245
- * ternary `irToHtmlTemplate` uses for the identical node in the row
246
- * template — so the region-patch effect's re-render matches what a fresh
247
- * mount would have produced. Shared by the top-level (`build-loop.ts`) and
248
- * branch (`build-branch-loop.ts`) plain-loop-plan builders.
246
+ * accessor and hand it to the same door (`spliceChildValue`) the row
247
+ * template's `irToHtmlTemplate` uses for the identical node so the
248
+ * region-patch effect's re-render (a `'markup'` claim writer, i.e. an
249
+ * `innerHTML` write) matches what a fresh mount would have produced, and
250
+ * the escape decision is made in exactly one place. Shared by the
251
+ * top-level (`build-loop.ts`) and branch (`build-branch-loop.ts`)
252
+ * plain-loop-plan builders.
249
253
  */
250
254
  export function buildPreambleRegionPlans(
251
255
  regions: readonly PreambleRegionSource[] | undefined,
252
256
  loopParam: string,
253
257
  loopParamBindings: readonly LoopParamBinding[] | undefined,
258
+ loopIndex?: string | null,
254
259
  ): readonly PreambleRegionPlan[] {
255
260
  if (!regions || regions.length === 0) return []
256
261
  return regions.map((r) => {
257
- const wrapped = wrapLoopParamAsAccessor(r.expr, loopParam, loopParamBindings)
258
- const valueExpr = r.joinArrayChild
259
- ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
260
- : `escapeText(${wrapped})`
262
+ const wrapped = wrapLoopParamAsAccessor(r.expr, loopParam, loopParamBindings, loopIndex)
263
+ // The region's row-template twin carries the same `slotId`, so the door
264
+ // takes the slotted arm; no `markupSlotIds` here a loop-row slot's
265
+ // initial render is plain `escapeText`, matching `irToHtmlTemplate`.
266
+ const valueExpr = spliceChildValue({ expr: r.expr, slotId: r.slotId, joinArrayChild: r.joinArrayChild }, wrapped, {})
261
267
  return { slotId: r.slotId, valueExpr }
262
268
  })
263
269
  }
@@ -270,8 +276,9 @@ export function buildComponentPropsExpr(
270
276
  comp: { props: Array<{ name: string; value: import('../../types.ts').AttrValue; isEventHandler: boolean }>, children?: import('../../types.ts').IRNode[] },
271
277
  loopParam?: string,
272
278
  loopParamBindings?: readonly LoopParamBinding[],
279
+ loopIndex?: string | null,
273
280
  ): string {
274
- const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings) : (expr: string) => expr
281
+ const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex) : (expr: string) => expr
275
282
  const entries = comp.props.map((p) => {
276
283
  if (p.isEventHandler) {
277
284
  const handlerExpr = attrValueToString(p.value) ?? 'undefined'
@@ -347,7 +354,20 @@ export function buildDepthLevels(
347
354
  }))
348
355
  }
349
356
 
350
- /** Emit a single addEventListener call for a child event on a given element. */
357
+ /**
358
+ * Emit a single addEventListener call for a child event on a given element.
359
+ *
360
+ * This is a DIRECT, one-time listener attach (composite/inner/conditional-arm
361
+ * loop shapes) — unlike the top-level delegated click dispatcher
362
+ * (`build-event-delegation.ts`), which re-derives the current index at click
363
+ * time via `arr.findIndex(...)` and is unaffected by this. A handler wired
364
+ * here closes over whatever `renderItem` bound its raw index parameter to at
365
+ * ROW CREATION time, so a reference to it needs the same accessor-call
366
+ * rewrite as every other loop-index read in this row (#2859) — otherwise a
367
+ * row that survives a same-key reorder keeps firing the handler with the
368
+ * stale, as-created index forever, the click-handler analogue of the
369
+ * render-output bug #2859 itself fixes.
370
+ */
351
371
  function emitEventSetup(
352
372
  ls: string[],
353
373
  indent: string,
@@ -356,8 +376,9 @@ function emitEventSetup(
356
376
  loopParam?: string,
357
377
  loopParamBindings?: readonly LoopParamBinding[],
358
378
  bodyIsMultiRoot: boolean = false,
379
+ loopIndex?: string | null,
359
380
  ): void {
360
- const handler = loopParam ? wrapLoopParamAsAccessor(ev.handler, loopParam, loopParamBindings) : ev.handler
381
+ const handler = loopParam ? wrapLoopParamAsAccessor(ev.handler, loopParam, loopParamBindings, loopIndex) : ev.handler
361
382
  emitListenerBlock(ls, indent, elVar, ev.childSlotId, '__e', ev.eventName, handler, 'dom', bodyIsMultiRoot)
362
383
  }
363
384
 
@@ -412,14 +433,15 @@ export function emitComponentAndEventSetup(
412
433
  loopParam?: string,
413
434
  loopParamBindings?: readonly LoopParamBinding[],
414
435
  bodyIsMultiRoot: boolean = false,
436
+ loopIndex?: string | null,
415
437
  ): void {
416
- const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings) : (expr: string) => expr
438
+ const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex) : (expr: string) => expr
417
439
  // Multi-root loop bodies (#1212) must search the item's sibling roots
418
440
  // and the pre-insertion `__bfExtras` stash; `upsertChildItem` wraps the
419
441
  // single-root `upsertChild` semantics in that walk.
420
442
  const upsertFn = bodyIsMultiRoot ? 'upsertChildItem' : 'upsertChild'
421
443
  for (const comp of comps) {
422
- const propsExpr = buildComponentPropsExpr(comp, loopParam, loopParamBindings)
444
+ const propsExpr = buildComponentPropsExpr(comp, loopParam, loopParamBindings, loopIndex)
423
445
  // Check if children are text-equivalent and reference the loop param — if so,
424
446
  // emit a createEffect to reactively update textContent when the signal changes.
425
447
  // Text-equivalent: expression, text, or conditional with text-only branches.
@@ -429,7 +451,8 @@ export function emitComponentAndEventSetup(
429
451
  const rawChildrenExpr = isTextOnly ? irChildrenToJsExpr(comp.children!) : null
430
452
  const childrenFreeIds = isTextOnly && comp.children ? irChildrenFreeIds(comp.children) : undefined
431
453
  const childrenRefsLoop = loopParam != null && rawChildrenExpr != null && childrenFreeIds != null
432
- && exprRefsLoopBinding(childrenFreeIds, { param: loopParam, paramBindings: loopParamBindings })
454
+ && (exprRefsLoopBinding(childrenFreeIds, { param: loopParam, paramBindings: loopParamBindings })
455
+ || (!!loopIndex && childrenFreeIds.has(loopIndex)))
433
456
 
434
457
  const slotIdLit = comp.slotId ? `'${comp.slotId}'` : 'null'
435
458
  const keyProp = comp.props.find(p => p.name === 'key')
@@ -449,7 +472,7 @@ export function emitComponentAndEventSetup(
449
472
  }
450
473
  }
451
474
  for (const ev of events) {
452
- emitEventSetup(ls, indent, elVar, ev, loopParam, loopParamBindings, bodyIsMultiRoot)
475
+ emitEventSetup(ls, indent, elVar, ev, loopParam, loopParamBindings, bodyIsMultiRoot, loopIndex)
453
476
  }
454
477
  }
455
478
 
@@ -53,6 +53,7 @@ export function stringifyCompositeLoop(lines: string[], plan: CompositeLoopPlan)
53
53
  innerLoops,
54
54
  loopParam,
55
55
  loopParamBindings,
56
+ loopIndex,
56
57
  reactiveEffects,
57
58
  childRefs,
58
59
  branchClearChildren,
@@ -124,7 +125,7 @@ export function stringifyCompositeLoop(lines: string[], plan: CompositeLoopPlan)
124
125
  // variant whose tail needs the row already connected.
125
126
  mountRow: true,
126
127
  })
127
- emitComponentAndEventSetup(lines, bodyIndent, '__el', compsArr, eventsArr, loopParam, loopParamBindings, bodyIsMultiRoot)
128
+ emitComponentAndEventSetup(lines, bodyIndent, '__el', compsArr, eventsArr, loopParam, loopParamBindings, bodyIsMultiRoot, loopIndex)
128
129
  if (innerLoops.length > 0) {
129
130
  stringifyInnerLoops(lines, innerLoops, bodyIndent, pc)
130
131
  }