@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.
- package/dist/combine-client-js.d.ts.map +1 -1
- package/dist/index.js +176 -51
- package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +26 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +19 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
- package/src/__tests__/child-components-in-map.test.ts +333 -0
- package/src/__tests__/combine-client-js.test.ts +47 -0
- package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
- package/src/__tests__/text-slot-escaping.test.ts +56 -0
- package/src/combine-client-js.ts +66 -22
- package/src/ir-to-client-js/collect-elements.ts +170 -32
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
- package/src/ir-to-client-js/emit-reactive.ts +9 -0
- package/src/ir-to-client-js/html-template.ts +82 -10
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
- package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
- package/src/ir-to-client-js/types.ts +27 -4
- 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
|
-
/**
|
|
14
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* (
|
|
35
|
-
*
|
|
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,
|
|
42
|
-
const offsets = new Map<IRLoop,
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
76
|
-
|
|
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,
|
|
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 =
|
|
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}="' +
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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,
|