@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
@@ -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
@@ -875,6 +875,196 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
875
875
  }
876
876
  }
877
877
 
878
+ /**
879
+ * Child-node index paths for a hoisted loop skeleton (perf, #2143): computed
880
+ * alongside `buildLoopSkeletonTemplate` from the SAME IR tree, so the
881
+ * compiler can emit direct `.firstChild`/`.nextSibling` property chains
882
+ * (Solid-style) instead of a per-row `qsa`/`$t` runtime lookup for every
883
+ * dynamic slot in the hoisted single-root loop fast path.
884
+ *
885
+ * Only ever consumed for a FRESH clone of the hoisted skeleton — hydration
886
+ * (`__existing`, real SSR-rendered DOM) keeps using `qsa`/`$t`, since the
887
+ * skeleton's empty text markers and omitted dynamic attrs don't describe the
888
+ * SSR-rendered tree's actual shape (see `computeSkeletonSlotPaths`).
889
+ */
890
+ export interface SkeletonSlotPaths {
891
+ /** slotId -> childNodes-index path from the clone root to the element carrying `bf="slotId"`. Empty array = the root itself. */
892
+ elementPaths: ReadonlyMap<string, readonly number[]>
893
+ /** slotId -> path to the first Comment of the `<!--bf:slotId--><!--/-->` marker pair. */
894
+ textMarkerPaths: ReadonlyMap<string, readonly number[]>
895
+ }
896
+
897
+ /**
898
+ * Tags whose HTML-parser behavior can silently restructure or drop children
899
+ * relative to the naive IR-tree model (implied table sections, `<select>`/
900
+ * `<optgroup>` child-dropping, `<p>` auto-close, leading-newline drop in
901
+ * `<pre>`/`<textarea>`, `<template>` content relocating into `.content`). A
902
+ * skeleton containing any of these bails on path computation entirely —
903
+ * the loop keeps its hoisted-clone fast path, but every slot lookup falls
904
+ * back to `qsa`/`$t`.
905
+ */
906
+ const SKELETON_PATH_HAZARD_TAGS = new Set([
907
+ 'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col',
908
+ 'select', 'optgroup',
909
+ 'p',
910
+ 'pre', 'textarea', 'listing',
911
+ 'template',
912
+ 'math', // MathML foreign-content: breakout tags pop content back out, same class of hazard as SVG.
913
+ ])
914
+
915
+ /**
916
+ * Tag groups the HTML parser force-closes when a tag from the group is
917
+ * nested inside ANY other open tag from the SAME group — not just an
918
+ * identical tag (e.g. `<h2>` inside `<h1>` closes the `<h1>`, `<dd>` inside
919
+ * `<dt>` closes the `<dt>`). A skeleton hitting this bails on path
920
+ * computation entirely (see `SKELETON_PATH_HAZARD_TAGS` doc).
921
+ */
922
+ const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
923
+ new Set(['a']),
924
+ new Set(['button']),
925
+ new Set(['form']),
926
+ new Set(['option']),
927
+ new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
928
+ new Set(['dd', 'dt']),
929
+ new Set(['li']),
930
+ ]
931
+
932
+ function skeletonForceCloseGroup(tag: string): number {
933
+ return SKELETON_PATH_FORCE_CLOSE_GROUPS.findIndex(group => group.has(tag))
934
+ }
935
+
936
+ interface SkeletonPathState {
937
+ elementPaths: Map<string, readonly number[]>
938
+ textMarkerPaths: Map<string, readonly number[]>
939
+ bailed: boolean
940
+ }
941
+
942
+ /**
943
+ * Compute per-slot child-index paths for a loop skeleton already proven
944
+ * safe by `buildLoopSkeletonTemplate` — call this ONLY after that returned
945
+ * non-null for the same `(node, safe)` pair; it assumes the same shape
946
+ * guarantees (no spreads/conditionals/components/nested loops) and does not
947
+ * re-derive them. Returns `null` when the tree contains a parser-hazard tag
948
+ * (see `SKELETON_PATH_HAZARD_TAGS`) that could make the index-path model
949
+ * diverge from the browser's actual parsed structure.
950
+ */
951
+ export function computeSkeletonSlotPaths(node: IRNode, safe: LoopSkeletonSafeSlots): SkeletonSlotPaths | null {
952
+ const state: SkeletonPathState = { elementPaths: new Map(), textMarkerPaths: new Map(), bailed: false }
953
+ walkSkeletonPathNode(node, [], safe, state, new Set())
954
+ if (state.bailed) return null
955
+ return { elementPaths: state.elementPaths, textMarkerPaths: state.textMarkerPaths }
956
+ }
957
+
958
+ function walkSkeletonPathNode(
959
+ node: IRNode,
960
+ path: readonly number[],
961
+ safe: LoopSkeletonSafeSlots,
962
+ state: SkeletonPathState,
963
+ forceCloseAncestors: ReadonlySet<number>,
964
+ ): void {
965
+ if (state.bailed || node.type !== 'element') return
966
+ if (SKELETON_PATH_HAZARD_TAGS.has(node.tag)) { state.bailed = true; return }
967
+ const groupIdx = skeletonForceCloseGroup(node.tag)
968
+ if (groupIdx >= 0 && forceCloseAncestors.has(groupIdx)) { state.bailed = true; return }
969
+ // Void elements never legally carry children; if the IR claims otherwise
970
+ // the browser reparses `</tag>` as stray content instead of a close tag,
971
+ // diverging from the naive one-node-per-element model.
972
+ const flatChildren = flattenSkeletonChildren(node.children)
973
+ if (VOID_ELEMENTS.has(node.tag) && flatChildren.length > 0) { state.bailed = true; return }
974
+ // Foster parenting (#2143 review): a bare `<tr>` root is intentionally NOT
975
+ // in SKELETON_PATH_HAZARD_TAGS (it's the common loop-row shape), but
976
+ // non-`td`/`th` element children — and any non-whitespace text run —
977
+ // directly inside it get foster-parented OUT of the row by the HTML
978
+ // parser, shifting every sibling index after them. Comments (the `bf:sN`
979
+ // marker pairs) are unaffected — comments are inserted in place even in
980
+ // table-related insertion modes.
981
+ if (node.tag === 'tr' && hasForeignTableRowContent(flatChildren)) { state.bailed = true; return }
982
+ if (node.slotId) state.elementPaths.set(node.slotId, path)
983
+ const nextAncestors = groupIdx >= 0
984
+ ? new Set([...forceCloseAncestors, groupIdx])
985
+ : forceCloseAncestors
986
+ walkSkeletonPathChildren(flatChildren, path, safe, state, nextAncestors)
987
+ }
988
+
989
+ /** True if `children` (already flattened) contains anything the HTML parser would foster-parent out of a `<tr>`. */
990
+ function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
991
+ for (const child of children) {
992
+ if (child.type === 'text') {
993
+ if (child.value.trim() !== '') return true
994
+ continue
995
+ }
996
+ if (child.type === 'element' && child.tag !== 'td' && child.tag !== 'th') {
997
+ return true
998
+ }
999
+ }
1000
+ return false
1001
+ }
1002
+
1003
+ /** Splice fragment children inline — a fragment contributes no DOM node of its own. */
1004
+ function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
1005
+ const out: IRNode[] = []
1006
+ for (const child of children) {
1007
+ if (child.type === 'fragment') {
1008
+ out.push(...flattenSkeletonChildren(child.children))
1009
+ } else {
1010
+ out.push(child)
1011
+ }
1012
+ }
1013
+ return out
1014
+ }
1015
+
1016
+ /**
1017
+ * Walk one level of (already flattened) children, tracking the DOM child
1018
+ * index as it will exist on a FRESH clone of the skeleton (empty text
1019
+ * markers, no dynamic attrs) — not the per-row interpolated template.
1020
+ * Mirrors the browser's text-node-merging behavior: adjacent text content
1021
+ * (including a dropped null/undefined expression) collapses into a single
1022
+ * Text node; an empty literal string contributes no node at all.
1023
+ */
1024
+ function walkSkeletonPathChildren(
1025
+ children: readonly IRNode[],
1026
+ parentPath: readonly number[],
1027
+ safe: LoopSkeletonSafeSlots,
1028
+ state: SkeletonPathState,
1029
+ forceCloseAncestors: ReadonlySet<number>,
1030
+ ): void {
1031
+ let idx = 0
1032
+ let pendingText = false
1033
+ for (const child of children) {
1034
+ if (state.bailed) return
1035
+ switch (child.type) {
1036
+ case 'text': {
1037
+ if (child.value === '') continue
1038
+ if (!pendingText) idx += 1
1039
+ pendingText = true
1040
+ continue
1041
+ }
1042
+ case 'expression': {
1043
+ if (child.expr === 'null' || child.expr === 'undefined') continue
1044
+ if (!child.slotId || !safe.reactiveTextSlotIds.has(child.slotId)) { state.bailed = true; return }
1045
+ state.textMarkerPaths.set(child.slotId, [...parentPath, idx])
1046
+ idx += 2 // the marker pair: <!--bf:sN--> then <!--/-->
1047
+ pendingText = false
1048
+ continue
1049
+ }
1050
+ case 'element': {
1051
+ walkSkeletonPathNode(child, [...parentPath, idx], safe, state, forceCloseAncestors)
1052
+ idx += 1
1053
+ pendingText = false
1054
+ continue
1055
+ }
1056
+ case 'fragment':
1057
+ continue // already flattened
1058
+ default:
1059
+ // conditional/component/loop/if-statement/provider/async/slot: none
1060
+ // of these should reach here — buildLoopSkeletonTemplate would have
1061
+ // already returned null for the same tree. Bail defensively.
1062
+ state.bailed = true
1063
+ return
1064
+ }
1065
+ }
1066
+ }
1067
+
878
1068
  /**
879
1069
  * Generate an HTML template for composite element reconciliation.
880
1070
  * Identical to irToHtmlTemplate except component nodes become placeholder
@@ -1135,6 +1325,20 @@ export interface TemplateOptions {
1135
1325
  csrEnv?: CsrEnv
1136
1326
  insideLoop?: boolean
1137
1327
  loopDepth?: number
1328
+ /**
1329
+ * Names bound by the ENCLOSING loops' callback params (item / index /
1330
+ * destructured binding names), accumulated by the `loop` case as the
1331
+ * recursion descends (#2222). Inside a loop body, `csrSubstitute`
1332
+ * receives each expression in isolation — no enclosing arrow text — so
1333
+ * its own bound-name tracking can't see the loop binding; the `loop`
1334
+ * case instead filters these names out of the child recursion's
1335
+ * `csrEnv`, so an inlinable const / signal / memo whose name is
1336
+ * shadowed by a loop param never substitutes at the shadowed
1337
+ * occurrence. Scope-accurate per nesting level (the CURRENT level's
1338
+ * own `transformExpr` — e.g. the loop's array-source expression —
1339
+ * keeps the unfiltered env).
1340
+ */
1341
+ loopBoundNames?: ReadonlySet<string>
1138
1342
  /** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
1139
1343
  inHoistedChildren?: boolean
1140
1344
  /**
@@ -1988,7 +2192,36 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1988
2192
  }
1989
2193
 
1990
2194
  case 'loop': {
1991
- let childTemplate = node.children.map(recurseInLoop).join('')
2195
+ // Accumulate this loop's callback-bound names and filter them out
2196
+ // of the child recursion's substitution env (#2222): an inlinable
2197
+ // const / signal / memo whose name the callback shadows must never
2198
+ // substitute inside the body. Destructured callbacks contribute
2199
+ // their individual binding names; a raw pattern-text `param` (the
2200
+ // BF025 fallback shapes, detected by the same leading-`[`/`{` check
2201
+ // `destructureLoopParam` uses — NOT an ASCII-identifier regex, which
2202
+ // would drop Unicode param names like `π` and re-enable the shadow
2203
+ // bug for that body) contributes nothing — conservatively
2204
+ // unfiltered rather than mis-filtered.
2205
+ const boundHere = new Set(opts.loopBoundNames ?? [])
2206
+ if (node.paramBindings && node.paramBindings.length > 0) {
2207
+ for (const b of node.paramBindings) boundHere.add(b.name)
2208
+ } else if (!node.param.startsWith('[') && !node.param.startsWith('{')) {
2209
+ boundHere.add(node.param)
2210
+ }
2211
+ if (node.index) boundHere.add(node.index)
2212
+ const childEnv: CsrEnv = {
2213
+ ...env,
2214
+ substitutions: new Map([...env.substitutions].filter(([name]) => !boundHere.has(name))),
2215
+ }
2216
+ const recurseInLoopBody = (n: IRNode): string => generateCsrTemplateWithOpts(n, {
2217
+ ...opts,
2218
+ insideLoop: true,
2219
+ loopDepth: loopDepth + 1,
2220
+ inHoistedChildren: false,
2221
+ loopBoundNames: boundHere,
2222
+ csrEnv: childEnv,
2223
+ })
2224
+ let childTemplate = node.children.map(recurseInLoopBody).join('')
1992
2225
  // Whole-item conditional loops (#1665): prepend the per-item
1993
2226
  // `<!--bf-loop-i:KEY-->` anchor so `mapArrayAnchored` can track items
1994
2227
  // that render no element. Mirrors the `irToHtmlTemplate` loop case.
@@ -2022,7 +2255,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2022
2255
  if (node.flatMapCallback) {
2023
2256
  let body = node.flatMapCallback.templateBody ?? node.flatMapCallback.body
2024
2257
  for (const frag of node.flatMapCallback.fragments) {
2025
- const renderedIr = recurseInLoop(frag.ir)
2258
+ const renderedIr = recurseInLoopBody(frag.ir)
2026
2259
  body = body.replace(frag.placeholder, `\`${renderedIr}\``)
2027
2260
  }
2028
2261
  body = applyPropsRewrite(body, propsObjectName ?? null)
@@ -14,6 +14,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
14
14
  'provideContext', 'createContext', 'useContext',
15
15
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
16
16
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
17
+ 'tAfter',
17
18
  // Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
18
19
  'beginTurn', 'endTurn',
19
20
  ] as const
@@ -9,7 +9,6 @@
9
9
  * - prop has property/index access → `?? {}` (skipped when the
10
10
  * prop is a conditional guard
11
11
  * so falsy values stay falsy)
12
- * - prop is optional with type info → `?? <inferred default>`
13
12
  * - otherwise → no default
14
13
  *
15
14
  * Skipped entirely when the component uses an opaque props object name
@@ -20,7 +19,7 @@
20
19
  import type { PropUsage } from '../../types.ts'
21
20
  import { propHasPropertyAccess } from '../compute-prop-usage.ts'
22
21
  import type { ClientJsContext } from '../types.ts'
23
- import { inferDefaultValue, PROPS_PARAM } from '../utils.ts'
22
+ import { PROPS_PARAM } from '../utils.ts'
24
23
 
25
24
  export function emitPropsExtraction(
26
25
  lines: string[],
@@ -53,14 +52,11 @@ export function emitPropsExtraction(
53
52
  lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? []`)
54
53
  } else if (propHasPropertyAccess(usage) && !propsUsedAsConditions.has(propName)) {
55
54
  lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? {}`)
56
- } else if (prop?.optional && prop?.type) {
57
- const inferredDefault = inferDefaultValue(prop.type)
58
- if (inferredDefault !== 'undefined') {
59
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? ${inferredDefault}`)
60
- } else {
61
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName}`)
62
- }
63
55
  } else {
56
+ // No synthesized default for a defaultless optional (`{ size }:
57
+ // { size?: number }`): the JS binding is `undefined` when absent, and
58
+ // a zero default would diverge from SSR (`size ?? 1` seeds 1
59
+ // server-side; a `_p.size ?? 0` extraction would hydrate to 0).
64
60
  lines.push(` const ${propName} = ${PROPS_PARAM}.${propName}`)
65
61
  }
66
62
  }
@@ -124,6 +124,11 @@ function buildInnerLoopNestedPlan(
124
124
  innerComps: readonly IRLoopChildComponent[],
125
125
  ): InnerLoopNestedInitPlan {
126
126
  const outerIndexParam = elem.index || '__idx'
127
+ // The user's declared inner index name, falling back to the synthetic
128
+ // `__innerIdx` — same idiom as the outer `elem.index || '__idx'` above.
129
+ // Hardcoding the synthetic name left a referenced user index unbound and
130
+ // `initChild`'s prop getters threw `ReferenceError` at hydration (#2231).
131
+ const innerIndexParam = innerLoop.index || '__innerIdx'
127
132
  const comps: InnerLoopComp[] = innerComps.map(comp => ({
128
133
  componentName: comp.name,
129
134
  selector: buildCompSelector(comp),
@@ -141,7 +146,8 @@ function buildInnerLoopNestedPlan(
141
146
  innerContainerSlotId: innerLoop.containerSlotId ?? null,
142
147
  innerArrayExpr: innerLoop.array,
143
148
  innerParam: innerLoop.param,
144
- innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
149
+ innerIndexParam,
150
+ innerOffsetExpr: buildLoopChildIndexExpr(innerIndexParam, innerLoop.offset),
145
151
  innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
146
152
  depth: innerLoop.depth,
147
153
  comps,
@@ -179,9 +185,14 @@ function buildComponentRootedInnerLoopPlan(
179
185
  containerVar: `_${varSlotId(elem.slotId)}`,
180
186
  outerArrayExpr: elem.array,
181
187
  outerParam: elem.param,
188
+ // Declared index names only (#2231) — the zip shape never indexes by
189
+ // position, so there's no synthetic fallback and index-less loops keep
190
+ // byte-identical output.
191
+ outerIndexParam: elem.index,
182
192
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
183
193
  innerArrayExpr: innerLoop.array,
184
194
  innerParam: innerLoop.param,
195
+ innerIndexParam: innerLoop.index,
185
196
  innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
186
197
  depth: innerLoop.depth,
187
198
  comps,
@@ -122,7 +122,16 @@ export interface InnerLoopNestedInitPlan {
122
122
  innerContainerSlotId: string | null
123
123
  innerArrayExpr: string
124
124
  innerParam: string
125
- /** Inner offset — `__innerIdx` plus any sibling-offset terms. */
125
+ /**
126
+ * Inner index parameter identifier — the user's declared `.map((item, i)
127
+ * => ...)` index name, or the synthetic `__innerIdx` when the callback
128
+ * declares none (#2231). Emitted as the inner `forEach`'s second param so
129
+ * prop getters that close over the index resolve it (previously the
130
+ * synthetic name was hardcoded and a referenced user index threw
131
+ * `ReferenceError` from `initChild`'s first prop read).
132
+ */
133
+ innerIndexParam: string
134
+ /** Inner offset — `innerIndexParam` plus any sibling-offset terms. */
126
135
  innerOffsetExpr: string
127
136
  /**
128
137
  * Inner `.map()` callback preamble locals, emitted after the
@@ -161,11 +170,21 @@ export interface ComponentRootedInnerLoopInitPlan {
161
170
  /** Outer loop's array expression. */
162
171
  outerArrayExpr: string
163
172
  outerParam: string
173
+ /**
174
+ * Outer `.map()` index param name, or `null` when the callback declares
175
+ * none (#2231). Unlike `inner-loop-nested`, this shape needs no synthetic
176
+ * fallback — the document-order zip never indexes by position — so the
177
+ * param is appended to the `forEach` head only when declared, keeping
178
+ * index-less loops byte-identical.
179
+ */
180
+ outerIndexParam: string | null
164
181
  /** Outer `.map()` callback preamble locals (#1064). */
165
182
  outerPreludeStatements: PreludeStatements
166
183
  /** Inner loop's array expression (references the outer param). */
167
184
  innerArrayExpr: string
168
185
  innerParam: string
186
+ /** Inner `.map()` index param name, or `null` — see `outerIndexParam` (#2231). */
187
+ innerIndexParam: string | null
169
188
  /** Inner `.map()` callback preamble locals (#1064). */
170
189
  innerPreludeStatements: PreludeStatements
171
190
  /** Depth used in the leading comment line. */
@@ -434,6 +434,12 @@ export function collectLoopChildEventsWithNesting(
434
434
  depth: scope.nestingStack.length + 1,
435
435
  array: l.array,
436
436
  param: l.param,
437
+ // Event-delegation metadata only (see the field comment on
438
+ // `NestedLoop.index`) — threaded for type-completeness (#2218)
439
+ // so this record stays a structurally-valid `NestedLoop`, even
440
+ // though delegated-handler index binding is handled separately
441
+ // by `indexBindingLine` in `stringify/event-delegation.ts` (#2189).
442
+ index: l.index,
437
443
  key: l.key,
438
444
  markerId: l.markerId,
439
445
  containerSlotId: scope.lastElementSlotId,
@@ -36,7 +36,7 @@
36
36
  * <i> if (!__outerEl) return
37
37
  * <i> <outerPreludeStatements*> // raw outer preamble (#1064)
38
38
  * <i> const __ic = <innerContainer-or-outerEl>
39
- * <i> <innerArr>.forEach((<innerParam>, __innerIdx) => {
39
+ * <i> <innerArr>.forEach((<innerParam>, <innerIdx>) => {
40
40
  * <i> const __innerEl = __ic.children[<innerOffset>]
41
41
  * <i> if (!__innerEl) return
42
42
  * <i> <innerPreludeStatements*> // raw inner preamble (#1064)
@@ -137,6 +137,7 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
137
137
  innerContainerSlotId,
138
138
  innerArrayExpr,
139
139
  innerParam,
140
+ innerIndexParam,
140
141
  innerOffsetExpr,
141
142
  innerPreludeStatements,
142
143
  depth,
@@ -157,7 +158,7 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
157
158
  } else {
158
159
  lines.push(` const __ic = __outerEl`)
159
160
  }
160
- lines.push(` ${innerArrayExpr}.forEach((${innerParam}, __innerIdx) => {`)
161
+ lines.push(` ${innerArrayExpr}.forEach((${innerParam}, ${innerIndexParam}) => {`)
161
162
  lines.push(` const __innerEl = __ic.children[${innerOffsetExpr}]`)
162
163
  lines.push(` if (!__innerEl) return`)
163
164
  // Inner `.map()` callback preamble — must precede the per-component
@@ -186,9 +187,9 @@ function emitInnerLoopNested(lines: string[], plan: InnerLoopNestedInitPlan): vo
186
187
  * <i>if (<container>) {
187
188
  * <i> const <scopes_c> = qsaChildScopes(<container>, <selector_c>) // per comp
188
189
  * <i> let <cursor_c> = 0
189
- * <i> <outerArr>.forEach((<outerParam>) => {
190
+ * <i> <outerArr>.forEach((<outerParam>[, <outerIdx>]) => {
190
191
  * <i> <outerPreludeStatements*>
191
- * <i> <innerArr>.forEach((<innerParam>) => {
192
+ * <i> <innerArr>.forEach((<innerParam>[, <innerIdx>]) => {
192
193
  * <i> <innerPreludeStatements*>
193
194
  * <i> const <compEl_c> = <scopes_c>[<cursor_c>++] // per comp
194
195
  * <i> if (<compEl_c>) initChild('<name>', <compEl_c>, <props>)
@@ -206,9 +207,11 @@ function emitComponentRootedInnerLoop(lines: string[], plan: ComponentRootedInne
206
207
  containerVar,
207
208
  outerArrayExpr,
208
209
  outerParam,
210
+ outerIndexParam,
209
211
  outerPreludeStatements,
210
212
  innerArrayExpr,
211
213
  innerParam,
214
+ innerIndexParam,
212
215
  innerPreludeStatements,
213
216
  depth,
214
217
  comps,
@@ -226,11 +229,13 @@ function emitComponentRootedInnerLoop(lines: string[], plan: ComponentRootedInne
226
229
  lines.push(` const ${scopesVar(i)} = qsaChildScopes(${containerVar}, ${comp.selector})`)
227
230
  lines.push(` let ${cursorVar(i)} = 0`)
228
231
  })
229
- lines.push(` ${outerArrayExpr}.forEach((${outerParam}) => {`)
232
+ // Declared index names are appended to the forEach heads (#2231);
233
+ // index-less loops keep the bare single-param head byte-identical.
234
+ lines.push(` ${outerArrayExpr}.forEach((${outerParam}${outerIndexParam ? `, ${outerIndexParam}` : ''}) => {`)
230
235
  for (const stmt of outerPreludeStatements) {
231
236
  lines.push(` ${stmt}`)
232
237
  }
233
- lines.push(` ${innerArrayExpr}.forEach((${innerParam}) => {`)
238
+ lines.push(` ${innerArrayExpr}.forEach((${innerParam}${innerIndexParam ? `, ${innerIndexParam}` : ''}) => {`)
234
239
  for (const stmt of innerPreludeStatements) {
235
240
  lines.push(` ${stmt}`)
236
241
  }