@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.
Files changed (51) hide show
  1. package/dist/analyzer-context.d.ts +8 -1
  2. package/dist/analyzer-context.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/combine-client-js.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +239 -65
  9. package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  13. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/imports.d.ts +2 -2
  17. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/types.d.ts +26 -4
  21. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/utils.d.ts +19 -1
  23. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  28. package/src/__tests__/child-components-in-map.test.ts +376 -0
  29. package/src/__tests__/combine-client-js.test.ts +47 -0
  30. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  31. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  32. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  33. package/src/analyzer-context.ts +59 -13
  34. package/src/analyzer.ts +8 -0
  35. package/src/combine-client-js.ts +66 -22
  36. package/src/expression-parser.ts +16 -1
  37. package/src/index.ts +2 -0
  38. package/src/ir-to-client-js/collect-elements.ts +191 -34
  39. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  41. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  43. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  44. package/src/ir-to-client-js/html-template.ts +82 -10
  45. package/src/ir-to-client-js/imports.ts +1 -1
  46. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  47. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  48. package/src/ir-to-client-js/types.ts +27 -4
  49. package/src/ir-to-client-js/utils.ts +41 -1
  50. package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
  51. package/src/types.ts +6 -0
@@ -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
  }
@@ -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
- const codeLines: string[] = []
105
-
106
- for (const line of content.split('\n')) {
107
- if (line.startsWith('import ')) {
108
- if (line.includes('@bf-child:')) continue
109
-
110
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]/)
111
- if (match) {
112
- const names = match[1].split(',').map(n => n.trim())
113
- const source = match[2]
114
- if (!importsBySource.has(source)) {
115
- importsBySource.set(source, new Set())
116
- }
117
- for (const name of names) {
118
- importsBySource.get(source)!.add(name)
119
- }
120
- } else {
121
- if (!otherImports.includes(line)) {
122
- otherImports.push(line)
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
- codeLines.push(line)
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
- const code = codeLines.join('\n').trim()
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
  }
@@ -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: `Higher-order method '${methodName}()' requires client-side evaluation. Use @client directive or pre-compute in Go.`,
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
@@ -40,7 +40,9 @@ export type {
40
40
  IRTemplatePart,
41
41
  IRProp,
42
42
  ParamInfo,
43
+ PropertyInfo,
43
44
  TypeInfo,
45
+ TypeDefinition,
44
46
  SourceLocation,
45
47
  CompilerError,
46
48
  } from './types'
@@ -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
- /** 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 element. 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.
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, number> {
32
- const offsets = new Map<IRLoop, number>()
33
- walkIR(root, null, {
34
- element: ({ node: el, descend }) => {
35
- let nonLoopCount = 0
36
- for (const child of el.children) {
37
- if (child.type === 'loop') {
38
- if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
39
- } else if (producesDomChild(child)) {
40
- nonLoopCount++
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
- descend()
44
- },
45
- // All container kinds (fragment / component / provider / async / loop /
46
- // conditional / if-statement) rely on walkIR's default descent with the
47
- // same scope. Leaves (text / expression / slot) are no-ops.
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, number>,
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
- siblingOffset: flat ? undefined : (siblingOffsets.get(n) || undefined),
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, number>,
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, number>,
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
- siblingOffset: siblingOffsets.get(l) || undefined,
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, number>,
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, number>,
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, number>,
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, number>,
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, number>,
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, number>,
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, number>,
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
- 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
  }