@barefootjs/jsx 0.5.2 → 0.5.3

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 (37) hide show
  1. package/dist/combine-client-js.d.ts.map +1 -1
  2. package/dist/index.js +176 -51
  3. package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
  4. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  7. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/imports.d.ts +2 -2
  11. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  13. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +26 -4
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/utils.d.ts +19 -1
  17. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  20. package/src/__tests__/child-components-in-map.test.ts +333 -0
  21. package/src/__tests__/combine-client-js.test.ts +47 -0
  22. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  23. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  24. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  25. package/src/combine-client-js.ts +66 -22
  26. package/src/ir-to-client-js/collect-elements.ts +170 -32
  27. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  28. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  29. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  30. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  31. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  32. package/src/ir-to-client-js/html-template.ts +82 -10
  33. package/src/ir-to-client-js/imports.ts +1 -1
  34. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  35. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  36. package/src/ir-to-client-js/types.ts +27 -4
  37. package/src/ir-to-client-js/utils.ts +41 -1
@@ -3,55 +3,170 @@
3
3
  */
4
4
 
5
5
  import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp, pickAttrMetaFromIR } from '../types'
6
- import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, NestedLoop } from './types'
6
+ import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr } from './html-template'
10
10
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling'
11
11
  import { walkIR, stopAt } from './walker'
12
+ import { buildLoopChainExpr } from '../loop-chain'
12
13
 
13
- /** Check if an IR node produces a DOM child element (for sibling offset counting). */
14
- function producesDomChild(node: IRNode): boolean {
14
+ /** Expressions that render nothing (0 DOM nodes) `&&` / `?:` empty branches. */
15
+ const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
16
+
17
+ /**
18
+ * Number of *element* children a node contributes to its parent's `.children`
19
+ * run — the collection that `container.children[idx]` indexes and that event
20
+ * delegation's `Array.from(container.children).indexOf(...)` walks. `.children`
21
+ * is element-only, so text / comment nodes never count.
22
+ *
23
+ * Returns a folded integer when the count is statically known, a JS expression
24
+ * string when it depends on runtime state, or `null` when the element count is
25
+ * statically undecidable (the caller then falls back to the legacy count):
26
+ * - element / component / provider / async → `1` (one root element)
27
+ * - text / empty-render expression (`null`/`false`/…) → `0`
28
+ * - plain loop → `(arr).length`; per-item-conditional / flatMap loop → `null`
29
+ * (renders a runtime-variable count, not `array.length`) (#1693)
30
+ * - conditional → fold to a number when both branches match, else
31
+ * `(cond ? t : f)`; `null` when a branch is undecidable (e.g. the `??`/`||`
32
+ * left operand, a bare expression that may render an element OR text)
33
+ * - fragment → sum of its children (transparent wrapper)
34
+ * - bare expression / slot / everything else → `null` (undecidable)
35
+ */
36
+ function domElementCount(node: IRNode): number | string | null {
37
+ switch (node.type) {
38
+ case 'element':
39
+ case 'component':
40
+ case 'provider':
41
+ case 'async':
42
+ return 1
43
+ case 'text':
44
+ return 0
45
+ case 'expression':
46
+ // `&&` / `?:` empty branches (`null`, `false`, …) render nothing; any
47
+ // other expression may resolve to an element or to text — undecidable.
48
+ return EMPTY_RENDER_EXPRS.has(node.expr.trim()) ? 0 : null
49
+ case 'loop':
50
+ // A per-item-conditional body (#1665) or flatMap renders a
51
+ // runtime-variable element count per item, not `array.length`.
52
+ if (node.bodyIsItemConditional || node.method === 'flatMap') return null
53
+ return `(${buildLoopChainExpr({
54
+ base: node.array,
55
+ sortComparator: node.sortComparator,
56
+ filterPredicate: node.filterPredicate,
57
+ chainOrder: node.chainOrder,
58
+ })}).length`
59
+ case 'conditional': {
60
+ const t = domElementCount(node.whenTrue)
61
+ const f = domElementCount(node.whenFalse)
62
+ if (t === null || f === null) return null
63
+ if (typeof t === 'number' && typeof f === 'number' && t === f) return t
64
+ // Active branch chosen at runtime — reuse the raw `condition`, the exact
65
+ // form `insert()` evaluates in the same init scope.
66
+ return `(${node.condition} ? ${t} : ${f})`
67
+ }
68
+ case 'fragment':
69
+ return sumElementCounts(node.children)
70
+ default:
71
+ // slot / if-statement: element count not statically known.
72
+ return null
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Sum `domElementCount` over a run of nodes, folding the static part. Returns
78
+ * `null` if any child's count is undecidable — the whole run is then unknown.
79
+ */
80
+ function sumElementCounts(nodes: readonly IRNode[]): number | string | null {
81
+ let staticCount = 0
82
+ const dynamic: string[] = []
83
+ for (const n of nodes) {
84
+ const c = domElementCount(n)
85
+ if (c === null) return null
86
+ if (typeof c === 'number') staticCount += c
87
+ else dynamic.push(c)
88
+ }
89
+ if (dynamic.length === 0) return staticCount
90
+ const parts = staticCount > 0 ? [String(staticCount), ...dynamic] : dynamic
91
+ return parts.length === 1 ? parts[0] : `(${parts.join(' + ')})`
92
+ }
93
+
94
+ /**
95
+ * Pre-#1693 element-count heuristic, used as the fallback for nodes whose count
96
+ * `domElementCount` cannot decide. Mirrors the old `producesDomChild` exactly,
97
+ * so an undecidable sibling contributes precisely what it did before this fix —
98
+ * guaranteeing no regression on shapes the new counting can't improve (a bare
99
+ * expression, a `??`/`||` fallback, a per-item-conditional loop).
100
+ */
101
+ function legacyElementCount(node: IRNode): number {
15
102
  return node.type === 'element' || node.type === 'component' || node.type === 'provider'
16
103
  || node.type === 'async'
17
104
  || node.type === 'text' || (node.type === 'expression' && !node.reactive)
18
105
  || node.type === 'conditional'
106
+ ? 1
107
+ : 0
19
108
  }
20
109
 
21
110
  /**
22
- * Pre-pass: for every loop node in the IR tree, record the number of non-loop
23
- * DOM siblings that appear before it in its parent container. Read when
24
- * constructing TopLevelLoop and NestedLoop so the client JS can offset
25
- * children[idx] access past statically-rendered siblings.
111
+ * Pre-pass: for every loop node in the IR tree, record the sibling nodes that
112
+ * appear before it in its parent container. Read when constructing
113
+ * TopLevelLoop and NestedLoop so the client JS can offset children[idx]
114
+ * access past everything rendered ahead of the loop's items.
26
115
  *
27
116
  * Counting must happen for every container whose children render as a
28
117
  * contiguous run of DOM siblings into the same parent — not just `element`.
29
118
  * A loop nested directly inside a component (`<Wrapper><span/>{xs.map(...)}`
30
119
  * </Wrapper>`), fragment, provider, or async boundary has its preceding
31
- * static sibling rendered as a sibling of the loop's items too, so
32
- * `children[idx]` access is shifted exactly as it is under an element parent
33
- * (#1688). Before this, a static sibling before a `.map()` inside a
34
- * (self-portaling) component dropped the first item's nested child component
35
- * during hydration because the offset was silently zero.
120
+ * siblings rendered as siblings of the loop's items too, so `children[idx]`
121
+ * access is shifted exactly as it is under an element parent (#1688).
122
+ *
123
+ * Transparent containers (fragment / provider / async) render no DOM element
124
+ * wrapper, so their children are siblings in the nearest ancestor element
125
+ * not in a container of their own. `recordRun` therefore threads ONE
126
+ * preceding-sibling accumulator through them, so a loop inside a fragment sees
127
+ * the parent element's earlier siblings too, not just the fragment's own
128
+ * children (#1699). `<Box><hr/><hr/><>{xs.map(...)}</></Box>` must offset the
129
+ * items past both `<hr/>`s.
130
+ *
131
+ * The siblings are stored raw; `resolveLoopOffset` turns each into its element
132
+ * count via `domElementCount`. That generalisation closes the #1688 follow-up
133
+ * (#1693): a preceding `.map()` contributes `array.length` and a preceding
134
+ * conditional contributes a `(cond ? … : …)` term, both resolved at runtime —
135
+ * a static-only count resolved later groups' nested children against the wrong
136
+ * `children[idx]`, leaving them inert after hydration.
36
137
  *
37
138
  * Computed once up front (instead of during collection) so the offset data
38
139
  * lives in an explicit value rather than a module-level WeakMap mutated by
39
140
  * two separate traversals.
40
141
  */
41
- export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
42
- const offsets = new Map<IRLoop, number>()
43
- const recordChildren = (children: IRNode[]): void => {
44
- let nonLoopCount = 0
142
+ export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, IRNode[]> {
143
+ const offsets = new Map<IRLoop, IRNode[]>()
144
+ // Walk a flat DOM run, flattening transparent containers inline so their
145
+ // children join the same preceding-sibling accumulator.
146
+ const recordRun = (children: IRNode[], preceding: IRNode[]): void => {
45
147
  for (const child of children) {
46
148
  if (child.type === 'loop') {
47
- if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
48
- } else if (producesDomChild(child)) {
49
- nonLoopCount++
149
+ // Record the preceding run only when something precedes this loop (a
150
+ // leading loop keeps bare `children[idx]`). `!offsets.has`: the
151
+ // enclosing run records the loop first, in pre-order, with the full
152
+ // preceding context; a later standalone visit of the transparent
153
+ // wrapper (still descended for loops that sit *directly* in a root /
154
+ // loop-body / branch fragment) must not overwrite it with a shorter
155
+ // run.
156
+ if (preceding.length > 0 && !offsets.has(child)) {
157
+ offsets.set(child, [...preceding])
158
+ }
159
+ preceding.push(child)
160
+ } else if (child.type === 'fragment' || child.type === 'provider' || child.type === 'async') {
161
+ // Transparent: no element wrapper — its children render into this run.
162
+ recordRun(child.children, preceding)
163
+ } else {
164
+ preceding.push(child)
50
165
  }
51
166
  }
52
167
  }
53
168
  const containerVisit = ({ node, descend }: { node: { children: IRNode[] }; descend: () => void }): void => {
54
- recordChildren(node.children)
169
+ recordRun(node.children, [])
55
170
  descend()
56
171
  }
57
172
  walkIR(root, null, {
@@ -68,6 +183,29 @@ export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
68
183
  return offsets
69
184
  }
70
185
 
186
+ /**
187
+ * Resolve a loop's preceding-sibling run into the `LoopOffset` value object
188
+ * stored on `TopLevelLoop` / `NestedLoop`: the folded static element count
189
+ * plus one dynamic term (`(arr).length`, `(cond ? … : …)`) per sibling whose
190
+ * count is only known at runtime. Siblings whose count is statically
191
+ * undecidable fall back to `legacyElementCount` (the pre-#1693 behaviour).
192
+ * Returns `undefined` when nothing precedes the loop (or only non-element
193
+ * nodes do), so the loop keeps bare `children[idx]`.
194
+ */
195
+ function resolveLoopOffset(preceding: IRNode[] | undefined): LoopOffset | undefined {
196
+ if (!preceding || preceding.length === 0) return undefined
197
+ let staticCount = 0
198
+ const dynamicTerms: string[] = []
199
+ for (const node of preceding) {
200
+ const c = domElementCount(node)
201
+ if (c === null) staticCount += legacyElementCount(node)
202
+ else if (typeof c === 'number') staticCount += c
203
+ else dynamicTerms.push(c)
204
+ }
205
+ if (staticCount === 0 && dynamicTerms.length === 0) return undefined
206
+ return { staticCount, dynamicTerms }
207
+ }
208
+
71
209
  /**
72
210
  * Options controlling `collectInnerLoops` traversal and payload collection.
73
211
  *
@@ -129,7 +267,7 @@ export const branchInnerLoopOptions: CollectInnerLoopsOptions = {
129
267
  */
130
268
  export function collectInnerLoops(
131
269
  nodes: IRNode[],
132
- siblingOffsets: Map<IRLoop, number>,
270
+ siblingOffsets: Map<IRLoop, IRNode[]>,
133
271
  outerLoopParam?: string,
134
272
  ctx?: ClientJsContext,
135
273
  options?: CollectInnerLoopsOptions,
@@ -258,7 +396,7 @@ export function collectInnerLoops(
258
396
  refsOuterParam: refsOuter,
259
397
  childComponents,
260
398
  insideConditional: !flat && scope.insideCond ? true : undefined,
261
- siblingOffset: flat ? undefined : (siblingOffsets.get(n) || undefined),
399
+ offset: flat ? undefined : resolveLoopOffset(siblingOffsets.get(n)),
262
400
  bindings,
263
401
  })
264
402
  // Branch-mode callers handle deeper nesting via their own collection paths.
@@ -286,7 +424,7 @@ export function collectInnerLoops(
286
424
  */
287
425
  function decideLoopRendering(
288
426
  loop: IRLoop,
289
- siblingOffsets: Map<IRLoop, number>,
427
+ siblingOffsets: Map<IRLoop, IRNode[]>,
290
428
  ctx: ClientJsContext | undefined,
291
429
  ): { useElementReconciliation: boolean; innerLoops: NestedLoop[] | undefined } {
292
430
  const hasNestedComps = (loop.nestedComponents?.length ?? 0) > 0
@@ -440,7 +578,7 @@ function buildBranchChildComponents(
440
578
  export function collectElements(
441
579
  node: IRNode,
442
580
  ctx: ClientJsContext,
443
- siblingOffsets: Map<IRLoop, number>,
581
+ siblingOffsets: Map<IRLoop, IRNode[]>,
444
582
  insideConditional = false,
445
583
  ): void {
446
584
  walkIR<boolean>(node, insideConditional, {
@@ -595,7 +733,7 @@ export function collectElements(
595
733
  isStaticArray: l.isStaticArray,
596
734
  useElementReconciliation,
597
735
  innerLoops: (useElementReconciliation || (l.isStaticArray && innerLoops?.length)) ? innerLoops : undefined,
598
- siblingOffset: siblingOffsets.get(l) || undefined,
736
+ offset: resolveLoopOffset(siblingOffsets.get(l)),
599
737
  filterPredicate: l.filterPredicate ? {
600
738
  param: l.filterPredicate.param,
601
739
  raw: l.filterPredicate.raw,
@@ -854,7 +992,7 @@ function collectBranchTextEffects(node: IRNode): ConditionalBranchTextEffect[] {
854
992
  function collectBranchLoops(
855
993
  node: IRNode,
856
994
  ctx: ClientJsContext | undefined,
857
- siblingOffsets: Map<IRLoop, number>,
995
+ siblingOffsets: Map<IRLoop, IRNode[]>,
858
996
  ): BranchLoop[] {
859
997
  const loops: BranchLoop[] = []
860
998
  const restNames = ctx ? buildRestSpreadNames(ctx) : undefined
@@ -953,7 +1091,7 @@ function collectBranchLoops(
953
1091
  function buildConditionalMetadata(
954
1092
  node: IRNode & { type: 'conditional' },
955
1093
  ctx: ClientJsContext,
956
- siblingOffsets: Map<IRLoop, number>,
1094
+ siblingOffsets: Map<IRLoop, IRNode[]>,
957
1095
  ): ConditionalElement {
958
1096
  const restNames = buildRestSpreadNames(ctx)
959
1097
  // Use loopDepth=-1 so the first loop encountered inside the branch emits
@@ -983,7 +1121,7 @@ function buildConditionalMetadata(
983
1121
  function summarizeBranch(
984
1122
  node: IRNode,
985
1123
  ctx: ClientJsContext,
986
- siblingOffsets: Map<IRLoop, number>,
1124
+ siblingOffsets: Map<IRLoop, IRNode[]>,
987
1125
  ): import('./types').BranchSummary {
988
1126
  return {
989
1127
  events: collectConditionalBranchEvents(node),
@@ -1003,7 +1141,7 @@ function summarizeBranch(
1003
1141
  function collectBranchConditionals(
1004
1142
  node: IRNode,
1005
1143
  ctx: ClientJsContext,
1006
- siblingOffsets: Map<IRLoop, number>,
1144
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1007
1145
  ): ConditionalElement[] {
1008
1146
  const result: ConditionalElement[] = []
1009
1147
  walkIR(node, null, {
@@ -1051,7 +1189,7 @@ function collectBranchConditionals(
1051
1189
  export function collectLoopChildBindings(
1052
1190
  children: readonly IRNode[],
1053
1191
  ctx: ClientJsContext,
1054
- siblingOffsets: Map<IRLoop, number>,
1192
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1055
1193
  loopParam: string,
1056
1194
  loopParamBindings: readonly import('../types').LoopParamBinding[] | undefined,
1057
1195
  ): LoopChildBindings {
@@ -1069,7 +1207,7 @@ export function collectLoopChildBindings(
1069
1207
  export function collectLoopChildConditionals(
1070
1208
  node: IRNode,
1071
1209
  ctx: ClientJsContext,
1072
- siblingOffsets: Map<IRLoop, number>,
1210
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1073
1211
  loopParam?: string,
1074
1212
  loopParamBindings?: readonly import('../types').LoopParamBinding[],
1075
1213
  ): LoopChildConditional[] {
@@ -1144,7 +1282,7 @@ export function collectLoopChildConditionals(
1144
1282
  function summarizeLoopChildBranch(
1145
1283
  node: IRNode,
1146
1284
  ctx: ClientJsContext,
1147
- siblingOffsets: Map<IRLoop, number>,
1285
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1148
1286
  loopParam?: string,
1149
1287
  loopParamBindings?: readonly import('../types').LoopParamBinding[],
1150
1288
  ): LoopChildBranchSummary {
@@ -69,7 +69,7 @@ export function buildStaticArrayDelegationPlan(elem: TopLevelLoop): EventDelegat
69
69
  arrayExpr: buildChainedArrayExpr(elem),
70
70
  param: elem.param,
71
71
  mapPreamble: elem.mapPreamble ?? null,
72
- siblingOffset: elem.siblingOffset ?? null,
72
+ offset: elem.offset ?? null,
73
73
  },
74
74
  }
75
75
  }
@@ -25,6 +25,7 @@ import type {
25
25
  } from '../../types'
26
26
  import {
27
27
  buildChainedArrayExpr,
28
+ buildLoopChildIndexExpr,
28
29
  setIntersects,
29
30
  varSlotId,
30
31
  wrapLoopParamAsAccessor,
@@ -129,7 +130,7 @@ export function buildStaticLoopPlan(elem: TopLevelLoop, unsafeLocalNames: Set<st
129
130
  }
130
131
 
131
132
  const indexParam = elem.index || '__idx'
132
- const childIndexExpr = elem.siblingOffset ? `${indexParam} + ${elem.siblingOffset}` : indexParam
133
+ const childIndexExpr = buildLoopChildIndexExpr(indexParam, elem.offset)
133
134
 
134
135
  return {
135
136
  kind: 'static',
@@ -5,7 +5,7 @@
5
5
  * container variable and the per-event item-lookup strategy.
6
6
  */
7
7
 
8
- import type { LoopChildEvent, TopLevelLoop } from '../../types'
8
+ import type { LoopChildEvent, LoopOffset, TopLevelLoop } from '../../types'
9
9
 
10
10
  /**
11
11
  * Plan for a loop's event-delegation block. Covers three legacy emitters:
@@ -72,6 +72,11 @@ export interface StaticIndexItemLookup {
72
72
  arrayExpr: string
73
73
  param: string
74
74
  mapPreamble: string | null
75
- /** Sibling offset for `__idx` arithmetic; `null` when no offset. */
76
- siblingOffset: number | null
75
+ /**
76
+ * Offset of the loop's items past its preceding container siblings. Its
77
+ * terms are subtracted from the DOM child index to recover the array index,
78
+ * so later `static + .map()` groups resolve the correct item (#1693).
79
+ * `null` when nothing precedes the loop.
80
+ */
81
+ offset: LoopOffset | null
77
82
  }
@@ -32,7 +32,7 @@
32
32
  * sentinel before destructuring (#951 TDZ-safe).
33
33
  */
34
34
 
35
- import { toDomEventName, varSlotId, substituteLoopBindings, DATA_KEY, keyAttrName } from '../../utils'
35
+ import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils'
36
36
  import type {
37
37
  EventDelegationPlan,
38
38
  KeyedItemLookup,
@@ -187,11 +187,11 @@ function emitStaticIndexLookup(
187
187
  lookup: StaticIndexItemLookup,
188
188
  containerVar: string,
189
189
  ): void {
190
- const { arrayExpr, param, mapPreamble, siblingOffset } = lookup
190
+ const { arrayExpr, param, mapPreamble, offset } = lookup
191
191
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
192
192
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
193
193
  ls.push(` if (__el.parentElement === ${containerVar}) {`)
194
- const idxOffset = siblingOffset ? ` - ${siblingOffset}` : ''
194
+ const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
195
195
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
196
196
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
197
197
  if (mapPreamble) ls.push(` ${mapPreamble}`)
@@ -17,6 +17,15 @@ import { createTemplateAwareStringProtector } from './html-template'
17
17
  */
18
18
  export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
19
19
  const htmlName = toHtmlAttrName(attrName)
20
+ if (attrName === 'dangerouslySetInnerHTML' || htmlName === 'dangerouslySetInnerHTML') {
21
+ // `{ __html }` is not an attribute — it replaces the element's content.
22
+ // Assign `innerHTML` (raw, intentional escape hatch) to mirror the SSR
23
+ // adapters' native `dangerouslySetInnerHTML` handling instead of
24
+ // stringifying the object into a bogus attribute.
25
+ return [
26
+ `{ const __v = ${expression}; ${target}.innerHTML = __v != null && __v.__html != null ? String(__v.__html) : '' }`,
27
+ ]
28
+ }
20
29
  if (htmlName === 'style') {
21
30
  return [
22
31
  `{ const __v = styleToCss(${expression}); if (__v != null) ${target}.setAttribute('style', __v); else ${target}.removeAttribute('style') }`,
@@ -192,11 +192,16 @@ const UNSAFE_TEMPLATE_EXPR = 'undefined'
192
192
  * @param attr - Attribute metadata (only presenceOrUndefined flag is used)
193
193
  */
194
194
  function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined?: boolean): string {
195
+ // `dangerouslySetInnerHTML={{ __html }}` is not an attribute — its value
196
+ // becomes the element's raw innerHTML (emitted as element content). Never
197
+ // serialise the `{ __html }` object into a `dangerouslySetInnerHTML="…"`
198
+ // attribute.
199
+ if (attrName === 'dangerouslySetInnerHTML') return ''
195
200
  if (isBooleanAttr(attrName) || presenceOrUndefined) {
196
201
  return `\${${valExpr} ? '${attrName}' : ''}`
197
202
  }
198
203
  if (attrName === 'style') {
199
- return `\${((v) => v != null ? 'style="' + v + '"' : '')(styleToCss(${valExpr}))}`
204
+ return `\${((v) => v != null ? 'style="' + ${escapeAttrValueExpr('v')} + '"' : '')(styleToCss(${valExpr}))}`
200
205
  }
201
206
  // `data-key` / `data-key-N` is a reconciliation contract — every loop item
202
207
  // must carry one. Emit unconditionally; if the user passes `key={undefined}`
@@ -205,7 +210,60 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
205
210
  if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
206
211
  return `${attrName}="\${${valExpr}}"`
207
212
  }
208
- return `\${(${valExpr}) != null ? '${attrName}="' + (${valExpr}) + '"' : ''}`
213
+ return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
214
+ }
215
+
216
+ /**
217
+ * Build a runtime expression that HTML-escapes an interpolated attribute
218
+ * value, matching the SSR adapters' attribute escaping (Hono escapes
219
+ * `& " ' < >`) via the `escapeAttr` runtime helper. The client template
220
+ * assembles an HTML string inserted via `innerHTML`, so an unescaped `"`
221
+ * / `<` / `>` in a value — e.g. UnoCSS arbitrary variants like
222
+ * `[class*="size-"]` or `has-[>svg]` — corrupts attribute parsing (and
223
+ * diverges from the SSR-rendered bytes). Escaping at interpolation time
224
+ * is the only correct layer: a post-assembly pass can't tell a delimiter
225
+ * `"` from a value `"`.
226
+ */
227
+ function escapeAttrValueExpr(valExpr: string): string {
228
+ return `escapeAttr(${valExpr})`
229
+ }
230
+
231
+ /**
232
+ * Build a runtime expression that HTML-escapes an interpolated **text
233
+ * content** slot, via the `escapeText` runtime helper. Only the
234
+ * `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
235
+ * runtime treats whatever sits between the markers as the slot's text, so
236
+ * a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
237
+ * to parse correctly under `innerHTML` and to match the SSR-rendered
238
+ * bytes. Bare `${...}` interpolations — `{children}` passthrough and
239
+ * `renderChild(...)` output — are pre-rendered HTML and must NOT be
240
+ * escaped, so this is applied only at the four text-marker emit sites.
241
+ * Hono escapes text content with the same set as attribute values
242
+ * (`& " ' < >`), so `escapeText` delegates to the same operation.
243
+ */
244
+ function escapeTextSlotExpr(innerExpr: string): string {
245
+ return `escapeText(${innerExpr})`
246
+ }
247
+
248
+ /**
249
+ * `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
250
+ * raw innerHTML — the intentional, React-style escape hatch. Returns the
251
+ * raw-content template expression to use *instead of* the element's normal
252
+ * children (emitted UNescaped by design, mirroring the SSR adapters'
253
+ * native handling), or `null` when the element carries no such attribute.
254
+ * The attribute itself is suppressed in `templateAttrExpr`, and the
255
+ * matching reactive update is emitted by `emitAttrUpdate` (assigns
256
+ * `innerHTML`). `toExpr` is the walker's value transform (`wrapExpr` /
257
+ * `transformExpr`) so the `{ __html }` object is lowered the same way an
258
+ * attribute value would be.
259
+ */
260
+ function dangerouslyHtmlChildren(
261
+ attrs: ReadonlyArray<IRAttribute>,
262
+ toExpr: (v: { expr: string; templateExpr?: string }) => string,
263
+ ): string | null {
264
+ const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
265
+ if (!attr || attr.value.kind !== 'expression') return null
266
+ return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
209
267
  }
210
268
 
211
269
  /**
@@ -322,6 +380,12 @@ export interface MergeContext {
322
380
  function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
323
381
  if (ctx.honorClientOnly && a.clientOnly) return false
324
382
  if (a.name === 'key') return false
383
+ // `dangerouslySetInnerHTML` is not an attribute — its `{ __html }` value
384
+ // becomes the element's raw innerHTML (emitted as content, set via
385
+ // `innerHTML` in init). Keep it out of the `spreadAttrs({...})` merge so
386
+ // it isn't serialised back into a bogus `dangerouslySetInnerHTML="…"`
387
+ // attribute when the element also carries a spread.
388
+ if (a.name === 'dangerouslySetInnerHTML') return false
325
389
  const v = a.value
326
390
  if (v.kind === 'jsx-children') return false
327
391
  if (v.kind === 'boolean-shorthand') return false
@@ -476,7 +540,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
476
540
 
477
541
  const attrs = attrParts.join(' ')
478
542
  const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
479
- const children = node.children.map(childrenRecurse).join('')
543
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
480
544
 
481
545
  // Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
482
546
  if (children || !VOID_ELEMENTS.has(node.tag)) {
@@ -491,7 +555,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
491
555
  case 'expression':
492
556
  if (node.expr === 'null' || node.expr === 'undefined') return ''
493
557
  if (node.slotId) {
494
- return `<!--bf:${node.slotId}-->\${${wrapInterpolation(wrapExpr(node.expr))}}<!--/-->`
558
+ const inner = wrapInterpolation(wrapExpr(node.expr))
559
+ // In branch-slot context `wrapInterpolation` routes the value
560
+ // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
561
+ // for live `Node` values (spliced back by `insert()`). Escaping
562
+ // would corrupt those markers and drop slotted content (#1694
563
+ // regression). `__bfSlot` owns coercion of its own value, so the
564
+ // text-escape applies only to the non-slot (plain text) form.
565
+ const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
566
+ return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
495
567
  }
496
568
  return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
497
569
 
@@ -656,7 +728,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
656
728
  }
657
729
 
658
730
  const attrs = attrParts.join(' ')
659
- const children = node.children.map(recurse).join('')
731
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(recurse).join('')
660
732
 
661
733
  if (children || !VOID_ELEMENTS.has(node.tag)) {
662
734
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -670,7 +742,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
670
742
  case 'expression':
671
743
  if (node.expr === 'null' || node.expr === 'undefined') return ''
672
744
  if (node.slotId) {
673
- return `<!--bf:${node.slotId}-->\${${wrapExpr(node.expr)}}<!--/-->`
745
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
674
746
  }
675
747
  return `\${${wrapExpr(node.expr)}}`
676
748
 
@@ -1041,7 +1113,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1041
1113
  }
1042
1114
 
1043
1115
  const attrs = attrParts.join(' ')
1044
- const children = node.children.map(childrenRecurse).join('')
1116
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1045
1117
 
1046
1118
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1047
1119
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1055,7 +1127,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1055
1127
  case 'expression':
1056
1128
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1057
1129
  if (node.slotId) {
1058
- return `<!--bf:${node.slotId}-->\${${transformExpr(node.expr, node.templateExpr)}}<!--/-->`
1130
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
1059
1131
  }
1060
1132
  return `\${${transformExpr(node.expr, node.templateExpr)}}`
1061
1133
 
@@ -1417,7 +1489,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1417
1489
  }
1418
1490
 
1419
1491
  const attrs = attrParts.join(' ')
1420
- const children = node.children.map(childrenRecurse).join('')
1492
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1421
1493
 
1422
1494
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1423
1495
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1440,7 +1512,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1440
1512
  // an empty placeholder instead.
1441
1513
  const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
1442
1514
  if (node.slotId) {
1443
- return `<!--bf:${node.slotId}-->\${${expr}}<!--/-->`
1515
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
1444
1516
  }
1445
1517
  return `\${${expr}}`
1446
1518
  }
@@ -11,7 +11,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
11
11
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
12
12
  'createPortal',
13
13
  'provideContext', 'createContext', 'useContext',
14
- 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
14
+ 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
15
15
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
16
16
  ] as const
17
17
 
@@ -16,7 +16,7 @@
16
16
  import type { IRLoopChildComponent } from '../../types'
17
17
  import type { NestedLoop, TopLevelLoop } from '../types'
18
18
  import type { ClientJsContext } from '../types'
19
- import { quotePropName, varSlotId, attrValueToString } from '../utils'
19
+ import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils'
20
20
  import { irChildrenToJsExpr } from '../html-template'
21
21
  import { buildCompSelector } from '../control-flow/shared'
22
22
 
@@ -98,7 +98,7 @@ function buildOuterNestedPlan(
98
98
  arrayExpr: elem.array,
99
99
  param: elem.param,
100
100
  indexParam,
101
- offsetExpr: elem.siblingOffset ? `${indexParam} + ${elem.siblingOffset}` : indexParam,
101
+ offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
102
102
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
103
103
  propsExpr: buildStaticPropsExpr(comp.props),
104
104
  }
@@ -122,16 +122,12 @@ function buildInnerLoopNestedPlan(
122
122
  outerArrayExpr: elem.array,
123
123
  outerParam: elem.param,
124
124
  outerIndexParam,
125
- outerOffsetExpr: elem.siblingOffset
126
- ? `${outerIndexParam} + ${elem.siblingOffset}`
127
- : outerIndexParam,
125
+ outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
128
126
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
129
127
  innerContainerSlotId: innerLoop.containerSlotId ?? null,
130
128
  innerArrayExpr: innerLoop.array,
131
129
  innerParam: innerLoop.param,
132
- innerOffsetExpr: innerLoop.siblingOffset
133
- ? `__innerIdx + ${innerLoop.siblingOffset}`
134
- : '__innerIdx',
130
+ innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
135
131
  innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
136
132
  depth: innerLoop.depth,
137
133
  comps,