@barefootjs/jsx 0.18.5 → 0.18.7

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 (88) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +77 -0
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts +22 -0
  4. package/dist/adapters/loop-bound-names.d.ts.map +1 -0
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/index.d.ts +14 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +977 -273
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  27. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  30. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  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/plan/static-array-child-init.d.ts +20 -1
  36. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  39. package/dist/ir-to-client-js/types.d.ts +21 -0
  40. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  41. package/dist/jsx-to-ir.d.ts.map +1 -1
  42. package/dist/signal-init-eval.d.ts +82 -0
  43. package/dist/signal-init-eval.d.ts.map +1 -0
  44. package/dist/static-literal.d.ts +45 -0
  45. package/dist/static-literal.d.ts.map +1 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  48. package/src/__tests__/create-selector.test.ts +110 -0
  49. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  50. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  51. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  52. package/src/__tests__/loop-bound-names.test.ts +102 -0
  53. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  54. package/src/__tests__/map-function-reference.test.ts +303 -0
  55. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  56. package/src/__tests__/signal-init-eval.test.ts +138 -0
  57. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  58. package/src/__tests__/static-literal.test.ts +126 -0
  59. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  60. package/src/adapters/dangerous-inner-html.ts +186 -0
  61. package/src/adapters/loop-bound-names.ts +80 -0
  62. package/src/adapters/parsed-expr-emitter.ts +9 -0
  63. package/src/analyzer.ts +6 -0
  64. package/src/index.ts +19 -3
  65. package/src/ir-to-client-js/collect-elements.ts +15 -3
  66. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  67. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  70. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  71. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  72. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  73. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  74. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  75. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  76. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  78. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  79. package/src/ir-to-client-js/html-template.ts +235 -2
  80. package/src/ir-to-client-js/imports.ts +1 -0
  81. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  82. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  83. package/src/ir-to-client-js/reactivity.ts +6 -0
  84. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  85. package/src/ir-to-client-js/types.ts +21 -0
  86. package/src/jsx-to-ir.ts +230 -39
  87. package/src/signal-init-eval.ts +165 -0
  88. package/src/static-literal.ts +128 -0
@@ -20,21 +20,118 @@ import { emitListenerBlock } from './stringify/event-listener.ts'
20
20
  import { nameForRegistryRef } from '../component-scope.ts'
21
21
  import { BF_SCOPE, BF_HOST, BF_AT } from '@barefootjs/shared'
22
22
  import type { LoopChildRefBinding } from './plan/loop.ts'
23
+ import {
24
+ extractFreeIdentifiersFromText,
25
+ extractFreeIdentifiersFromStatementText,
26
+ extractFreeIdentifiersFromTemplateText,
27
+ } from '../csr-substitute.ts'
23
28
 
24
29
  /**
25
30
  * Build the `keyFn` argument for mapArray / reconcileElements. `null` when
26
- * the loop has no key expression. Narrowing on `loop.kind` keeps `index`
27
- * off `NestedLoop` (nested loops never thread an explicit index parameter)
28
- * and lets the compiler verify we handled every flavour exhaustively.
31
+ * the loop has no key expression. Every `CollectedLoop` variant (top-level /
32
+ * branch / nested) carries an `index: string | null` field (#2218 threaded
33
+ * it onto `NestedLoop` too), so the index param when present is always
34
+ * appended to the keyFn's own parameter list. mapArray invokes `keyFn(item,
35
+ * i)` directly with the numeric index, independent of the synthetic
36
+ * `__innerIdx<uid>` binding used inside a nested loop's renderItem body
37
+ * (see `nestedLoopReferencesIndex` below), so no aliasing is needed here.
29
38
  */
30
39
  export function loopKeyFn(loop: CollectedLoop): string {
31
40
  if (loop.key === null) return 'null'
32
- const params = loop.kind === 'nested'
33
- ? loop.param
34
- : `${loop.param}${loop.index ? `, ${loop.index}` : ''}`
41
+ const params = `${loop.param}${loop.index ? `, ${loop.index}` : ''}`
35
42
  return `(${params}) => String(${loop.key})`
36
43
  }
37
44
 
45
+ /**
46
+ * Does a nested inner loop's body reference its own declared index
47
+ * parameter (e.g. `i` from `.map((item, i) => ...)`)? Scans every surface
48
+ * the renderItem/forEach body can read the index from — the key
49
+ * expression, reactive text/attr expressions, the map-preamble statements,
50
+ * the per-item HTML template's `${...}` interpolations, per-item events,
51
+ * ref callbacks, and child-component props — so the byte-stable gate only
52
+ * fires when the index is truly used (mirrors #2189's handler-only gate
53
+ * for delegated events, `indexBindingLine` in `stringify/event-delegation.ts`).
54
+ *
55
+ * `comps` / `events` are passed separately (rather than read off `inner`)
56
+ * because callers hold them in different shapes at different points in the
57
+ * build pipeline — `DepthLevel.comps`/`.events` for the plain inner-loop
58
+ * path, `inner.childComponents`/`inner.bindings.events` for the loop-child-
59
+ * arm (conditional-branch) path.
60
+ *
61
+ * AST-based throughout (never regex) per repo convention — a token-level
62
+ * scan would false-match a property access like `item.i` as a reference to
63
+ * an index param named `i`.
64
+ */
65
+ export function nestedLoopReferencesIndex(
66
+ inner: NestedLoop,
67
+ comps: readonly IRLoopChildComponent[],
68
+ events: readonly LoopChildEvent[],
69
+ ): boolean {
70
+ const index = inner.index
71
+ if (!index) return false
72
+
73
+ const exprRefs = (text: string | null | undefined, precomputed?: ReadonlySet<string>): boolean => {
74
+ if (precomputed) return precomputed.has(index)
75
+ if (!text) return false
76
+ return extractFreeIdentifiersFromText(text).has(index)
77
+ }
78
+
79
+ if (exprRefs(inner.key)) return true
80
+ for (const t of inner.bindings.reactiveTexts) {
81
+ if (exprRefs(t.expression, t.freeIdentifiers)) return true
82
+ }
83
+ for (const a of inner.bindings.reactiveAttrs) {
84
+ if (exprRefs(a.expression, a.freeIdentifiers)) return true
85
+ }
86
+ if (inner.mapPreamble && extractFreeIdentifiersFromStatementText(inner.mapPreamble).has(index)) return true
87
+ if (inner.template && extractFreeIdentifiersFromTemplateText(inner.template).has(index)) return true
88
+ for (const ev of events) {
89
+ if (exprRefs(ev.handler)) return true
90
+ }
91
+ for (const r of inner.bindings.refs) {
92
+ if (exprRefs(r.callback)) return true
93
+ }
94
+ for (const c of comps) {
95
+ for (const p of c.props) {
96
+ if (exprRefs(attrValueToString(p.value))) return true
97
+ }
98
+ if (c.children?.length) {
99
+ const childrenExpr = irChildrenToJsExpr(c.children)
100
+ if (childrenExpr && exprRefs(childrenExpr)) return true
101
+ }
102
+ }
103
+ return false
104
+ }
105
+
106
+ /**
107
+ * Build the prelude alias statement that binds a nested inner loop's index
108
+ * param to the synthetic numeric-index value mapArray/forEach passes into
109
+ * renderItem (#2218), or `null` when no alias is needed. Gated by
110
+ * `nestedLoopReferencesIndex` for byte-for-byte stability — loops that
111
+ * declare an index but never read it emit no extra line. `paramHead` guards
112
+ * against the (JS-illegal, can't actually happen) case where the index name
113
+ * collides with the loop's own emitted item-parameter identifier — mirrors
114
+ * the self-alias guard in #2189's `indexBindingLine`.
115
+ *
116
+ * `syntheticIndexVar` is the already-formatted variable name the caller's
117
+ * stringifier binds the numeric index to (`__innerIdx<uid>` in
118
+ * `stringify/inner-loop.ts`, `__bidx<uid>` in `stringify/loop-child-arm.ts`)
119
+ * — passed in rather than assembled here so this helper doesn't need to
120
+ * know which emission family is calling it.
121
+ */
122
+ export function nestedLoopIndexAlias(
123
+ inner: NestedLoop,
124
+ syntheticIndexVar: string,
125
+ paramHead: string,
126
+ comps: readonly IRLoopChildComponent[],
127
+ events: readonly LoopChildEvent[],
128
+ ): string | null {
129
+ const index = inner.index
130
+ if (!index || index === paramHead) return null
131
+ if (!nestedLoopReferencesIndex(inner, comps, events)) return null
132
+ return `const ${index} = ${syntheticIndexVar}`
133
+ }
134
+
38
135
  /**
39
136
  * Wrap each ref's `callback` expression with the loop-param accessor and
40
137
  * return `LoopChildRefBinding[]` ready for the stringifier. For dynamic
@@ -33,7 +33,7 @@
33
33
  import { keyAttrName, profileBindingId } from '../../utils.ts'
34
34
  import { emitComponentAndEventSetup } from '../shared.ts'
35
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
36
- import { emitMultiRootTemplateCloneLines } from './template-parse.ts'
36
+ import { emitMultiRootTemplateCloneLines, templateRootIsSvg } from './template-parse.ts'
37
37
  import { emitLoopChildRefs } from './loop.ts'
38
38
  import type {
39
39
  InnerLoopPlan,
@@ -82,7 +82,16 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
82
82
  lines.push(`${innerIndent} __innerEl${uid}.__bfExtras = __innerExtras${uid}`)
83
83
  lines.push(`${indent} }`)
84
84
  } else {
85
- lines.push(`${indent} let __innerEl${uid} = __existing ?? (() => { const __t = document.createElement('template'); __t.innerHTML = \`${emit.wrappedTemplate}\`; return __t.content.firstElementChild.cloneNode(true) })()`)
85
+ // SVG-rooted item templates must parse inside a synthetic `<svg>` wrap
86
+ // (#2219): `template.innerHTML` parses in the HTML namespace, so a bare
87
+ // `<line>`/`<circle>` root clones as an HTMLUnknownElement and the SVG
88
+ // renderer silently draws nothing. Mirrors `templateRootIsSvg` handling
89
+ // on the top-level (#135/#1088) and branch-arm paths; HTML-rooted
90
+ // templates keep byte-identical output.
91
+ const isSvg = templateRootIsSvg(emit.wrappedTemplate)
92
+ const innerHtml = isSvg ? `<svg>${emit.wrappedTemplate}</svg>` : emit.wrappedTemplate
93
+ const childPath = isSvg ? '.firstElementChild.firstElementChild' : '.firstElementChild'
94
+ lines.push(`${indent} let __innerEl${uid} = __existing ?? (() => { const __t = document.createElement('template'); __t.innerHTML = \`${innerHtml}\`; return __t.content${childPath}.cloneNode(true) })()`)
86
95
  }
87
96
  if (emit.wrappedKey) {
88
97
  lines.push(`${indent} __innerEl${uid}.setAttribute('${keyAttrName(inner.keyDepth)}', String(${emit.wrappedKey}))`)
@@ -86,6 +86,11 @@ export function stringifyBranchInnerLoops(
86
86
  const uid = inner.uidSuffix
87
87
  lines.push(`${indent}{ const __bic${uid} = ${inner.containerExpr}`)
88
88
  lines.push(`${indent}if (__bic${uid}) mapArray(() => ${inner.arrayExpr} || [], __bic${uid}, ${inner.keyFn}, (${inner.paramHead}, __bidx${uid}, __existing) => {`)
89
+ // Index alias (#2218) lands first — before the destructure unwrap and
90
+ // the template clone, either of which may reference the index.
91
+ if (inner.indexAlias) {
92
+ lines.push(`${indent} ${inner.indexAlias}`)
93
+ }
89
94
  if (inner.paramUnwrap) {
90
95
  lines.push(`${indent} ${inner.paramUnwrap}`)
91
96
  }
@@ -27,7 +27,8 @@
27
27
  import { emitRefCall, varSlotId, profileBindingId } from '../../utils.ts'
28
28
  import { emitAttrUpdate } from '../../emit-reactive.ts'
29
29
  import { stringifyReactiveEffects } from './reactive-effects.ts'
30
- import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr } from './template-parse.ts'
30
+ import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr, templateRootIsSvg, multiRootTemplateNeedsSvgWrap } from './template-parse.ts'
31
+ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.ts'
31
32
  import { stringifyComponentLoop } from './component-loop.ts'
32
33
  import { stringifyCompositeLoop } from './composite-loop.ts'
33
34
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
@@ -47,14 +48,18 @@ import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } fro
47
48
  export function emitLoopChildRefs(
48
49
  lines: string[],
49
50
  refs: readonly LoopChildRefBinding[],
50
- opts: { indent: string; elVar: string; bodyIsMultiRoot: boolean },
51
+ opts: { indent: string; elVar: string; bodyIsMultiRoot: boolean; elementIndexBySlot?: ReadonlyMap<string, number> },
51
52
  ): void {
52
53
  if (refs.length === 0) return
53
- const { indent, elVar, bodyIsMultiRoot } = opts
54
+ const { indent, elVar, bodyIsMultiRoot, elementIndexBySlot } = opts
54
55
  const lookup = bodyIsMultiRoot ? 'qsaItem' : 'qsa'
55
56
  for (const ref of refs) {
56
57
  const varName = `__rf_${varSlotId(ref.childSlotId)}`
57
- lines.push(`${indent}{ const ${varName} = ${lookup}(${elVar}, '[bf="${ref.childSlotId}"]')`)
58
+ const pIdx = elementIndexBySlot?.get(ref.childSlotId)
59
+ const lookupExpr = pIdx !== undefined
60
+ ? `__p ? __p[${pIdx}] : ${lookup}(${elVar}, '[bf="${ref.childSlotId}"]')`
61
+ : `${lookup}(${elVar}, '[bf="${ref.childSlotId}"]')`
62
+ lines.push(`${indent}{ const ${varName} = ${lookupExpr}`)
58
63
  lines.push(`${indent}if (${varName}) ${emitRefCall(ref.callback, varName)} }`)
59
64
  }
60
65
  }
@@ -181,10 +186,37 @@ export function stringifyPlainLoop(
181
186
  singleRootLayout: 'inline',
182
187
  })
183
188
  }
189
+ // Direct child-index paths (perf, #2143): only reachable when hoistedTpl
190
+ // is set, which itself requires a single-root, conditional-free body — so
191
+ // `reactiveEffects.conditionals` is always empty here and needs no path
192
+ // handling. `__p` is `null` on the hydration branch (`__existing` truthy)
193
+ // since the skeleton's empty markers/omitted attrs don't describe the
194
+ // SSR-rendered tree; every resolution falls back to qsa/$t there.
195
+ let pathPlan: SkeletonPathPlan | null = null
196
+ if (hoistedTpl && plan.skeletonPaths) {
197
+ const elementSlotIds = [
198
+ ...(reactiveEffects?.attrSlots.map(s => s.slotId) ?? []),
199
+ ...childRefs.map(r => r.childSlotId),
200
+ ]
201
+ const textSlotIds = reactiveEffects?.outerTexts.map(t => t.slotId) ?? []
202
+ if (elementSlotIds.length > 0 || textSlotIds.length > 0) {
203
+ const built = buildSkeletonPathPlan(plan.skeletonPaths, '__el', { elementSlotIds, textSlotIds })
204
+ if (built.arrayElems.length > 0) {
205
+ pathPlan = built
206
+ lines.push(`${bodyIndent}const __p = __existing ? null : [${built.arrayElems.join(', ')}]`)
207
+ }
208
+ }
209
+ }
184
210
  if (reactiveEffects !== null) {
185
- stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot })
211
+ stringifyReactiveEffects(lines, reactiveEffects, {
212
+ indent: bodyIndent,
213
+ elVar: '__el',
214
+ bodyIsMultiRoot,
215
+ elementIndexBySlot: pathPlan?.elementIndexBySlot,
216
+ textIndexBySlot: pathPlan?.textIndexBySlot,
217
+ })
186
218
  }
187
- emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot })
219
+ emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot, elementIndexBySlot: pathPlan?.elementIndexBySlot })
188
220
  lines.push(`${bodyIndent}return __el`)
189
221
  lines.push(`${topIndent}}, '${markerId}'${loopBfId})`)
190
222
  }
@@ -283,15 +315,26 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
283
315
  lines.push(` let __iterEl = ${containerVar}.children[${childIndexExpr}]`)
284
316
  if (csrMaterialize) {
285
317
  lines.push(` if (!__iterEl) {`)
318
+ // SVG-rooted item templates parse inside a synthetic `<svg>` wrap and
319
+ // descend one extra level (#2219) — `template.innerHTML` alone parses in
320
+ // the HTML namespace and the materialized elements would never draw.
321
+ // Mirrors `templateRootIsSvg` handling on the reactive clone paths;
322
+ // HTML-rooted templates keep byte-identical output. Multi-root fragments
323
+ // use the fragment-aware predicate so an `<svg>`-container-first fragment
324
+ // isn't over-wrapped (#2233 Copilot review).
325
+ const isSvg = csrMaterialize.bodyIsMultiRoot
326
+ ? multiRootTemplateNeedsSvgWrap(csrMaterialize.itemTemplate)
327
+ : templateRootIsSvg(csrMaterialize.itemTemplate)
328
+ const itemHtml = isSvg ? `<svg>${csrMaterialize.itemTemplate}</svg>` : csrMaterialize.itemTemplate
286
329
  if (csrMaterialize.bodyIsMultiRoot) {
287
330
  // Multi-root: clone every top-level sibling of the per-item template and
288
331
  // insert them in order. `__iterEl` is the first root (the one reactive
289
332
  // bindings attach to); the rest land alongside it via insertBefore.
290
333
  lines.push(` const __mtpl = document.createElement('template')`)
291
- lines.push(` __mtpl.innerHTML = \`${csrMaterialize.itemTemplate}\``)
334
+ lines.push(` __mtpl.innerHTML = \`${itemHtml}\``)
292
335
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
293
336
  lines.push(` let __first = null`)
294
- lines.push(` let __sib = __mtpl.content.firstElementChild`)
337
+ lines.push(` let __sib = __mtpl.content${isSvg ? '.firstElementChild' : ''}.firstElementChild`)
295
338
  lines.push(` while (__sib) {`)
296
339
  lines.push(` const __next = __sib.nextElementSibling`)
297
340
  lines.push(` const __cloned = __sib.cloneNode(true)`)
@@ -302,8 +345,8 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
302
345
  lines.push(` __iterEl = __first`)
303
346
  } else {
304
347
  lines.push(` const __tpl = document.createElement('template')`)
305
- lines.push(` __tpl.innerHTML = \`${csrMaterialize.itemTemplate}\``)
306
- lines.push(` const __cloned = __tpl.content.firstElementChild`)
348
+ lines.push(` __tpl.innerHTML = \`${itemHtml}\``)
349
+ lines.push(` const __cloned = __tpl.content${isSvg ? '.firstElementChild' : ''}.firstElementChild`)
307
350
  lines.push(` if (__cloned) {`)
308
351
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
309
352
  lines.push(` ${containerVar}.insertBefore(__cloned, __anchor)`)
@@ -40,6 +40,16 @@ export interface StringifyReactiveEffectsOptions {
40
40
  * `<!--bf-loop-i-->`-bounded siblings). Optional — defaults to `false`.
41
41
  */
42
42
  bodyIsMultiRoot?: boolean
43
+ /**
44
+ * Compile-time `__p` index for each attr slotId (perf, #2143) — see
45
+ * `buildSkeletonPathPlan`. When a slot has an entry, the emitted lookup
46
+ * becomes `__p ? __p[i] : qsa(...)` instead of the bare `qsa(...)` call;
47
+ * `__p` is `null` on the hydration branch (`__existing`), so hydration
48
+ * still resolves through the battle-tested runtime lookup.
49
+ */
50
+ elementIndexBySlot?: ReadonlyMap<string, number>
51
+ /** Same as `elementIndexBySlot`, for text-marker slots (`$t`). */
52
+ textIndexBySlot?: ReadonlyMap<string, number>
43
53
  }
44
54
 
45
55
  export function stringifyReactiveEffects(
@@ -47,7 +57,7 @@ export function stringifyReactiveEffects(
47
57
  plan: ReactiveEffectsPlan,
48
58
  opts: StringifyReactiveEffectsOptions,
49
59
  ): void {
50
- const { indent, elVar, bodyIsMultiRoot } = opts
60
+ const { indent, elVar, bodyIsMultiRoot, elementIndexBySlot, textIndexBySlot } = opts
51
61
  const lookup = bodyIsMultiRoot ? 'qsaItem' : 'qsa'
52
62
  const pc = plan.profileComponentName
53
63
  const bindingBfId = (slotId: string): string => profileBindingId(pc, slotId)
@@ -55,7 +65,11 @@ export function stringifyReactiveEffects(
55
65
  // 1. Reactive attribute effects (one qsa per slot, then per-attr createEffect).
56
66
  for (const slot of plan.attrSlots) {
57
67
  const varName = `__ra_${varSlotId(slot.slotId)}`
58
- lines.push(`${indent}{ const ${varName} = ${lookup}(${elVar}, '[bf="${slot.slotId}"]')`)
68
+ const pIdx = elementIndexBySlot?.get(slot.slotId)
69
+ const lookupExpr = pIdx !== undefined
70
+ ? `__p ? __p[${pIdx}] : ${lookup}(${elVar}, '[bf="${slot.slotId}"]')`
71
+ : `${lookup}(${elVar}, '[bf="${slot.slotId}"]')`
72
+ lines.push(`${indent}{ const ${varName} = ${lookupExpr}`)
59
73
  lines.push(`${indent}if (${varName}) {`)
60
74
  for (const attr of slot.attrs) {
61
75
  lines.push(`${indent} createEffect(() => {`)
@@ -69,7 +83,7 @@ export function stringifyReactiveEffects(
69
83
 
70
84
  // 2. Outer text effects (slots NOT inside any conditional branch).
71
85
  for (const text of plan.outerTexts) {
72
- emitOuterText(lines, indent, elVar, text, bindingBfId(text.slotId))
86
+ emitOuterText(lines, indent, elVar, text, bindingBfId(text.slotId), textIndexBySlot?.get(text.slotId))
73
87
  }
74
88
 
75
89
  // 3. Reactive conditionals — each emits an insert(...) over `elVar` whose
@@ -85,9 +99,16 @@ function emitOuterText(
85
99
  elVar: string,
86
100
  text: ReactiveTextEffect,
87
101
  bfId: string = '',
102
+ pIdx?: number,
88
103
  ): void {
89
104
  const varName = `__rt_${varSlotId(text.slotId)}`
90
- lines.push(`${indent}{ const [${varName}] = $t(${elVar}, '${text.slotId}')`)
105
+ if (pIdx !== undefined) {
106
+ // Direct-path resolution (#2143): `__p` holds the marker Comment node;
107
+ // `tAfter` mirrors `$t`'s create-if-absent Text-node semantics exactly.
108
+ lines.push(`${indent}{ const ${varName} = __p ? tAfter(__p[${pIdx}]) : $t(${elVar}, '${text.slotId}')[0]`)
109
+ } else {
110
+ lines.push(`${indent}{ const [${varName}] = $t(${elVar}, '${text.slotId}')`)
111
+ }
91
112
  lines.push(`${indent}if (${varName}) createEffect(() => { ${varName}.textContent = String(${text.wrappedExpression}) }${bfId}) }`)
92
113
  }
93
114
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Turn compile-time `SkeletonSlotPaths` (html-template.ts) into the `__p`
3
+ * array-literal contents plus slot -> index maps consumed by
4
+ * `stringifyPlainLoop` / `stringifyReactiveEffects` / `emitLoopChildRefs`
5
+ * (perf: direct child-index paths for hoisted single-root loops, #2143).
6
+ *
7
+ * Single source of truth for `__p` ordering: `loop.ts` (which emits the
8
+ * array literal) and `reactive-effects.ts` (which emits the per-slot index
9
+ * reads) both derive their indices from the SAME `SkeletonPathPlan`, so the
10
+ * two can never drift out of sync.
11
+ */
12
+
13
+ import type { SkeletonSlotPaths } from '../../html-template.ts'
14
+
15
+ /**
16
+ * Convert a root-relative child-index path into a `.firstChild.nextSibling…`
17
+ * property chain off `base`. An empty path resolves to `base` itself (the
18
+ * slot lives on the clone root, e.g. an attr on the loop body's own tag).
19
+ */
20
+ export function pathExpr(base: string, path: readonly number[]): string {
21
+ let expr = base
22
+ for (const idx of path) {
23
+ expr += '.firstChild'
24
+ if (idx > 0) expr += '.nextSibling'.repeat(idx)
25
+ }
26
+ return expr
27
+ }
28
+
29
+ export interface SkeletonPathPlan {
30
+ /** Expressions for the `const __p = [...]` array literal, in stable order. */
31
+ arrayElems: string[]
32
+ /** slotId -> index into `__p`, for element-anchored slots (reactive attrs, refs). */
33
+ elementIndexBySlot: Map<string, number>
34
+ /** slotId -> index into `__p`, for text-marker-anchored slots (reactive texts). */
35
+ textIndexBySlot: Map<string, number>
36
+ }
37
+
38
+ /**
39
+ * Build the `__p` plan for a hoisted loop's reactive attr / text / ref
40
+ * slots. A slotId absent from `skeletonPaths` (shouldn't happen for a
41
+ * successfully-hoisted skeleton, but handled defensively) is simply left
42
+ * out of both maps — its stringifier call site falls back to `qsa`/`$t`
43
+ * for that one slot without affecting any other slot's resolution.
44
+ */
45
+ export function buildSkeletonPathPlan(
46
+ skeletonPaths: SkeletonSlotPaths,
47
+ elVar: string,
48
+ opts: { elementSlotIds: readonly string[]; textSlotIds: readonly string[] },
49
+ ): SkeletonPathPlan {
50
+ const arrayElems: string[] = []
51
+ const elementIndexBySlot = new Map<string, number>()
52
+ const textIndexBySlot = new Map<string, number>()
53
+
54
+ for (const slotId of opts.elementSlotIds) {
55
+ if (elementIndexBySlot.has(slotId)) continue
56
+ const path = skeletonPaths.elementPaths.get(slotId)
57
+ if (!path) continue
58
+ elementIndexBySlot.set(slotId, arrayElems.length)
59
+ arrayElems.push(pathExpr(elVar, path))
60
+ }
61
+ for (const slotId of opts.textSlotIds) {
62
+ if (textIndexBySlot.has(slotId)) continue
63
+ const path = skeletonPaths.textMarkerPaths.get(slotId)
64
+ if (!path) continue
65
+ textIndexBySlot.set(slotId, arrayElems.length)
66
+ arrayElems.push(pathExpr(elVar, path))
67
+ }
68
+
69
+ return { arrayElems, elementIndexBySlot, textIndexBySlot }
70
+ }
@@ -82,6 +82,30 @@ export function templateRootIsSvg(template: string): boolean {
82
82
  return branches.every(templateRootIsSvg)
83
83
  }
84
84
 
85
+ /**
86
+ * Wrap decision for MULTI-ROOT (fragment) templates, where the synthetic
87
+ * `<svg>` wrap swallows every sibling root at once (#2233 Copilot review).
88
+ *
89
+ * `templateRootIsSvg` inspects only the FIRST root tag. For single-root
90
+ * templates that's exact, but a fragment whose first root is an `<svg>`
91
+ * CONTAINER (`<><svg/><span/></>`) doesn't need the wrap at all — the
92
+ * HTML parser enters foreign content at `<svg>` on its own — and wrapping
93
+ * would drag the HTML siblings into the SVG namespace (`<span>` becomes an
94
+ * SVGUnknownElement, silently undrawn). So `<svg>`-first fragments skip
95
+ * the wrap; only leaf-rooted fragments (`<line>`, `<circle>`, ...) get it.
96
+ *
97
+ * Known edge (degenerate, pre-existing): an `<svg>`-first fragment with
98
+ * SVG-LEAF siblings (`<><svg/><line/></>`) leaves the bare leaf siblings
99
+ * in the HTML namespace — exactly the pre-#2219 behavior. Deciding that
100
+ * shape correctly needs a scan of every top-level root tag; not worth the
101
+ * parser until a real component hits it.
102
+ */
103
+ export function multiRootTemplateNeedsSvgWrap(template: string): boolean {
104
+ const m = stripLeadingNonContent(template).match(/^<\s*([A-Za-z][A-Za-z0-9-]*)/)
105
+ if (m && m[1].toLowerCase() === 'svg') return false
106
+ return templateRootIsSvg(template)
107
+ }
108
+
85
109
  /**
86
110
  * Strip leading whitespace and HTML comment markers (`<!-- ... -->`) so
87
111
  * that a branch like `<!--bf-cond-start:s0-->${...}<!--bf-cond-end:s0-->`
@@ -269,7 +293,7 @@ export function emitMultiRootTemplateCloneLines(
269
293
  varEl: string,
270
294
  varExtras: string,
271
295
  ): string[] {
272
- const isSvg = templateRootIsSvg(template)
296
+ const isSvg = multiRootTemplateNeedsSvgWrap(template)
273
297
  // Wrap in `<svg>` so the parser walks into SVG foreign content; we then
274
298
  // descend one level to pick up the per-item roots.
275
299
  const innerHtmlExpr = isSvg ? `\`<svg>${template}</svg>\`` : `\`${template}\``
@@ -338,6 +338,59 @@ export function extractFreeIdentifiersFromText(text: string): Set<string> {
338
338
  return extractFreeIdentifiersFromNode(expr)
339
339
  }
340
340
 
341
+ /**
342
+ * Free identifiers referenced by a block of JS *statements* — e.g. an inner
343
+ * `.map()` callback's block-body preamble (`mapPreamble`, #1052). Unlike
344
+ * `extractFreeIdentifiersFromText`, which wraps its input in `(...)` to
345
+ * force single-expression parsing, statement text (`const x = ...;`) is
346
+ * not a valid expression and would fail to parse inside parens — parse it
347
+ * as top-level source instead. Used by nested-loop index-param reference
348
+ * gating (#2218).
349
+ */
350
+ export function extractFreeIdentifiersFromStatementText(text: string): Set<string> {
351
+ if (!text || text.trim().length === 0) return new Set()
352
+ const sf = ts.createSourceFile(
353
+ '__free_ids_stmt__.ts',
354
+ text,
355
+ ts.ScriptTarget.Latest,
356
+ /* setParentNodes */ true,
357
+ ts.ScriptKind.TS,
358
+ )
359
+ return extractFreeIdentifiersFromNode(sf)
360
+ }
361
+
362
+ /**
363
+ * Free identifiers referenced inside a template-literal-style string's
364
+ * `${...}` interpolations — e.g. a nested loop's per-item HTML `template`
365
+ * (#2218). Wraps the text in backticks and parses it as a real
366
+ * `TemplateExpression` so brace balancing, string literals, and nested
367
+ * object/array literals inside `${...}` are handled correctly by the TS
368
+ * parser (AST-based — never the char-class regex `extractTemplateExpressions`
369
+ * in `identifiers.ts` uses for its looser references-graph pass). Safe by
370
+ * construction: the same `template` string is embedded verbatim inside a
371
+ * real backtick literal at emit time (`__t.innerHTML = \`${template}\``),
372
+ * so wrapping it here to parse mirrors exactly how it's already used.
373
+ */
374
+ export function extractFreeIdentifiersFromTemplateText(template: string): Set<string> {
375
+ if (!template || template.length === 0) return new Set()
376
+ const sf = ts.createSourceFile(
377
+ '__free_ids_template__.ts',
378
+ `(\`${template}\`);`,
379
+ ts.ScriptTarget.Latest,
380
+ /* setParentNodes */ true,
381
+ ts.ScriptKind.TS,
382
+ )
383
+ const stmt = sf.statements[0]
384
+ if (!stmt || !ts.isExpressionStatement(stmt)) return new Set()
385
+ const expr = ts.isParenthesizedExpression(stmt.expression) ? stmt.expression.expression : stmt.expression
386
+ if (!ts.isTemplateExpression(expr)) return new Set()
387
+ const ids = new Set<string>()
388
+ for (const span of expr.templateSpans) {
389
+ for (const id of extractFreeIdentifiersFromNode(span.expression)) ids.add(id)
390
+ }
391
+ return ids
392
+ }
393
+
341
394
  /**
342
395
  * Reduce a memo's `() => expr` source to the expression that should be
343
396
  * substituted in for `memoName()`. Matches the extraction done by the