@barefootjs/jsx 0.5.1 → 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/analyzer-context.d.ts +8 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/combine-client-js.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -65
- package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
- 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/dist/types.d.ts +6 -0
- package/dist/types.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 +376 -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/analyzer-context.ts +59 -13
- package/src/analyzer.ts +8 -0
- package/src/combine-client-js.ts +66 -22
- package/src/expression-parser.ts +16 -1
- package/src/index.ts +2 -0
- package/src/ir-to-client-js/collect-elements.ts +191 -34
- 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
- package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
- package/src/types.ts +6 -0
package/src/analyzer-context.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
CompilerError,
|
|
21
21
|
SourceLocation,
|
|
22
22
|
ParamInfo,
|
|
23
|
+
PropertyInfo,
|
|
23
24
|
ReactiveFactoryInfo,
|
|
24
25
|
} from './types'
|
|
25
26
|
import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types'
|
|
@@ -301,6 +302,46 @@ export function getSourceLocation(
|
|
|
301
302
|
// Type Helpers
|
|
302
303
|
// =============================================================================
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Extract structured {@link PropertyInfo} entries from the members of an object
|
|
307
|
+
* type literal or interface declaration. Shared so the type-literal branch of
|
|
308
|
+
* {@link typeNodeToTypeInfo} and interface-definition collection produce
|
|
309
|
+
* identical field shapes.
|
|
310
|
+
*/
|
|
311
|
+
export function membersToProperties(
|
|
312
|
+
members: ts.NodeArray<ts.TypeElement>,
|
|
313
|
+
sourceFile: ts.SourceFile
|
|
314
|
+
): PropertyInfo[] {
|
|
315
|
+
return members
|
|
316
|
+
.filter(ts.isPropertySignature)
|
|
317
|
+
.map((member) => ({
|
|
318
|
+
name: propertyNameText(member.name, sourceFile),
|
|
319
|
+
type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
|
|
320
|
+
kind: 'unknown' as const,
|
|
321
|
+
raw: 'unknown',
|
|
322
|
+
},
|
|
323
|
+
optional: !!member.questionToken,
|
|
324
|
+
readonly: !!member.modifiers?.some(
|
|
325
|
+
(m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
|
|
326
|
+
),
|
|
327
|
+
}))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The source-level name of an object/interface member. String- and numeric-
|
|
332
|
+
* literal keys are returned unquoted (`{ "id": ... }` → `id`), so consumers see
|
|
333
|
+
* the same name whether the key was written as an identifier or a string —
|
|
334
|
+
* `getText()` would otherwise keep the quotes.
|
|
335
|
+
*/
|
|
336
|
+
function propertyNameText(
|
|
337
|
+
name: ts.PropertyName | undefined,
|
|
338
|
+
sourceFile: ts.SourceFile
|
|
339
|
+
): string {
|
|
340
|
+
if (!name) return ''
|
|
341
|
+
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
|
|
342
|
+
return name.getText(sourceFile)
|
|
343
|
+
}
|
|
344
|
+
|
|
304
345
|
export function typeNodeToTypeInfo(
|
|
305
346
|
typeNode: ts.TypeNode | undefined,
|
|
306
347
|
sourceFile: ts.SourceFile
|
|
@@ -352,24 +393,29 @@ export function typeNodeToTypeInfo(
|
|
|
352
393
|
return {
|
|
353
394
|
kind: 'object',
|
|
354
395
|
raw,
|
|
355
|
-
properties: typeNode.members
|
|
356
|
-
.filter(ts.isPropertySignature)
|
|
357
|
-
.map((member) => ({
|
|
358
|
-
name: member.name?.getText(sourceFile) ?? '',
|
|
359
|
-
type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
|
|
360
|
-
kind: 'unknown',
|
|
361
|
-
raw: 'unknown',
|
|
362
|
-
},
|
|
363
|
-
optional: !!member.questionToken,
|
|
364
|
-
readonly: !!member.modifiers?.some(
|
|
365
|
-
(m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
|
|
366
|
-
),
|
|
367
|
-
})),
|
|
396
|
+
properties: membersToProperties(typeNode.members, sourceFile),
|
|
368
397
|
}
|
|
369
398
|
}
|
|
370
399
|
|
|
371
400
|
// Type reference (named type)
|
|
372
401
|
if (ts.isTypeReferenceNode(typeNode)) {
|
|
402
|
+
// Normalise the generic array forms `Array<T>` / `ReadonlyArray<T>` to the
|
|
403
|
+
// same `kind: 'array'` shape as `T[]`, so every consumer sees one array
|
|
404
|
+
// representation regardless of how the source spelled it.
|
|
405
|
+
const refName = ts.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : ''
|
|
406
|
+
if (
|
|
407
|
+
(refName === 'Array' || refName === 'ReadonlyArray') &&
|
|
408
|
+
typeNode.typeArguments?.length === 1
|
|
409
|
+
) {
|
|
410
|
+
return {
|
|
411
|
+
kind: 'array',
|
|
412
|
+
raw,
|
|
413
|
+
elementType: typeNodeToTypeInfo(typeNode.typeArguments[0], sourceFile) ?? {
|
|
414
|
+
kind: 'unknown',
|
|
415
|
+
raw: 'unknown',
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
373
419
|
return {
|
|
374
420
|
kind: 'interface',
|
|
375
421
|
raw,
|
package/src/analyzer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createAnalyzerContext,
|
|
17
17
|
getSourceLocation,
|
|
18
18
|
typeNodeToTypeInfo,
|
|
19
|
+
membersToProperties,
|
|
19
20
|
isComponentFunction,
|
|
20
21
|
isArrowComponentFunction,
|
|
21
22
|
} from './analyzer-context'
|
|
@@ -1782,6 +1783,7 @@ function collectInterfaceDefinition(
|
|
|
1782
1783
|
kind: 'interface',
|
|
1783
1784
|
name: node.name.text,
|
|
1784
1785
|
definition: node.getText(ctx.sourceFile),
|
|
1786
|
+
properties: membersToProperties(node.members, ctx.sourceFile),
|
|
1785
1787
|
loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
|
|
1786
1788
|
})
|
|
1787
1789
|
}
|
|
@@ -1790,10 +1792,16 @@ function collectTypeAliasDefinition(
|
|
|
1790
1792
|
node: ts.TypeAliasDeclaration,
|
|
1791
1793
|
ctx: AnalyzerContext
|
|
1792
1794
|
): void {
|
|
1795
|
+
// Only object-type aliases carry structured fields; other aliases
|
|
1796
|
+
// (string-literal unions, etc.) have no field set to record.
|
|
1797
|
+
const properties = ts.isTypeLiteralNode(node.type)
|
|
1798
|
+
? membersToProperties(node.type.members, ctx.sourceFile)
|
|
1799
|
+
: undefined
|
|
1793
1800
|
ctx.typeDefinitions.push({
|
|
1794
1801
|
kind: 'type',
|
|
1795
1802
|
name: node.name.text,
|
|
1796
1803
|
definition: node.getText(ctx.sourceFile),
|
|
1804
|
+
properties,
|
|
1797
1805
|
loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
|
|
1798
1806
|
})
|
|
1799
1807
|
}
|
package/src/combine-client-js.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* into the parent's file, eliminating the need for separate HTTP requests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import ts from 'typescript'
|
|
12
|
+
|
|
11
13
|
const CHILD_PLACEHOLDER_RE = /import '\/\* @bf-child:(\w+) \*\/'/g
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -101,33 +103,75 @@ function parseAndMerge(
|
|
|
101
103
|
otherImports: string[],
|
|
102
104
|
codeSections: string[]
|
|
103
105
|
): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
106
|
+
// Parse the client JS so we only ever treat *real* top-level
|
|
107
|
+
// `ImportDeclaration` statements as imports. The predecessor matched
|
|
108
|
+
// raw lines beginning with `import `, which also caught `import …`
|
|
109
|
+
// lines that merely live *inside a string / template literal value*
|
|
110
|
+
// (e.g. a data module exporting a code snippet). Tearing such a line
|
|
111
|
+
// out of its string relocated the component's real runtime import into
|
|
112
|
+
// the literal and left `hydrate` undefined at call time. See
|
|
113
|
+
// piconic-ai/barefootjs#1702.
|
|
114
|
+
// Parent pointers aren't needed here — we only read `statements` and each
|
|
115
|
+
// import's `getStart`/`getEnd` — so skip building them to keep the per-chunk
|
|
116
|
+
// parse cheap when combining many files.
|
|
117
|
+
const sourceFile = ts.createSourceFile(
|
|
118
|
+
'combine.js',
|
|
119
|
+
content,
|
|
120
|
+
ts.ScriptTarget.Latest,
|
|
121
|
+
/*setParentNodes*/ false,
|
|
122
|
+
ts.ScriptKind.JS,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Character spans of the top-level imports to strip from the emitted
|
|
126
|
+
// code, so everything that isn't an import (including literals whose
|
|
127
|
+
// contents look like imports) is preserved verbatim.
|
|
128
|
+
const importSpans: Array<[number, number]> = []
|
|
129
|
+
|
|
130
|
+
for (const stmt of sourceFile.statements) {
|
|
131
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
132
|
+
const start = stmt.getStart(sourceFile)
|
|
133
|
+
const end = stmt.getEnd()
|
|
134
|
+
importSpans.push([start, end])
|
|
135
|
+
|
|
136
|
+
const stmtText = content.slice(start, end)
|
|
137
|
+
// `@bf-child:` placeholders are resolved by inlining elsewhere; drop
|
|
138
|
+
// them entirely (neither merged nor kept as code).
|
|
139
|
+
if (stmtText.includes('@bf-child:')) continue
|
|
140
|
+
|
|
141
|
+
const clause = stmt.importClause
|
|
142
|
+
const bindings = clause?.namedBindings
|
|
143
|
+
const specifier = ts.isStringLiteral(stmt.moduleSpecifier)
|
|
144
|
+
? stmt.moduleSpecifier.text
|
|
145
|
+
: ''
|
|
146
|
+
if (clause && !clause.name && bindings && ts.isNamedImports(bindings)) {
|
|
147
|
+
// Pure named import (`import { a, b as c } from '…'`) — merge by source.
|
|
148
|
+
if (!importsBySource.has(specifier)) {
|
|
149
|
+
importsBySource.set(specifier, new Set())
|
|
150
|
+
}
|
|
151
|
+
const set = importsBySource.get(specifier)!
|
|
152
|
+
for (const el of bindings.elements) {
|
|
153
|
+
const name = el.propertyName
|
|
154
|
+
? `${el.propertyName.text} as ${el.name.text}`
|
|
155
|
+
: el.name.text
|
|
156
|
+
set.add(name)
|
|
124
157
|
}
|
|
125
158
|
} else {
|
|
126
|
-
|
|
159
|
+
// default / namespace / side-effect import — keep verbatim.
|
|
160
|
+
if (!otherImports.includes(stmtText)) {
|
|
161
|
+
otherImports.push(stmtText)
|
|
162
|
+
}
|
|
127
163
|
}
|
|
128
164
|
}
|
|
129
165
|
|
|
130
|
-
|
|
166
|
+
// Reconstruct the code with the import spans removed.
|
|
167
|
+
let code = ''
|
|
168
|
+
let cursor = 0
|
|
169
|
+
for (const [start, end] of importSpans) {
|
|
170
|
+
code += content.slice(cursor, start)
|
|
171
|
+
cursor = end
|
|
172
|
+
}
|
|
173
|
+
code += content.slice(cursor)
|
|
174
|
+
code = code.trim()
|
|
131
175
|
if (code) {
|
|
132
176
|
codeSections.push(code)
|
|
133
177
|
}
|
package/src/expression-parser.ts
CHANGED
|
@@ -214,6 +214,21 @@ const UNSUPPORTED_METHODS = new Set([
|
|
|
214
214
|
// `bf_lower` / `bf_upper` (Go) and Perl's native `lc` / `uc` (Mojo).
|
|
215
215
|
// `trim` lowers via the `array-method` IR + `bf_trim` (Go) and a
|
|
216
216
|
// Perl regex strip (Mojo).
|
|
217
|
+
//
|
|
218
|
+
// #1448 follow-up — String methods that have NO lowering yet. These
|
|
219
|
+
// were previously absent from this gate, so `isSupported` reported
|
|
220
|
+
// them "supported" and the adapters emitted a raw method call
|
|
221
|
+
// (`{{.Name.StartsWith "a"}}` on Go, `$name->{startsWith}('a')` on
|
|
222
|
+
// Mojo) with no build diagnostic — a silent footgun that only
|
|
223
|
+
// surfaced as a crash at template-render time. Listing them here
|
|
224
|
+
// makes the build fail loudly with BF101 (the same treatment the
|
|
225
|
+
// unsupported array methods above get), pointing users at the
|
|
226
|
+
// `/* @client */` escape hatch. Each name drops off as its lowering
|
|
227
|
+
// lands. See #1448 "Unsupported string methods" Tier B / Tier C.
|
|
228
|
+
'split', 'startsWith', 'endsWith', 'replace', 'replaceAll',
|
|
229
|
+
'repeat', 'padStart', 'padEnd',
|
|
230
|
+
'charAt', 'charCodeAt', 'codePointAt', 'normalize',
|
|
231
|
+
'substring', 'substr', 'match', 'matchAll', 'search',
|
|
217
232
|
])
|
|
218
233
|
|
|
219
234
|
// =============================================================================
|
|
@@ -1724,7 +1739,7 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
1724
1739
|
return {
|
|
1725
1740
|
supported: false,
|
|
1726
1741
|
level: 'L5_UNSUPPORTED',
|
|
1727
|
-
reason: `
|
|
1742
|
+
reason: `Method '${methodName}()' has no template lowering and requires client-side evaluation. Wrap the expression in /* @client */ to defer it to hydration, or pre-compute the value before rendering.`,
|
|
1728
1743
|
}
|
|
1729
1744
|
}
|
|
1730
1745
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,52 +3,209 @@
|
|
|
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.
|
|
115
|
+
*
|
|
116
|
+
* Counting must happen for every container whose children render as a
|
|
117
|
+
* contiguous run of DOM siblings into the same parent — not just `element`.
|
|
118
|
+
* A loop nested directly inside a component (`<Wrapper><span/>{xs.map(...)}`
|
|
119
|
+
* </Wrapper>`), fragment, provider, or async boundary has its preceding
|
|
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.
|
|
26
137
|
*
|
|
27
138
|
* Computed once up front (instead of during collection) so the offset data
|
|
28
139
|
* lives in an explicit value rather than a module-level WeakMap mutated by
|
|
29
140
|
* two separate traversals.
|
|
30
141
|
*/
|
|
31
|
-
export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop,
|
|
32
|
-
const offsets = new Map<IRLoop,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 => {
|
|
147
|
+
for (const child of children) {
|
|
148
|
+
if (child.type === 'loop') {
|
|
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])
|
|
41
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)
|
|
42
165
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const containerVisit = ({ node, descend }: { node: { children: IRNode[] }; descend: () => void }): void => {
|
|
169
|
+
recordRun(node.children, [])
|
|
170
|
+
descend()
|
|
171
|
+
}
|
|
172
|
+
walkIR(root, null, {
|
|
173
|
+
element: containerVisit,
|
|
174
|
+
component: containerVisit,
|
|
175
|
+
fragment: containerVisit,
|
|
176
|
+
provider: containerVisit,
|
|
177
|
+
async: containerVisit,
|
|
178
|
+
// `loop` / `conditional` / `if-statement` are not flat sibling
|
|
179
|
+
// containers (their children are item bodies / branches), and leaves
|
|
180
|
+
// (text / expression / slot) have no children — all rely on walkIR's
|
|
181
|
+
// default descent with the same scope.
|
|
48
182
|
})
|
|
49
183
|
return offsets
|
|
50
184
|
}
|
|
51
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
|
+
|
|
52
209
|
/**
|
|
53
210
|
* Options controlling `collectInnerLoops` traversal and payload collection.
|
|
54
211
|
*
|
|
@@ -110,7 +267,7 @@ export const branchInnerLoopOptions: CollectInnerLoopsOptions = {
|
|
|
110
267
|
*/
|
|
111
268
|
export function collectInnerLoops(
|
|
112
269
|
nodes: IRNode[],
|
|
113
|
-
siblingOffsets: Map<IRLoop,
|
|
270
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
114
271
|
outerLoopParam?: string,
|
|
115
272
|
ctx?: ClientJsContext,
|
|
116
273
|
options?: CollectInnerLoopsOptions,
|
|
@@ -239,7 +396,7 @@ export function collectInnerLoops(
|
|
|
239
396
|
refsOuterParam: refsOuter,
|
|
240
397
|
childComponents,
|
|
241
398
|
insideConditional: !flat && scope.insideCond ? true : undefined,
|
|
242
|
-
|
|
399
|
+
offset: flat ? undefined : resolveLoopOffset(siblingOffsets.get(n)),
|
|
243
400
|
bindings,
|
|
244
401
|
})
|
|
245
402
|
// Branch-mode callers handle deeper nesting via their own collection paths.
|
|
@@ -267,7 +424,7 @@ export function collectInnerLoops(
|
|
|
267
424
|
*/
|
|
268
425
|
function decideLoopRendering(
|
|
269
426
|
loop: IRLoop,
|
|
270
|
-
siblingOffsets: Map<IRLoop,
|
|
427
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
271
428
|
ctx: ClientJsContext | undefined,
|
|
272
429
|
): { useElementReconciliation: boolean; innerLoops: NestedLoop[] | undefined } {
|
|
273
430
|
const hasNestedComps = (loop.nestedComponents?.length ?? 0) > 0
|
|
@@ -421,7 +578,7 @@ function buildBranchChildComponents(
|
|
|
421
578
|
export function collectElements(
|
|
422
579
|
node: IRNode,
|
|
423
580
|
ctx: ClientJsContext,
|
|
424
|
-
siblingOffsets: Map<IRLoop,
|
|
581
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
425
582
|
insideConditional = false,
|
|
426
583
|
): void {
|
|
427
584
|
walkIR<boolean>(node, insideConditional, {
|
|
@@ -576,7 +733,7 @@ export function collectElements(
|
|
|
576
733
|
isStaticArray: l.isStaticArray,
|
|
577
734
|
useElementReconciliation,
|
|
578
735
|
innerLoops: (useElementReconciliation || (l.isStaticArray && innerLoops?.length)) ? innerLoops : undefined,
|
|
579
|
-
|
|
736
|
+
offset: resolveLoopOffset(siblingOffsets.get(l)),
|
|
580
737
|
filterPredicate: l.filterPredicate ? {
|
|
581
738
|
param: l.filterPredicate.param,
|
|
582
739
|
raw: l.filterPredicate.raw,
|
|
@@ -835,7 +992,7 @@ function collectBranchTextEffects(node: IRNode): ConditionalBranchTextEffect[] {
|
|
|
835
992
|
function collectBranchLoops(
|
|
836
993
|
node: IRNode,
|
|
837
994
|
ctx: ClientJsContext | undefined,
|
|
838
|
-
siblingOffsets: Map<IRLoop,
|
|
995
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
839
996
|
): BranchLoop[] {
|
|
840
997
|
const loops: BranchLoop[] = []
|
|
841
998
|
const restNames = ctx ? buildRestSpreadNames(ctx) : undefined
|
|
@@ -934,7 +1091,7 @@ function collectBranchLoops(
|
|
|
934
1091
|
function buildConditionalMetadata(
|
|
935
1092
|
node: IRNode & { type: 'conditional' },
|
|
936
1093
|
ctx: ClientJsContext,
|
|
937
|
-
siblingOffsets: Map<IRLoop,
|
|
1094
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
938
1095
|
): ConditionalElement {
|
|
939
1096
|
const restNames = buildRestSpreadNames(ctx)
|
|
940
1097
|
// Use loopDepth=-1 so the first loop encountered inside the branch emits
|
|
@@ -964,7 +1121,7 @@ function buildConditionalMetadata(
|
|
|
964
1121
|
function summarizeBranch(
|
|
965
1122
|
node: IRNode,
|
|
966
1123
|
ctx: ClientJsContext,
|
|
967
|
-
siblingOffsets: Map<IRLoop,
|
|
1124
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
968
1125
|
): import('./types').BranchSummary {
|
|
969
1126
|
return {
|
|
970
1127
|
events: collectConditionalBranchEvents(node),
|
|
@@ -984,7 +1141,7 @@ function summarizeBranch(
|
|
|
984
1141
|
function collectBranchConditionals(
|
|
985
1142
|
node: IRNode,
|
|
986
1143
|
ctx: ClientJsContext,
|
|
987
|
-
siblingOffsets: Map<IRLoop,
|
|
1144
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
988
1145
|
): ConditionalElement[] {
|
|
989
1146
|
const result: ConditionalElement[] = []
|
|
990
1147
|
walkIR(node, null, {
|
|
@@ -1032,7 +1189,7 @@ function collectBranchConditionals(
|
|
|
1032
1189
|
export function collectLoopChildBindings(
|
|
1033
1190
|
children: readonly IRNode[],
|
|
1034
1191
|
ctx: ClientJsContext,
|
|
1035
|
-
siblingOffsets: Map<IRLoop,
|
|
1192
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
1036
1193
|
loopParam: string,
|
|
1037
1194
|
loopParamBindings: readonly import('../types').LoopParamBinding[] | undefined,
|
|
1038
1195
|
): LoopChildBindings {
|
|
@@ -1050,7 +1207,7 @@ export function collectLoopChildBindings(
|
|
|
1050
1207
|
export function collectLoopChildConditionals(
|
|
1051
1208
|
node: IRNode,
|
|
1052
1209
|
ctx: ClientJsContext,
|
|
1053
|
-
siblingOffsets: Map<IRLoop,
|
|
1210
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
1054
1211
|
loopParam?: string,
|
|
1055
1212
|
loopParamBindings?: readonly import('../types').LoopParamBinding[],
|
|
1056
1213
|
): LoopChildConditional[] {
|
|
@@ -1125,7 +1282,7 @@ export function collectLoopChildConditionals(
|
|
|
1125
1282
|
function summarizeLoopChildBranch(
|
|
1126
1283
|
node: IRNode,
|
|
1127
1284
|
ctx: ClientJsContext,
|
|
1128
|
-
siblingOffsets: Map<IRLoop,
|
|
1285
|
+
siblingOffsets: Map<IRLoop, IRNode[]>,
|
|
1129
1286
|
loopParam?: string,
|
|
1130
1287
|
loopParamBindings?: readonly import('../types').LoopParamBinding[],
|
|
1131
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
|
}
|