@barefootjs/jsx 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) 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/expression-parser.d.ts +8 -0
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1002 -284
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  21. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  32. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  34. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/imports.d.ts +2 -2
  36. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/phases/props-extraction.d.ts +0 -1
  38. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  40. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  43. package/dist/ir-to-client-js/types.d.ts +21 -0
  44. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  45. package/dist/jsx-to-ir.d.ts.map +1 -1
  46. package/dist/signal-init-eval.d.ts +82 -0
  47. package/dist/signal-init-eval.d.ts.map +1 -0
  48. package/dist/static-literal.d.ts +45 -0
  49. package/dist/static-literal.d.ts.map +1 -0
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  52. package/src/__tests__/client-js-generation.test.ts +40 -0
  53. package/src/__tests__/create-selector.test.ts +110 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  55. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  56. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  57. package/src/__tests__/loop-bound-names.test.ts +102 -0
  58. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  59. package/src/__tests__/map-function-reference.test.ts +303 -0
  60. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  61. package/src/__tests__/props-destructuring.test.ts +61 -8
  62. package/src/__tests__/signal-init-eval.test.ts +138 -0
  63. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  64. package/src/__tests__/static-literal.test.ts +126 -0
  65. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  66. package/src/adapters/dangerous-inner-html.ts +186 -0
  67. package/src/adapters/loop-bound-names.ts +80 -0
  68. package/src/adapters/parsed-expr-emitter.ts +9 -0
  69. package/src/analyzer.ts +36 -22
  70. package/src/expression-parser.ts +33 -0
  71. package/src/index.ts +20 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  74. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  76. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  77. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  78. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  79. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  80. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  81. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  82. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  83. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  85. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  86. package/src/ir-to-client-js/html-template.ts +235 -2
  87. package/src/ir-to-client-js/imports.ts +1 -0
  88. package/src/ir-to-client-js/phases/props-extraction.ts +5 -9
  89. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  90. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  91. package/src/ir-to-client-js/reactivity.ts +6 -0
  92. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  93. package/src/ir-to-client-js/types.ts +21 -0
  94. package/src/jsx-to-ir.ts +230 -39
  95. package/src/signal-init-eval.ts +165 -0
  96. package/src/static-literal.ts +128 -0
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  ParamInfo,
50
50
  PropertyInfo,
51
51
  MemoInfo,
52
+ ConstantInfo,
52
53
  TypeInfo,
53
54
  TypeDefinition,
54
55
  SourceLocation,
@@ -87,6 +88,9 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
87
88
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
88
89
  export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
89
90
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
91
+ export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
92
+ export { evaluateSignalInit, tryEvaluateSignalInit, type SignalInitEvalResult } from './signal-init-eval.ts'
93
+ export { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from './static-literal.ts'
90
94
  export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
91
95
  export type { EnvSignalReader } from './adapters/env-signal.ts'
92
96
  export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
@@ -116,6 +120,13 @@ export { emitIRNode } from './adapters/ir-node-emitter.ts'
116
120
  export type { IRNodeEmitter, EmitIRNode } from './adapters/ir-node-emitter.ts'
117
121
  export { emitAttrValue } from './adapters/attr-value-emitter.ts'
118
122
  export type { AttrValueEmitter } from './adapters/attr-value-emitter.ts'
123
+ export {
124
+ isDangerousInnerHtmlAttr,
125
+ resolveDangerousInnerHtml,
126
+ dangerousInnerHtmlMetacharViolation,
127
+ dangerousInnerHtmlDiagnostic,
128
+ } from './adapters/dangerous-inner-html.ts'
129
+ export type { DangerousInnerHtmlResolution } from './adapters/dangerous-inner-html.ts'
119
130
 
120
131
  // Client JS Generator
121
132
  export { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
@@ -272,15 +283,20 @@ export interface BuildOptions {
272
283
  * - `'treeshake'` (default) — bundle only the runtime exports this
273
284
  * project's compiled client JS actually imports, plus a small
274
285
  * always-kept public mount API (`render`, `hydrate`, etc.).
286
+ * - `'treeshake-exact'` — same collection, but without the always-kept
287
+ * set. Smaller output; a hand-written page script the CLI never
288
+ * compiles must list any runtime names it calls directly in
289
+ * `runtimeKeep`, or they're silently dropped.
275
290
  * - `'full'` — copy the entire prebuilt runtime bundle verbatim.
276
291
  * See `@barefootjs/cli`'s `runtime-treeshake.ts` for the collector and
277
292
  * `ALWAYS_KEEP_RUNTIME_EXPORTS` for the always-kept names.
278
293
  */
279
- runtimeBundle?: 'treeshake' | 'full'
294
+ runtimeBundle?: 'treeshake' | 'treeshake-exact' | 'full'
280
295
  /**
281
296
  * Extra `@barefootjs/client*` export names to force-keep in `barefoot.js`
282
- * under `runtimeBundle: 'treeshake'` — for names only ever referenced
283
- * from hand-written page scripts the CLI never compiles.
297
+ * under `runtimeBundle: 'treeshake'` or `'treeshake-exact'` — for names
298
+ * only ever referenced from hand-written page scripts the CLI never
299
+ * compiles.
284
300
  */
285
301
  runtimeKeep?: string[]
286
302
  }
@@ -306,6 +322,7 @@ export { ErrorCodes, createError, formatError, generateCodeFrame } from './error
306
322
  // Expression Parser
307
323
  export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
308
324
  export type { StyleObjectEntry } from './expression-parser.ts'
325
+ export { PARSED_EXPR_KINDS } from './expression-parser.ts'
309
326
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
310
327
  export { buildLoopChainExpr } from './loop-chain.ts'
311
328
  export type { LoopChainInputs } from './loop-chain.ts'
@@ -6,7 +6,8 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, type SkeletonSlotPaths } from './html-template.ts'
10
+ import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
10
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
11
12
  import { walkIR, stopAt } from './walker.ts'
12
13
  import { buildLoopChainExpr } from '../loop-chain.ts'
@@ -385,6 +386,7 @@ export function collectInnerLoops(
385
386
  arrayFreeIdentifiers: n.arrayFreeIdentifiers,
386
387
  param: n.param,
387
388
  paramBindings: n.paramBindings,
389
+ index: n.index,
388
390
  key: n.key,
389
391
  markerId: n.markerId,
390
392
  bodyIsMultiRoot: n.bodyIsMultiRoot,
@@ -673,6 +675,7 @@ export function collectElements(
673
675
  let template = ''
674
676
  let staticItemTemplate: string | undefined
675
677
  let skeletonTemplate: string | undefined
678
+ let skeletonPaths: SkeletonSlotPaths | undefined
676
679
  if (l.childComponent) {
677
680
  template = '' // childComponent path uses createComponent directly
678
681
  // CSR materialize fallback (#1268): when the loop array references an
@@ -724,10 +727,18 @@ export function collectElements(
724
727
  // expressions, …) and returns `null` for anything it can't prove
725
728
  // safe; the plan builder (`build-loop.ts`) falls back to the
726
729
  // per-row `template` above whenever this stays `undefined`.
727
- skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], {
730
+ const skeletonSafeSlots = {
728
731
  reactiveAttrKeys: new Set(bindings.reactiveAttrs.map(a => `${a.childSlotId}::${a.attrName}`)),
729
732
  reactiveTextSlotIds: new Set(bindings.reactiveTexts.map(t => t.slotId)),
730
- }) ?? undefined
733
+ }
734
+ skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], skeletonSafeSlots) ?? undefined
735
+ // Direct child-index paths (perf, #2143): only attempted when the
736
+ // skeleton itself hoisted, and skipped for SVG roots for now (the
737
+ // `<svg>`-wrap namespace fix-up is orthogonal and untested against
738
+ // this path model — safe fallback to qsa/$t for those loops).
739
+ if (skeletonTemplate && !templateRootIsSvg(skeletonTemplate)) {
740
+ skeletonPaths = computeSkeletonSlotPaths(l.children[0], skeletonSafeSlots) ?? undefined
741
+ }
731
742
  }
732
743
  }
733
744
 
@@ -748,6 +759,7 @@ export function collectElements(
748
759
  template,
749
760
  staticItemTemplate,
750
761
  skeletonTemplate,
762
+ skeletonPaths,
751
763
  childEventHandlers: childHandlers,
752
764
  bindings,
753
765
  childComponent: l.childComponent,
@@ -58,6 +58,7 @@ import type { DepthLevel } from '../shared.ts'
58
58
  import {
59
59
  destructureLoopParam,
60
60
  loopKeyFn,
61
+ nestedLoopIndexAlias,
61
62
  } from '../shared.ts'
62
63
  import type {
63
64
  InnerLoopPlan,
@@ -114,8 +115,8 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
114
115
  const useReactive = refsParent && !!inner.template
115
116
 
116
117
  const emit: InnerLoopReactiveEmit | InnerLoopStaticEmit = useReactive
117
- ? buildReactiveEmit(inner, level, wrapOuter)
118
- : buildStaticEmit(inner, level)
118
+ ? buildReactiveEmit(inner, level, wrapOuter, uidSuffix)
119
+ : buildStaticEmit(inner, level, uidSuffix)
119
120
 
120
121
  const arrayExpr = useReactive ? wrapOuter(inner.array) : inner.array
121
122
 
@@ -153,6 +154,7 @@ function buildReactiveEmit(
153
154
  inner: NestedLoop,
154
155
  level: DepthLevel,
155
156
  wrapOuter: (expr: string) => string,
157
+ uidSuffix: string,
156
158
  ): InnerLoopReactiveEmit {
157
159
  const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
158
160
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
@@ -218,7 +220,14 @@ function buildReactiveEmit(
218
220
  // The destructure unwrap (when `inner.param` is a binding pattern)
219
221
  // has to land before the preamble so the preamble's bare-binding
220
222
  // references resolve.
223
+ //
224
+ // The index alias (#2218) lands first — before both the unwrap and the
225
+ // preamble — since either may reference the user's index name (e.g. a
226
+ // `const rowTag = \`row-${i}\`` preamble line), and both run before the
227
+ // cloned-template IIFE that may also reference it.
221
228
  const preludeStatements: string[] = []
229
+ const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, paramHead, level.comps, level.events)
230
+ if (indexAlias) preludeStatements.push(indexAlias)
222
231
  if (paramUnwrap) preludeStatements.push(paramUnwrap)
223
232
  if (inner.mapPreamble) preludeStatements.push(wrapInner(wrapOuter(inner.mapPreamble)))
224
233
 
@@ -240,7 +249,7 @@ function buildReactiveEmit(
240
249
  }
241
250
  }
242
251
 
243
- function buildStaticEmit(inner: NestedLoop, level: DepthLevel): InnerLoopStaticEmit {
252
+ function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string): InnerLoopStaticEmit {
244
253
  // Static `forEach` iterates with the literal item as its first param, so
245
254
  // no signal-accessor rewrite is needed — emit the preamble verbatim
246
255
  // before the component/event setup so prop getters and event handlers
@@ -248,7 +257,15 @@ function buildStaticEmit(inner: NestedLoop, level: DepthLevel): InnerLoopStaticE
248
257
  // wrapping the callback would rewrite `s.x` to `s().x` and throw at
249
258
  // runtime when the callback closes over the static inner param (#1244,
250
259
  // PR #1352 Copilot review).
251
- const preludeStatements: string[] = inner.mapPreamble ? [inner.mapPreamble] : []
260
+ //
261
+ // The index alias (#2218) lands first, same rationale as the reactive
262
+ // path: `forEach`'s second param is the synthetic `__innerIdx<uid>`, not
263
+ // the user's index name, so a preamble/prop/event that reads it needs the
264
+ // alias bound before anything else runs.
265
+ const preludeStatements: string[] = []
266
+ const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, inner.param, level.comps, level.events)
267
+ if (indexAlias) preludeStatements.push(indexAlias)
268
+ if (inner.mapPreamble) preludeStatements.push(inner.mapPreamble)
252
269
  return {
253
270
  mode: 'static',
254
271
  rawKey: inner.key ?? null,
@@ -57,7 +57,7 @@ function wrapAttrValueExpression(value: AttrValue, wrap: (s: string) => string):
57
57
  return AttrValueOf.spread(wrap(value.expr), value.templateExpr ? wrap(value.templateExpr) : undefined)
58
58
  }
59
59
  }
60
- import { destructureLoopParam, loopKeyFn, buildCompSelector } from '../shared.ts'
60
+ import { destructureLoopParam, loopKeyFn, buildCompSelector, nestedLoopIndexAlias } from '../shared.ts'
61
61
  import { BF_HOST, BF_AT } from '@barefootjs/shared'
62
62
  import type {
63
63
  BranchChildComponentInit,
@@ -269,6 +269,17 @@ export function buildBranchInnerLoopsPlan(
269
269
  handler: wrapInner(ev.handler),
270
270
  }))
271
271
 
272
+ // Index-param gating runs against the RAW (pre-wrap) component/event
273
+ // data — wrapping only rewrites loop-param references, never the
274
+ // index name, so it doesn't affect whether the index is referenced.
275
+ const indexAlias = nestedLoopIndexAlias(
276
+ inner,
277
+ `__bidxbr_${i}`,
278
+ paramHead,
279
+ inner.childComponents ?? [],
280
+ inner.bindings.events,
281
+ )
282
+
272
283
  const reactiveTexts: BranchInnerLoopText[] = inner.bindings.reactiveTexts.map(text => ({
273
284
  slotId: text.slotId,
274
285
  wrappedExpression: wrapBoth(text.expression),
@@ -286,6 +297,7 @@ export function buildBranchInnerLoopsPlan(
286
297
  keyFn: loopKeyFn(inner),
287
298
  paramHead,
288
299
  paramUnwrap,
300
+ indexAlias,
289
301
  wrappedTemplate: inner.template!,
290
302
  wrappedKey,
291
303
  keyDepth: 1,
@@ -103,6 +103,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
103
103
  mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
104
104
  template: elem.template,
105
105
  skeletonTemplate: elem.skeletonTemplate,
106
+ skeletonPaths: elem.skeletonPaths,
106
107
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
107
108
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
108
109
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -108,6 +108,15 @@ export interface BranchInnerLoop {
108
108
  paramHead: string
109
109
  /** Body-entry unwrap statement (empty when no destructured param). */
110
110
  paramUnwrap: string
111
+ /**
112
+ * Body-entry index-alias statement (#2218), or `null` when the inner
113
+ * loop declares no index param or never references it. Binds the user's
114
+ * index name to the synthetic `__bidx<uid>` renderItem param — mirrors
115
+ * `InnerLoopReactiveEmit`'s prelude alias in `plan/inner-loop.ts`. Must
116
+ * be emitted before `paramUnwrap` and the template clone, since either
117
+ * may reference the index.
118
+ */
119
+ indexAlias: string | null
111
120
  /** Already-wrapped HTML template for one inner-loop item. */
112
121
  wrappedTemplate: string
113
122
  /** Pre-wrapped key expression for setAttribute, or null when no key. */
@@ -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 { SkeletonSlotPaths } from '../../html-template.ts'
26
27
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
27
28
  import type { InnerLoopsPlan } from './inner-loop.ts'
28
29
 
@@ -103,6 +104,14 @@ interface PlainLoopVariant extends DynamicLoopCommon {
103
104
  * stringifier falls back to the legacy per-row `emitTemplateCloneInline`.
104
105
  */
105
106
  skeletonTemplate?: string
107
+ /**
108
+ * Compile-time child-index paths for `skeletonTemplate`'s dynamic slots
109
+ * (perf, #2143). When present, the stringifier resolves attr/text/ref
110
+ * slots via direct `.firstChild`/`.nextSibling` property chains on a
111
+ * fresh clone instead of `qsa`/`$t`, falling back to the runtime lookup
112
+ * only for hydration (`__existing`) or any slot missing from the map.
113
+ */
114
+ skeletonPaths?: SkeletonSlotPaths
106
115
  /** Resolved reactive-effects plan — null forces the single-line renderItem shape. */
107
116
  reactiveEffects: ReactiveEffectsPlan | null
108
117
  /**
@@ -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