@barefootjs/jsx 0.18.4 → 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 (109) 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 +2 -2
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +2 -1
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +14 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1108 -307
  13. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  19. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  23. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  25. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  33. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  35. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/csr-substitute.d.ts +24 -0
  37. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  39. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/imports.d.ts +2 -2
  41. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  43. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  46. package/dist/ir-to-client-js/types.d.ts +30 -0
  47. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  48. package/dist/ir-to-client-js/utils.d.ts +25 -0
  49. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  50. package/dist/jsx-to-ir.d.ts.map +1 -1
  51. package/dist/signal-init-eval.d.ts +82 -0
  52. package/dist/signal-init-eval.d.ts.map +1 -0
  53. package/dist/static-literal.d.ts +45 -0
  54. package/dist/static-literal.d.ts.map +1 -0
  55. package/dist/types.d.ts +66 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  59. package/src/__tests__/create-selector.test.ts +110 -0
  60. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  61. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  62. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  63. package/src/__tests__/expression-parser.test.ts +38 -0
  64. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  65. package/src/__tests__/ir-walker.test.ts +1 -0
  66. package/src/__tests__/loop-bound-names.test.ts +102 -0
  67. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  68. package/src/__tests__/map-function-reference.test.ts +303 -0
  69. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  70. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  71. package/src/__tests__/signal-init-eval.test.ts +138 -0
  72. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  73. package/src/__tests__/static-literal.test.ts +126 -0
  74. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  75. package/src/adapters/dangerous-inner-html.ts +186 -0
  76. package/src/adapters/loop-bound-names.ts +80 -0
  77. package/src/adapters/parsed-expr-emitter.ts +19 -1
  78. package/src/analyzer.ts +6 -0
  79. package/src/expression-parser.ts +59 -25
  80. package/src/index.ts +19 -3
  81. package/src/ir-to-client-js/collect-elements.ts +18 -3
  82. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  83. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  84. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  86. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  88. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  89. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  90. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  92. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  93. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  94. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  95. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  96. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  97. package/src/ir-to-client-js/csr-substitute.ts +54 -1
  98. package/src/ir-to-client-js/html-template.ts +292 -13
  99. package/src/ir-to-client-js/imports.ts +1 -0
  100. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  101. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  102. package/src/ir-to-client-js/reactivity.ts +6 -0
  103. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  104. package/src/ir-to-client-js/types.ts +31 -0
  105. package/src/ir-to-client-js/utils.ts +34 -1
  106. package/src/jsx-to-ir.ts +322 -48
  107. package/src/signal-init-eval.ts +165 -0
  108. package/src/static-literal.ts +128 -0
  109. package/src/types.ts +68 -0
@@ -10,7 +10,7 @@ import { nameForRegistryRef } from './component-scope.ts'
10
10
  import { assertNever } from './walker.ts'
11
11
  import { buildSignalMemoEnv, csrSubstitute, applyPropsRewrite, type CsrEnv } from './csr-substitute.ts'
12
12
  import type { ClientJsContext } from './types.ts'
13
- import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE } from '@barefootjs/shared'
13
+ import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE, escapeHtml } from '@barefootjs/shared'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
15
 
16
16
  /**
@@ -145,6 +145,42 @@ function applyIterationShape(
145
145
  callbackParam: `(${node.param})`,
146
146
  }
147
147
  }
148
+ // `objectIteration` (#2168 object-entries-map): reconstruct the STATIC
149
+ // `Object.entries/keys/values(x)` call the compiler stripped at IR-build
150
+ // time (`isObjectIteratorCall`, `jsx-to-ir.ts`) — `arrayExpr` here is just
151
+ // `x` (the plain object), so the client, unlike a template adapter,
152
+ // re-wraps it in real JS to get the actual entries/keys/values array
153
+ // (this runs in a real JS engine, so no per-language lowering is needed).
154
+ //
155
+ // Unlike the array `iterationShape` case, the 'entries' ARRAY wrap does
156
+ // NOT require `node.index` — that field is only populated for a CLEAN
157
+ // 2-identifier destructure (`([word, n]) => …`); an elided/nested
158
+ // pattern (`([, cfg]) => …`) falls through to the generic `paramBindings`
159
+ // machinery instead (`node.param` stays the raw destructure TEXT, e.g.
160
+ // `"[, cfg]"`, which is already a syntactically valid callback param that
161
+ // correctly destructures a `[key, value]` pair — see the trailing
162
+ // fallback below). Only the ARRAY needs wrapping in that case; the
163
+ // callback param is unaffected either way.
164
+ if (node.objectIteration === 'entries') {
165
+ return {
166
+ array: `Object.entries(${arrayExpr})`,
167
+ callbackParam: node.index
168
+ ? `([${node.index}, ${node.param}])`
169
+ : `(${node.param}${indexParam})`,
170
+ }
171
+ }
172
+ if (node.objectIteration === 'keys') {
173
+ return {
174
+ array: `Object.keys(${arrayExpr})`,
175
+ callbackParam: `(${node.param})`,
176
+ }
177
+ }
178
+ if (node.objectIteration === 'values') {
179
+ return {
180
+ array: `Object.values(${arrayExpr})`,
181
+ callbackParam: `(${node.param})`,
182
+ }
183
+ }
148
184
  return { array: arrayExpr, callbackParam: `(${node.param}${indexParam})` }
149
185
  }
150
186
 
@@ -313,7 +349,7 @@ function renderTemplateAttrPart(
313
349
  case 'boolean-attr':
314
350
  return attrName
315
351
  case 'literal':
316
- return `${attrName}="${v.value}"`
352
+ return `${attrName}="${escapeHtml(v.value)}"`
317
353
  case 'expression': {
318
354
  const valExpr = wrap(v.expr)
319
355
  return templateAttrExpr(attrName, valExpr, v.presenceOrUndefined)
@@ -550,7 +586,9 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
550
586
  }
551
587
 
552
588
  case 'text':
553
- return node.value
589
+ // IRText carries the entity-DECODED value; this string is parsed
590
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
591
+ return escapeHtml(node.value)
554
592
 
555
593
  case 'expression':
556
594
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -757,7 +795,7 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
757
795
  const v = a.value
758
796
  switch (v.kind) {
759
797
  case 'literal':
760
- attrParts.push(`${toHtmlAttrName(a.name)}="${v.value}"`)
798
+ attrParts.push(`${toHtmlAttrName(a.name)}="${escapeHtml(v.value)}"`)
761
799
  break
762
800
  case 'boolean-attr':
763
801
  attrParts.push(toHtmlAttrName(a.name))
@@ -796,7 +834,9 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
796
834
  }
797
835
 
798
836
  case 'text':
799
- return node.value
837
+ // IRText carries the entity-DECODED value; this string is parsed
838
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
839
+ return escapeHtml(node.value)
800
840
 
801
841
  case 'expression':
802
842
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -835,6 +875,196 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
835
875
  }
836
876
  }
837
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
+
838
1068
  /**
839
1069
  * Generate an HTML template for composite element reconciliation.
840
1070
  * Identical to irToHtmlTemplate except component nodes become placeholder
@@ -870,7 +1100,9 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
870
1100
  }
871
1101
 
872
1102
  case 'text':
873
- return node.value
1103
+ // IRText carries the entity-DECODED value; this string is parsed
1104
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
1105
+ return escapeHtml(node.value)
874
1106
 
875
1107
  case 'expression':
876
1108
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -1093,6 +1325,20 @@ export interface TemplateOptions {
1093
1325
  csrEnv?: CsrEnv
1094
1326
  insideLoop?: boolean
1095
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>
1096
1342
  /** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
1097
1343
  inHoistedChildren?: boolean
1098
1344
  /**
@@ -1228,7 +1474,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1228
1474
  return templateAttrExpr(keyName, transformExpr(tmplStr))
1229
1475
  }
1230
1476
  case 'literal':
1231
- return `${keyName}="${v.value}"`
1477
+ return `${keyName}="${escapeHtml(v.value)}"`
1232
1478
  default:
1233
1479
  return ''
1234
1480
  }
@@ -1238,7 +1484,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1238
1484
  case 'boolean-attr':
1239
1485
  return attrName
1240
1486
  case 'literal':
1241
- return `${attrName}="${v.value}"`
1487
+ return `${attrName}="${escapeHtml(v.value)}"`
1242
1488
  case 'expression':
1243
1489
  return templateAttrExpr(attrName, transformExpr(v.expr, v.templateExpr), v.presenceOrUndefined)
1244
1490
  case 'template': {
@@ -1269,7 +1515,9 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1269
1515
  }
1270
1516
 
1271
1517
  case 'text':
1272
- return node.value
1518
+ // IRText carries the entity-DECODED value; this string is parsed
1519
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
1520
+ return escapeHtml(node.value)
1273
1521
 
1274
1522
  case 'expression':
1275
1523
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -1806,7 +2054,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1806
2054
  case 'boolean-attr':
1807
2055
  return attrName
1808
2056
  case 'literal':
1809
- return `${attrName}="${v.value}"`
2057
+ return `${attrName}="${escapeHtml(v.value)}"`
1810
2058
  case 'expression':
1811
2059
  return templateAttrExpr(attrName, transformExpr(v.expr, v.templateExpr), v.presenceOrUndefined)
1812
2060
  case 'template': {
@@ -1837,7 +2085,9 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1837
2085
  }
1838
2086
 
1839
2087
  case 'text':
1840
- return node.value
2088
+ // IRText carries the entity-DECODED value; this string is parsed
2089
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
2090
+ return escapeHtml(node.value)
1841
2091
 
1842
2092
  case 'expression':
1843
2093
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -1942,7 +2192,36 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1942
2192
  }
1943
2193
 
1944
2194
  case 'loop': {
1945
- 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('')
1946
2225
  // Whole-item conditional loops (#1665): prepend the per-item
1947
2226
  // `<!--bf-loop-i:KEY-->` anchor so `mapArrayAnchored` can track items
1948
2227
  // that render no element. Mirrors the `irToHtmlTemplate` loop case.
@@ -1976,7 +2255,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1976
2255
  if (node.flatMapCallback) {
1977
2256
  let body = node.flatMapCallback.templateBody ?? node.flatMapCallback.body
1978
2257
  for (const frag of node.flatMapCallback.fragments) {
1979
- const renderedIr = recurseInLoop(frag.ir)
2258
+ const renderedIr = recurseInLoopBody(frag.ir)
1980
2259
  body = body.replace(frag.placeholder, `\`${renderedIr}\``)
1981
2260
  }
1982
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
@@ -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
  }
@@ -22,6 +22,7 @@ import type {
22
22
  ImportInfo,
23
23
  } from '../types.ts'
24
24
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
25
+ import type { SkeletonSlotPaths } from './html-template.ts'
25
26
 
26
27
  export interface ClientJsContext {
27
28
  componentName: string
@@ -283,6 +284,16 @@ export interface LoopCore {
283
284
  * (#1448 Tier B). Threaded from `IRLoop.iterationShape`.
284
285
  */
285
286
  iterationShape?: 'entries' | 'keys'
287
+
288
+ /**
289
+ * Object iteration shape from the STATIC `Object.entries(x)` /
290
+ * `.keys(x)` / `.values(x)` call form (#2168 object-entries-map) —
291
+ * distinct from {@link iterationShape} for the same reason `IRLoop`'s
292
+ * own field is (see that field's docstring, `packages/jsx/src/types.ts`):
293
+ * `x` is a plain object, not an array. Threaded from
294
+ * `IRLoop.objectIteration`.
295
+ */
296
+ objectIteration?: 'entries' | 'keys' | 'values'
286
297
  }
287
298
 
288
299
  /**
@@ -394,6 +405,18 @@ export type ConditionalBranchConditional = ConditionalElement
394
405
  export interface NestedLoop extends LoopCore {
395
406
  kind: 'nested'
396
407
  depth: number // 1 for first nesting level, 2 for second, etc.
408
+ /**
409
+ * Index parameter of the inner `.map()` callback (e.g. `i` from
410
+ * `.map((item, i) => ...)`), or `null` when the callback doesn't declare
411
+ * one. Mirrors `TopLevelLoop.index` / `BranchLoop.index` — added by #2218
412
+ * so a nested loop's `key`/reactive-text/reactive-attr/template/event/ref
413
+ * expressions that reference the index can be bound instead of throwing
414
+ * `ReferenceError` at runtime. The renderItem body only receives the
415
+ * index as a synthetic `__innerIdx<uid>` positional param (to avoid
416
+ * cross-depth name collisions), so emitters must alias it under this
417
+ * name when referenced — see `nestedLoopReferencesIndex` in `shared.ts`.
418
+ */
419
+ index: string | null
397
420
  containerSlotId: string | null // Slot ID of the parent element containing the loop (for hydration)
398
421
  /** HTML template for a single inner loop item (for mapArray CSR rendering) */
399
422
  template?: string
@@ -519,6 +542,14 @@ export interface TopLevelLoop extends LoopCore {
519
542
  * proven covered by an effect).
520
543
  */
521
544
  skeletonTemplate?: string
545
+ /**
546
+ * Compile-time child-index paths for `skeletonTemplate`'s dynamic slots
547
+ * (perf, #2143), computed by `computeSkeletonSlotPaths` from the same IR
548
+ * tree. `undefined` means "resolve every slot via `qsa`/`$t` at runtime"
549
+ * (skeletonTemplate is still hoisted/cloned — only the runtime lookups on
550
+ * top of the fresh clone become the fallback rather than the path chain).
551
+ */
552
+ skeletonPaths?: SkeletonSlotPaths
522
553
  childEventHandlers: string[] // Bare-identifier event handler names (for the reachability graph)
523
554
  childComponent?: IRLoopChildComponent // For createComponent-based rendering
524
555
  nestedComponents?: IRLoopChildComponent[] // For nested components in loop bodies