@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
@@ -38,15 +38,27 @@ import { identifierCallPattern } from '../identifier-pattern.ts'
38
38
  * `index` field through before this fix (see each call site's own
39
39
  * `undefined`/omitted-arg default), so `undefined` here still means "not
40
40
  * guarded," not "no index param."
41
+ *
42
+ * `parentScope` (#2861): stacks this loop-row frame on top of an
43
+ * ENCLOSING loop's own scope, rather than always starting from
44
+ * `BindingScope.EMPTY` — for a nested `.map()` inside another `.map()`'s
45
+ * JSX body. `BindingScope.lookup`/`valueBoundNames` search innermost-first
46
+ * across every frame, so a row expression referencing the OUTER loop's
47
+ * item/index (with no dependency on the INNER loop's own item/index or any
48
+ * signal) is still granted a slot and classified reactive, instead of
49
+ * silently returning `'none'` because only the inner-most frame was ever
50
+ * visible. Omitted (or `undefined`) for a loop with no enclosing loop —
51
+ * identical to the pre-#2861 shape.
41
52
  */
42
53
  export function buildLoopRowScope(
43
54
  loopParam?: string,
44
55
  loopParamBindings?: readonly LoopParamBinding[],
45
56
  preambleNames?: ReadonlySet<string>,
46
57
  loopIndex?: string | null,
58
+ parentScope?: BindingScope,
47
59
  ): BindingScope | undefined {
48
60
  if (!loopParam) return undefined
49
- return BindingScope.EMPTY.enterLoopRow({
61
+ return (parentScope ?? BindingScope.EMPTY).enterLoopRow({
50
62
  param: loopParam,
51
63
  paramBindings: loopParamBindings,
52
64
  index: loopIndex,
@@ -274,14 +286,28 @@ function needsEffectWrapperCore(
274
286
  /**
275
287
  * Why a given expression should be treated as reactive inside a loop item.
276
288
  *
277
- * Surfaces the two distinct reasons loop-child collectors care about:
289
+ * Surfaces the three distinct reasons loop-child collectors care about:
278
290
  * - `signal-or-memo-or-prop` — the expression reads a signal getter, a memo,
279
291
  * or a prop name (what `needsEffectWrapper` already classifies).
280
- * - `loop-param` — the expression reads the loop parameter, which becomes a
281
- * per-item signal accessor at runtime; the string-level `needsEffectWrapper`
282
- * does not know about loop params.
292
+ * - `loop-param` — the expression reads the loop's item (or a destructured
293
+ * binding of it), which becomes a per-item signal accessor at runtime;
294
+ * the string-level `needsEffectWrapper` does not know about loop params.
295
+ * - `loop-index` (#2861) — the expression reads ONLY the loop's index
296
+ * parameter (`.map((item, i) => ...)`'s `i`), with no item/signal/memo/prop
297
+ * read anywhere else in it (e.g. bare `{i}`, `class={i % 2 === 0 ? …}`).
298
+ * `mapArray`/`mapArrayAnchored` hand `renderItem` an index ACCESSOR
299
+ * exactly like the item accessor (#2859/#2860), and `mapArrayLazy` tracks
300
+ * a row's position on `entry.index` exactly like `entry.item` — but
301
+ * before this classification existed, nothing ever put such an
302
+ * expression in front of that machinery in the first place: Phase 1
303
+ * (`jsx-to-ir.ts`'s `referencesLoopParam`) already grants it a patchable
304
+ * slot via `BindingScope.valueBoundNames()` (item/index/destructure
305
+ * together), while this function — Phase 2 — had no equivalent case for
306
+ * the index alone, so the slot was written once at row-creation time and
307
+ * never revisited.
283
308
  *
284
- * `none` means neither applies and the collector can skip the expression.
309
+ * `none` means none of the three applies and the collector can skip the
310
+ * expression.
285
311
  *
286
312
  * Consolidates the duplicated reactive-classification check shared by
287
313
  * `collectLoopChildReactiveTexts`, `collectLoopChildReactiveAttrs`, and
@@ -291,37 +317,53 @@ export type ReactivitySource =
291
317
  | { kind: 'none' }
292
318
  | { kind: 'signal-or-memo-or-prop' }
293
319
  | { kind: 'loop-param'; param: string }
320
+ | { kind: 'loop-index'; param: string }
294
321
 
295
322
  /**
296
323
  * Classify a (constant-expanded) expression as reactive inside a loop item.
297
- * See `ReactivitySource` for the two reasons we care about. Loop-param
298
- * matching takes precedence so `loop-param` is reported even when the
299
- * expression also reads a signal — the `kind` is purely informational and
300
- * collectors only care about `kind !== 'none'`.
324
+ * See `ReactivitySource` for the three reasons we care about. Loop-param
325
+ * and loop-index matching take precedence so one of them is reported even
326
+ * when the expression also reads a signal — the `kind` is purely
327
+ * informational and collectors only care about `kind !== 'none'`.
328
+ *
329
+ * Takes the row's `BindingScope` (#2482) rather than a raw `(loopParam,
330
+ * loopParamBindings)` pair so this reads the EXACT SAME query Phase 1's
331
+ * `referencesLoopParam` reads (`scope.valueBoundNames()` — item/index/
332
+ * destructure together) to decide whether an expression gets a patchable
333
+ * slot at all. `undefined` means no enclosing loop.
334
+ *
335
+ * For destructured `.map()` callbacks (#951), a `'destructure'`-sourced
336
+ * binding is reported as `loop-param` — the pattern text itself (e.g.
337
+ * `{ id, label }`) never word-matches on a bare binding like `id`, so
338
+ * iterating `valueBoundNames()` (rather than testing the raw param text)
339
+ * is what catches a destructured reference at all.
301
340
  *
302
- * For destructured `.map()` callbacks (#951), `loopParamBindings` lists each
303
- * destructured binding name; any reference to one of those names is treated
304
- * as a `loop-param` hit. The pattern text itself (e.g. `{ id, label }`)
305
- * never word-matches on a bare binding like `id`, so the straight
306
- * `loopParam` check misses destructured references without this widening.
341
+ * Item/destructure bindings are checked before the index so `loop-param`
342
+ * wins when an expression reads both (e.g. `` `${item.id}-${i}` ``) this
343
+ * only affects which `kind` is reported, since every caller of this
344
+ * function besides the two `ReactivitySource`-typed union members simply
345
+ * tests `.kind !== 'none'`.
307
346
  */
308
347
  export function classifyReactivity(
309
348
  expr: string,
310
349
  ctx: ClientJsContext,
311
- loopParam?: string,
312
- loopParamBindings?: readonly LoopParamBinding[],
350
+ scope: BindingScope | undefined,
313
351
  freeIdentifiers?: ReadonlySet<string>,
314
352
  ): ReactivitySource {
315
353
  const has = (name: string): boolean =>
316
354
  freeIdentifiers ? freeIdentifiers.has(name) : tokenContainsIdent(expr, name)
317
- if (loopParamBindings && loopParamBindings.length > 0) {
318
- for (const b of loopParamBindings) {
319
- if (has(b.name)) {
320
- return { kind: 'loop-param', param: loopParam ?? b.name }
355
+ if (scope) {
356
+ let indexHit: string | undefined
357
+ for (const name of scope.valueBoundNames()) {
358
+ if (!has(name)) continue
359
+ const source = scope.lookup(name)?.binding.source
360
+ if (source === 'index') {
361
+ indexHit ??= name
362
+ continue
321
363
  }
364
+ return { kind: 'loop-param', param: name }
322
365
  }
323
- } else if (loopParam && has(loopParam)) {
324
- return { kind: 'loop-param', param: loopParam }
366
+ if (indexHit) return { kind: 'loop-index', param: indexHit }
325
367
  }
326
368
  if (needsEffectWrapper(expr, ctx, freeIdentifiers)) {
327
369
  return { kind: 'signal-or-memo-or-prop' }
@@ -666,9 +708,11 @@ export function collectLoopChildReactiveTexts(
666
708
  stopAtReactiveConditionals = false,
667
709
  preambleNames?: ReadonlySet<string>,
668
710
  loopIndex?: string | null,
711
+ /** Enclosing loop's own scope, for a NESTED loop (#2861) — see `buildLoopRowScope`. */
712
+ parentScope?: BindingScope,
669
713
  ): LoopChildReactiveText[] {
670
714
  const texts: LoopChildReactiveText[] = []
671
- const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
715
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex, parentScope)
672
716
  walkIR(node, false, {
673
717
  // Skip loop/async/if-statement subtrees — the original walker omitted
674
718
  // them; they have their own scopes (inner-loop reconciliation, async
@@ -696,7 +740,7 @@ export function collectLoopChildReactiveTexts(
696
740
  // `classifyReactivity` can't see through) still gets an update
697
741
  // effect instead of silently freezing at its SSR value (#2282).
698
742
  const reactive =
699
- classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
743
+ classifyReactivity(expanded.expr, ctx, scope, expanded.freeIds).kind !== 'none'
700
744
  || decideWrapFromAstFlags(n).wrap
701
745
  if (!reactive) return
702
746
  texts.push({
@@ -746,9 +790,11 @@ export function collectLoopChildReactiveAttrs(
746
790
  stopAtReactiveConditionals = false,
747
791
  preambleNames?: ReadonlySet<string>,
748
792
  loopIndex?: string | null,
793
+ /** Enclosing loop's own scope, for a NESTED loop (#2861) — see `buildLoopRowScope`. */
794
+ parentScope?: BindingScope,
749
795
  ): LoopChildReactiveAttr[] {
750
796
  const attrs: LoopChildReactiveAttr[] = []
751
- const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
797
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex, parentScope)
752
798
  traverseElements(node, (el) => {
753
799
  if (el.slotId) {
754
800
  for (const attr of el.attrs) {
@@ -799,7 +845,7 @@ export function collectLoopChildReactiveAttrs(
799
845
  preambleNames.size > 0 &&
800
846
  anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
801
847
  const reactive =
802
- classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
848
+ classifyReactivity(expanded.expr, ctx, scope, expanded.freeIds).kind !== 'none'
803
849
  || readsPreamble
804
850
  || attr.callsReactiveGetters
805
851
  || attr.hasFunctionCalls
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Escape-by-default for client-JS HTML template strings (#2795).
3
+ *
4
+ * Every builder in `html-template.ts` assembles a JS template literal whose
5
+ * `${...}` holes are evaluated in the browser and parsed as HTML
6
+ * (`innerHTML`). Whether a hole's value is escaped used to be an OPT-IN each
7
+ * builder remembered separately — `escapeInClientTemplate` on the IR node, a
8
+ * pre-baked `escapeText(...)` string in `templateExpr`, an
9
+ * `escapeLeafTextExpressions` rewrite pass, and a hand-rolled `escapeText`
10
+ * in `buildPreambleRegionPlans` — and a forgotten opt-in shipped raw text
11
+ * twice (#1694, #2765/#2792). This module inverts that:
12
+ *
13
+ * - `SafeHtml` is a compile-time nominal brand (the `TsxSourceText`
14
+ * precedent, `types.ts`) on a JS EXPRESSION STRING meaning "when this
15
+ * evaluates in the client template, the result is HTML that is safe to
16
+ * concatenate in". Nothing outside this module can construct one, so a
17
+ * `SafeHtml` value is the compiler vouching for itself — unlike the
18
+ * runtime `bfMarkup()` brand (`@barefootjs/client/runtime`), which
19
+ * vouches for a value the compiler never saw.
20
+ * - `interp` is the ONLY way to turn a value into a `${...}` hole, and it
21
+ * accepts `SafeHtml` only. A builder holding a plain `string` cannot
22
+ * splice it: it must either go through a named producer below (each of
23
+ * which documents WHY its output is safe) or through `spliceChildValue`,
24
+ * the door for a child-position expression, whose default is
25
+ * `escapeText(...)`.
26
+ *
27
+ * The runtime dispatch that already existed for values the compiler cannot
28
+ * classify statically is unchanged: a branch-slot value still goes through
29
+ * `__bfSlot` (may be a live Node), a claim-plan `'markup'` slot still goes
30
+ * through `escapeTextOrMarkup` (may be a `bfMarkup()`-branded prop). Those
31
+ * are producers here too — they escape internally, so their output is safe
32
+ * by construction.
33
+ *
34
+ * Attribute-value holes (`escapeAttr`, `spreadAttrs`) are a separate,
35
+ * always-on path in `html-template.ts` and are not routed through this
36
+ * module; `client-template-escape-soundness.test.ts` pins their count.
37
+ */
38
+
39
+ import type { IRExpression } from '../types.ts'
40
+
41
+ /**
42
+ * A JS expression string that evaluates, in the client template, to HTML
43
+ * safe to splice raw. Branded at construction by the producers below; only
44
+ * `interp` consumes it.
45
+ */
46
+ export type SafeHtml = string & { readonly __safeHtmlBrand: unique symbol }
47
+
48
+ /** The single place the brand is applied. Module-private on purpose. */
49
+ function safeHtml(expr: string): SafeHtml {
50
+ return expr as SafeHtml
51
+ }
52
+
53
+ /**
54
+ * Emit a `${...}` hole. The parameter type is the whole mechanism: a builder
55
+ * that has only a `string` in hand cannot call this, so "forgot to escape"
56
+ * is a type error at the splice, not a runtime surprise in the browser.
57
+ */
58
+ export function interp(span: SafeHtml): string {
59
+ return `\${${span}}`
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Producers — escaping forms. Each wraps a runtime helper from
64
+ // `@barefootjs/client/runtime` (see `imports.ts`'s allowlist) that escapes
65
+ // its own input.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** `escapeText(expr)` — the default for any child-position value. */
69
+ export function escapedText(expr: string): SafeHtml {
70
+ return safeHtml(`escapeText(${expr})`)
71
+ }
72
+
73
+ /**
74
+ * `escapeTextOrMarkup(expr)` — `escapeText`'s strict superset that unwraps a
75
+ * `bfMarkup()`-branded value raw (#2651). Used only for a slot whose
76
+ * REACTIVE claim writer is `kind: 'markup'` (`markup-slots.ts`), so the
77
+ * initial render and the update agree on whether the slot may carry a
78
+ * compiler-built JSX prop value.
79
+ */
80
+ export function escapedTextOrMarkup(expr: string): SafeHtml {
81
+ return safeHtml(`escapeTextOrMarkup(${expr})`)
82
+ }
83
+
84
+ /**
85
+ * `__bfSlot(expr, slots)` — a conditional-branch `template()` value. The
86
+ * runtime returns raw `<!--bf-slot:N-->` markers for live Nodes (spliced
87
+ * back by `insert()`) and `escapeText`s every string itself
88
+ * (`branch-slot.ts`). Wrapping this in another escape corrupts the markers
89
+ * and drops slotted content (the #1694 regression) — hence a producer, not
90
+ * an input to `escapedText`.
91
+ */
92
+ export function branchSlotValue(expr: string, slotsVar: string): SafeHtml {
93
+ return safeHtml(`__bfSlot(${expr}, ${slotsVar})`)
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Producers — already-HTML forms. The value is markup the compiler or
98
+ // runtime built (and escaped piecewise) BEFORE it reaches the hole.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * `markupOrEmpty(expr)` — a bare `{children}` passthrough (#2775). The value
103
+ * is the HTML string `materializeComponent` joined from the caller's
104
+ * children, or `undefined` when none were passed; the helper's entire job is
105
+ * the nullish case. Never escaped: escaping would render real child markup
106
+ * as visible `&lt;span&gt;` text.
107
+ */
108
+ export function childrenMarkup(expr: string): SafeHtml {
109
+ return safeHtml(`markupOrEmpty(${expr})`)
110
+ }
111
+
112
+ /**
113
+ * Stage 3 / D4 — an element-array child (`{out}`) built by an arbitrary
114
+ * `.map()` preamble is an array of compiled-leaf HTML strings; join it
115
+ * rather than let `${[...]}` `String`-comma-collapse it. Each leaf's own
116
+ * holes already went through `interp`, so the concatenation is exactly as
117
+ * safe as any other compiler-emitted fragment. Also used by
118
+ * `buildPreambleRegionPlans` for the region-patch effect's value so the
119
+ * re-render matches the row template byte-for-byte.
120
+ */
121
+ export function joinedMarkup(expr: string): SafeHtml {
122
+ return safeHtml(`Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`)
123
+ }
124
+
125
+ /**
126
+ * `renderChild('Name', props, ...scopeArgs)` — a child component rendered
127
+ * from its registered template at runtime. Its output is that component's
128
+ * own compiled template, whose holes went through this module in turn.
129
+ */
130
+ export function renderChildCall(registryName: string, propsExpr: string, tailArgs: string): SafeHtml {
131
+ return safeHtml(`renderChild('${registryName}', ${propsExpr}${tailArgs})`)
132
+ }
133
+
134
+ /**
135
+ * `dangerouslySetInnerHTML={{ __html: E }}` — the intentional, React-style,
136
+ * author-facing escape hatch, and the ONLY one: the element's content is
137
+ * whatever `E.__html` holds, unescaped by design (mirroring every SSR
138
+ * adapter's native handling). The name carries the warning so an audit of
139
+ * raw-markup producers (`grep dangerousInnerHtml`) finds it.
140
+ */
141
+ export function dangerousInnerHtml(expr: string): SafeHtml {
142
+ return safeHtml(`((${expr}) ?? {}).__html ?? ''`)
143
+ }
144
+
145
+ /**
146
+ * `cond ? \`whenTrue\` : \`whenFalse\`` — both branches are compiled
147
+ * template-literal BODIES (already-assembled HTML with `interp`ed holes),
148
+ * not values; the condition only selects between them.
149
+ */
150
+ export function conditionalMarkup(condition: string, whenTrue: string, whenFalse: string): SafeHtml {
151
+ return safeHtml(`${condition} ? \`${whenTrue}\` : \`${whenFalse}\``)
152
+ }
153
+
154
+ /**
155
+ * `array.method(params => body).join('')` — a loop rendered inline. `body`
156
+ * is either a compiled row template literal (\`...\`), a preamble block
157
+ * returning one, or a flatMap descriptor body rendered through
158
+ * `renderPreamble`; every hole inside it went through `interp`.
159
+ */
160
+ export function mappedRowsMarkup(arrayExpr: string, method: string, params: string, body: string): SafeHtml {
161
+ return safeHtml(`${arrayExpr}.${method}(${params} => ${body}).join('')`)
162
+ }
163
+
164
+ /** The empty string literal — a deferred placeholder the init effect fills (#1128). */
165
+ export const EMPTY_MARKUP: SafeHtml = safeHtml("''")
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // The door for a child-position expression node.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Recognizes a JSX child-position expression that is exactly a reference to
173
+ * the reserved `children` prop — bare `children` (destructured) or
174
+ * `<receiver>.children` for any single-identifier receiver (`props.children`,
175
+ * a custom props-param name, a loop-scoped alias closing over props, ...).
176
+ *
177
+ * Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
178
+ * runs on the TS AST with the analyzer-resolved props name in hand. This
179
+ * layer works on IR text with no analyzer, so any single-identifier receiver
180
+ * is the available approximation. The looseness costs nothing measurable: an
181
+ * unrelated `.children` member (a tree node's own `children` array, say) is a
182
+ * reactive member expression that gets a `slotId` and takes the slotted arm
183
+ * of `spliceChildValue` before this predicate is consulted.
184
+ */
185
+ function isChildrenPassthroughExpr(expr: string): boolean {
186
+ return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
187
+ }
188
+
189
+ export interface ChildSpliceContext {
190
+ /**
191
+ * Set while emitting inside a conditional-branch `template()` arrow
192
+ * (`irToHtmlTemplate`'s `branchSlotsVar`): every value in that context is
193
+ * routed through `__bfSlot`, which owns its own coercion.
194
+ */
195
+ readonly branchSlotsVar?: string
196
+ /**
197
+ * Slot ids whose reactive claim writer is `kind: 'markup'`
198
+ * (`markupSlotIdsOf(ctx)`, `markup-slots.ts`). Absent for the loop-row /
199
+ * branch-row builders, whose reactive twin is a `kind: 'text'` writer
200
+ * (`String(...)` into `nodeValue`) — plain `escapeText` is the consistent
201
+ * choice there, and it is also the choice that never unwraps a
202
+ * `bfMarkup()`-shaped object arriving in JSON-sourced loop data.
203
+ */
204
+ readonly markupSlotIds?: ReadonlySet<string>
205
+ }
206
+
207
+ /**
208
+ * THE door for a child-position `IRExpression` value entering a client HTML
209
+ * template. Takes the caller's already-wrapped/transformed expression text
210
+ * (`wrapExpr` / `transformExpr` output) and decides, in exactly one place,
211
+ * which producer it goes through. The last arm is the default, and it
212
+ * escapes: there is no arm that returns `valueExpr` unwrapped.
213
+ *
214
+ * Arms, first match wins:
215
+ * 1. `joinArrayChild` → `joinedMarkup` (array of compiled leaves)
216
+ * 2. `branchSlotsVar` → `branchSlotValue` (runtime coercion, may be a Node)
217
+ * 3. `slotId` ∈ markupSlotIds → `escapedTextOrMarkup`
218
+ * 4. `slotId` → `escapedText`
219
+ * 5. `{children}` passthrough → `childrenMarkup` (bare splice only, #2775/#2786)
220
+ * 6. otherwise → `escapedText`
221
+ *
222
+ * Arm 5 tests BOTH `node.expr` (the original source text, stable across
223
+ * builders) and the caller's resolved form (parens stripped): a
224
+ * destructured-and-renamed children (`const { children: kids } = props`)
225
+ * reads `kids` in the source and `(_p.children)` after substitution, and
226
+ * either alone misses one of the two shapes (#2786).
227
+ */
228
+ export function spliceChildValue(
229
+ node: Pick<IRExpression, 'expr' | 'slotId' | 'joinArrayChild'>,
230
+ valueExpr: string,
231
+ cx: ChildSpliceContext,
232
+ ): SafeHtml {
233
+ if (node.joinArrayChild) return joinedMarkup(valueExpr)
234
+ if (cx.branchSlotsVar) return branchSlotValue(valueExpr, cx.branchSlotsVar)
235
+ if (node.slotId) {
236
+ return cx.markupSlotIds?.has(node.slotId) ? escapedTextOrMarkup(valueExpr) : escapedText(valueExpr)
237
+ }
238
+ const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
239
+ if (isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)) {
240
+ return childrenMarkup(valueExpr)
241
+ }
242
+ return escapedText(valueExpr)
243
+ }
@@ -400,7 +400,19 @@ export interface LoopChildRef {
400
400
  export interface BranchLoop extends LoopCore {
401
401
  kind: 'branch'
402
402
  index: string | null // Index parameter (e.g., 'i')
403
- template: string // HTML template for each item
403
+ template: string // HTML template for each item, item-only wrapped (no index — see `templateIndexed`)
404
+ /**
405
+ * Same render as `template`, but with `index` also wrapped as an accessor
406
+ * (`i()`) — rendered structurally at IR time (`irToHtmlTemplate`'s
407
+ * `loopParams`), never via a post-hoc regex pass over `template` (#2868:
408
+ * a word-boundary regex over assembled HTML can match a bare tag name
409
+ * colliding with the index identifier, e.g. `<i>` -> `<i()>`). Present
410
+ * iff the loop declares an index; `build-plain-row.ts` picks this over
411
+ * `template` once lazy eligibility rules out the lazy row plan (which
412
+ * needs the index UNWRAPPED — `mapArrayLazy`'s `createRow` hands it a
413
+ * plain number, never an accessor).
414
+ */
415
+ templateIndexed?: string
404
416
  containerSlotId: string // bf slot ID of the container element (e.g., 's1' for <ul bf="s1">)
405
417
  preamble?: MapCallbackPreamble
406
418
  // Composite loop fields (loops whose body contains child components)
@@ -489,8 +501,6 @@ export interface NestedLoop extends LoopCore {
489
501
  * (and any subsequent reads) are in scope (#1052).
490
502
  */
491
503
  preamble?: MapCallbackPreamble
492
- /** Whether the inner array references the outer loop param (needs reactive mapArray) */
493
- refsOuterParam?: boolean
494
504
  /** Child components inside inner loop items (for initChild/createComponent) */
495
505
  childComponents?: import('../types.ts').IRLoopChildComponent[]
496
506
  /** True when this loop is inside a conditional branch (handled by insert() bindEvents instead) */
@@ -622,6 +632,12 @@ export interface TopLevelLoop extends LoopCore {
622
632
  slotId: string
623
633
  index: string | null
624
634
  template: string
635
+ /**
636
+ * Same render as `template`, but with `index` also wrapped as an accessor
637
+ * (`i()`) — see `BranchLoop.templateIndexed` for why this exists as a
638
+ * second structured render rather than a post-hoc regex pass (#2868).
639
+ */
640
+ templateIndexed?: string
625
641
  /**
626
642
  * Per-iteration HTML template for static-array loops that need to
627
643
  * self-heal on CSR mount (#1247). Unlike `template`, this variant skips
@@ -581,17 +581,63 @@ function renderLoopBindingAccess(b: LoopParamBinding, base: string): string {
581
581
  * binding name is rewritten to `__bfItem()${path}` instead of wrapping the
582
582
  * raw pattern text. `paramName` is ignored in that case — destructured
583
583
  * callbacks never expose the pattern itself as a local.
584
- */
585
- export function wrapLoopParamAsAccessor(expr: string, paramName: string, bindings?: readonly LoopParamBinding[]): string {
584
+ *
585
+ * `indexParam` (#2859): the same `.map()` callback's index parameter
586
+ * (`(item, i) => ...`'s `i`), when the loop declares one. `mapArray`/
587
+ * `mapArrayAnchored` hand `renderItem` an INDEX ACCESSOR (mirroring the item
588
+ * accessor) precisely so a same-key reorder — which never re-invokes
589
+ * `renderItem`, only pushes fresh values through the item/index signals —
590
+ * can keep a row's index-derived output live. A bare reference to
591
+ * `indexParam` is therefore rewritten the same way as the item param; a
592
+ * caller that never threads an index through (e.g. no second callback
593
+ * param) simply omits it and behavior is unchanged. Rewritten as a SEPARATE
594
+ * pass after the item/bindings rewrite so an index name that happens to
595
+ * collide with a destructured binding name is a no-op here (the binding
596
+ * rewrite already consumed it; `!== paramName` below only guards the plain,
597
+ * non-destructured case, since `bindings` and `paramName` are mutually
598
+ * exclusive inputs to the item rewrite above).
599
+ */
600
+ export function wrapLoopParamAsAccessor(
601
+ expr: string,
602
+ paramName: string,
603
+ bindings?: readonly LoopParamBinding[],
604
+ indexParam?: string | null,
605
+ ): string {
606
+ let result: string
586
607
  if (bindings && bindings.length > 0) {
587
- return rewriteLoopBindingRefs(expr, bindings, '__bfItem()')
608
+ result = rewriteLoopBindingRefs(expr, bindings, '__bfItem()')
609
+ } else {
610
+ // `paramName` is a legal JS identifier and may itself start with `$`
611
+ // (`$1`, `$&`, …) — a plain string replacement would risk `String.replace`
612
+ // reading those as backreference/whole-match sequences, so use a replacer
613
+ // function to insert the accessor text literally (#2592).
614
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(paramName)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
615
+ result = replaceInExprContexts(expr, re, () => `${paramName}()`)
616
+ }
617
+ if (indexParam && indexParam !== paramName) {
618
+ result = wrapIndexParamAsAccessor(result, indexParam)
588
619
  }
589
- // `paramName` is a legal JS identifier and may itself start with `$`
590
- // (`$1`, `$&`, …) — a plain string replacement would risk `String.replace`
591
- // reading those as backreference/whole-match sequences, so use a replacer
592
- // function to insert the accessor text literally (#2592).
593
- const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(paramName)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
594
- return replaceInExprContexts(expr, re, () => `${paramName}()`)
620
+ return result
621
+ }
622
+
623
+ /**
624
+ * The index-only half of `wrapLoopParamAsAccessor` — same regex shape, own
625
+ * identifier. Factored out so `wrapLoopParamAsAccessor` can run it as a
626
+ * second pass regardless of which item-rewrite branch (bindings vs. plain
627
+ * param) ran first (#2859).
628
+ *
629
+ * Also exported standalone for `build-loop.ts`/`build-branch-loop.ts`: a
630
+ * plain loop's `template`/`mapPreambleWrapped` are built ONCE, before lazy
631
+ * eligibility is known, because the lazy row plan reuses them verbatim when
632
+ * this loop turns out to be lazy-eligible (`mapArrayLazy`'s `createRow`
633
+ * still hands the row a plain index NUMBER, never an accessor). So those two
634
+ * builders wrap item references up front (safe either way) and apply this
635
+ * index-only pass afterward, ONLY once `buildLazyRowPlan` confirms the loop
636
+ * is NOT going lazy.
637
+ */
638
+ export function wrapIndexParamAsAccessor(expr: string, indexParam: string): string {
639
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(indexParam)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
640
+ return replaceInExprContexts(expr, re, () => `${indexParam}()`)
595
641
  }
596
642
 
597
643
  /**
@@ -718,6 +764,8 @@ export function substituteLoopBindings(
718
764
  export interface LoopParamSpec {
719
765
  param: string
720
766
  bindings?: readonly LoopParamBinding[]
767
+ /** This loop's index parameter name, when it declares one (#2859). */
768
+ index?: string | null
721
769
  }
722
770
 
723
771
  /**
@@ -747,7 +795,7 @@ export function wrapExprWithLoopParams(expr: string, loopParams?: ReadonlyArray<
747
795
  let result = expr
748
796
  for (const p of loopParams) {
749
797
  const spec = typeof p === 'string' ? { param: p } : p
750
- result = wrapLoopParamAsAccessor(result, spec.param, spec.bindings)
798
+ result = wrapLoopParamAsAccessor(result, spec.param, spec.bindings, spec.index)
751
799
  }
752
800
  return result
753
801
  }