@barefootjs/jsx 0.26.3 → 0.27.0

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 (99) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +735 -140
  5. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  7. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  10. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  15. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +13 -2
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  21. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
  27. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  29. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  31. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  32. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  34. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/html-template.d.ts +73 -1
  36. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/imports.d.ts +2 -2
  38. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/types.d.ts +35 -2
  41. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/utils.d.ts +1 -1
  43. package/dist/jsx-to-ir.d.ts.map +1 -1
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
  48. package/src/__tests__/child-components-in-map.test.ts +4 -4
  49. package/src/__tests__/client-js-generation.test.ts +26 -12
  50. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  51. package/src/__tests__/composite-branch-loop.test.ts +2 -2
  52. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  53. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  54. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  55. package/src/__tests__/flatmap-segments.test.ts +182 -0
  56. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  57. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  58. package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  60. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  61. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  62. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  63. package/src/__tests__/preamble-region-patch.test.ts +156 -0
  64. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  65. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  66. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  67. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  68. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  69. package/src/__tests__/template-closure.test.ts +1 -1
  70. package/src/compiler.ts +11 -0
  71. package/src/index.ts +1 -0
  72. package/src/ir-to-client-js/build-references.ts +16 -0
  73. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  74. package/src/ir-to-client-js/collect-elements.ts +74 -14
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  78. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  79. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  80. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  81. package/src/ir-to-client-js/control-flow/shared.ts +27 -2
  82. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  83. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  84. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  85. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  86. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  87. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
  88. package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
  89. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  90. package/src/ir-to-client-js/control-flow.ts +1 -1
  91. package/src/ir-to-client-js/element-refs.ts +9 -11
  92. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  93. package/src/ir-to-client-js/html-template.ts +144 -15
  94. package/src/ir-to-client-js/imports.ts +7 -4
  95. package/src/ir-to-client-js/reactivity.ts +7 -0
  96. package/src/ir-to-client-js/types.ts +34 -2
  97. package/src/ir-to-client-js/utils.ts +1 -1
  98. package/src/jsx-to-ir.ts +382 -15
  99. package/src/types.ts +72 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 "Step B —
3
+ * marker elision"): decide, EXACTLY ONCE and BEFORE either `adapter.generate`
4
+ * (SSR) or `generateClientJs` (CSR) run, which `/* @client *\/` text slots
5
+ * can drop their `<!--bf:sN-->…<!--/-->` marker pair entirely from both
6
+ * outputs. Every consumer (all nine SSR adapters' `renderExpression`, and
7
+ * the CSR emitters in `html-template.ts`) reads the single
8
+ * `IRExpression.markerless` flag this pass writes — nobody re-derives the
9
+ * decision, per CLAUDE.md's "Never add compiler options/hooks for
10
+ * tool-specific output rewriting" spirit: one door in, everyone reads it.
11
+ *
12
+ * Scope — deliberately the NARROWEST slice of §3(b)'s elision rule that is
13
+ * fully sound today, not the general case:
14
+ *
15
+ * Only `expr.clientOnly && expr.slotId` expressions OUTSIDE any loop or
16
+ * conditional branch (i.e. reachable from the component's own render tree
17
+ * by walking only `element`/`text`/`expression`/`fragment` nodes).
18
+ *
19
+ * Why THIS slice and not ordinary reactive text slots (loop rows,
20
+ * conditional branches): every other kind of slot's SSR-rendered width is
21
+ * DATA-DEPENDENT — an ordinary `{item.name}` may render empty or non-empty
22
+ * per request, so a LATER sibling's absolute child-index path would only be
23
+ * valid for the specific width THIS request happened to produce, not for
24
+ * every request the compiled function ever serves. `/* @client *\/`
25
+ * expressions are the one case free of that problem: SSR can never evaluate
26
+ * client-only JS (the whole point of the escape valve), so its rendered
27
+ * width is deterministically ZERO on every request, for every adapter, with
28
+ * no data dependence at all. That determinism is what makes computing a
29
+ * real, reusable, hydration-safe path sound here without also solving the
30
+ * general "does an earlier sibling's width vary by request" problem.
31
+ *
32
+ * Path safety within that scope — the part that generalizes A3's
33
+ * `computeSkeletonSlotPaths` (`html-template.ts`) rather than reinventing
34
+ * it: paths are root-relative child-index chains, exactly like that
35
+ * function's, walked in document order with the SAME "once we hit something
36
+ * whose contributed width isn't a compile-time constant, every remaining
37
+ * sibling AND everything nested inside it loses eligibility" rule (nested
38
+ * content reached only through an uncertain-index sibling would resolve an
39
+ * absolute path through that uncertain index — silently wrong, not loud, so
40
+ * it must never be attempted). Concretely, walking a children list left to
41
+ * right:
42
+ * - a static `element` always contributes exactly one node — recurse into
43
+ * its own children as an independent, freshly-indexed scope (its own
44
+ * internal accounting can't leak out, and nothing external can poison
45
+ * it either, since the element itself is a fixed anchor regardless of
46
+ * what's inside it);
47
+ * - static `text` contributes a node per HTML-parser text-run-merging
48
+ * rules (adjacent text/expression children collapse into ONE Text
49
+ * node — mirrors `computeSkeletonSlotPaths`'s `pendingText` tracking);
50
+ * - a `clientOnly` expression with a slotId is the one ELIGIBLE case,
51
+ * PROVIDED it is not itself part of a merged text run (rule (i) — the
52
+ * `/* @client *\/` slot must not be immediately adjacent, once markers
53
+ * are gone, to loose text or another expression, or the HTML parser
54
+ * would merge them into one Text node with no way to tell which
55
+ * content is which). Once one is elided, this level (and everything
56
+ * nested inside any later sibling) is marked ineligible for the
57
+ * remainder of the walk at this level — see the "freeze" note below;
58
+ * - any other `expression` (a real reactive/static text or markup slot),
59
+ * any `conditional`/`loop`/`component`/`async`/`provider`/
60
+ * `if-statement`/`slot` node, is data-dependent or opaque width and
61
+ * FREEZES the rest of this children list (see below) — this is
62
+ * strictly conservative: it also means a `/* @client *\/` expression
63
+ * is never eligible once any of these appears earlier at the same
64
+ * level, even though `/* @client *\/`'s own width is always zero,
65
+ * because SIBLINGS reached only via nested recursion past that point
66
+ * would otherwise resolve an absolute path through the frozen node's
67
+ * uncertain contributed width;
68
+ * - the same hazard-tag / force-close-group / void-element / `<tr>`
69
+ * foster-parenting guards `computeSkeletonSlotPaths` uses (imported,
70
+ * not re-derived) abort the ENTIRE walk (a global bail, exactly like
71
+ * that function's `state.bailed`) — a parser hazard means every index
72
+ * computed anywhere in this tree is suspect, not just the ones near
73
+ * the hazard, so "don't guess" means don't guess about the blast
74
+ * radius either.
75
+ *
76
+ * "Freeze" (the one simplification versus a maximally-precise per-branch
77
+ * eligibility tracker): once ANY `/* @client *\/` slot at a given level is
78
+ * elided — OR once anything data-dependent/opaque is seen at that level —
79
+ * that level's remaining siblings (and anything nested only through them)
80
+ * stop being assigned real paths for the rest of THIS walk, even though a
81
+ * maximally precise analysis could sometimes still find one. This caps
82
+ * elision at (at most) one `/* @client *\/` slot per static subtree —
83
+ * conservative by design, matching the elision rule's own framing, and it
84
+ * guarantees two elided slots can never end up needing an insertion whose
85
+ * reference node depends on the other's still-unresolved width. Freezing is
86
+ * PER CHILDREN-LIST (an element's own children start a fresh, unfrozen
87
+ * scope) — global-bail is reserved for hazard tags alone.
88
+ *
89
+ * Deferred (loudly, not silently): ordinary reactive text slots in loop
90
+ * rows / conditional branches keep their markers — see above for why
91
+ * widening this to data-dependent-width content needs a materially
92
+ * different (and per-adapter-verified) safety argument that this PR does
93
+ * not attempt.
94
+ */
95
+
96
+ import type { IRNode, IRExpression } from '../types.ts'
97
+ import {
98
+ VOID_ELEMENTS,
99
+ SKELETON_PATH_HAZARD_TAGS,
100
+ skeletonForceCloseGroup,
101
+ hasForeignTableRowContent,
102
+ flattenSkeletonChildren,
103
+ } from './html-template.ts'
104
+
105
+ /** Global bail, mirroring `computeSkeletonSlotPaths`'s `state.bailed`: once
106
+ * a parser hazard is seen anywhere, every remaining step of this ONE
107
+ * `decideClientOnlyElision` call becomes a no-op. Slots already elided
108
+ * before the hazard was reached keep their `markerless`/`elidedPath` —
109
+ * those paths were computed from safe ground and remain valid; only
110
+ * FURTHER assignment stops. */
111
+ interface ElisionState {
112
+ bailed: boolean
113
+ }
114
+
115
+ /**
116
+ * Entry point: mutates `root`'s `expression` nodes in place, setting
117
+ * `markerless`/`elidedPath` on every eligible `/* @client *\/` slot found.
118
+ * Safe to call on any component's render tree; a tree with none simply
119
+ * mutates nothing.
120
+ */
121
+ export function decideClientOnlyElision(root: IRNode): void {
122
+ walkNode(root, [], new Set(), { bailed: false })
123
+ }
124
+
125
+ /** Walk one node that occupies a known position (`path`) in its parent's
126
+ * children. Only `element`/`fragment` are ever recursed into by the
127
+ * children-list walker below — this wrapper exists so both the top-level
128
+ * call and `walkChildren`'s per-element recursion can share one path. */
129
+ function walkNode(
130
+ node: IRNode,
131
+ path: readonly number[],
132
+ forceCloseAncestors: ReadonlySet<number>,
133
+ state: ElisionState,
134
+ ): void {
135
+ if (state.bailed) return
136
+ if (node.type === 'element') {
137
+ if (!elementIsPathSafe(node.tag, flattenSkeletonChildren(node.children))) {
138
+ state.bailed = true
139
+ return
140
+ }
141
+ const groupIdx = skeletonForceCloseGroup(node.tag)
142
+ if (groupIdx >= 0 && forceCloseAncestors.has(groupIdx)) {
143
+ state.bailed = true
144
+ return
145
+ }
146
+ const nextAncestors = groupIdx >= 0 ? new Set([...forceCloseAncestors, groupIdx]) : forceCloseAncestors
147
+ walkChildren(flattenSkeletonChildren(node.children), path, nextAncestors, state)
148
+ } else if (node.type === 'fragment') {
149
+ walkChildren(flattenSkeletonChildren(node.children), path, forceCloseAncestors, state)
150
+ }
151
+ // Any other root shape (a bare conditional/loop/component return, etc.)
152
+ // has no static children list to walk — nothing to do.
153
+ }
154
+
155
+ /**
156
+ * Walk one parent's (already-fragment-flattened) children left to right,
157
+ * assigning paths to eligible `/* @client *\/` slots. `parentPath` is the
158
+ * path to the PARENT of this children list (the element/root whose
159
+ * `childNodes` these indices are relative to). `forceCloseAncestors`
160
+ * mirrors `computeSkeletonSlotPaths`'s ancestor-threaded force-close-group
161
+ * tracking (`<h2>` inside an open `<h1>`, `<li>` inside an open `<li>`, …) —
162
+ * threaded down through element recursion, exactly like that function.
163
+ * `frozen` is local to THIS call (this one children list) — an element's
164
+ * own children get a fresh, unfrozen scope regardless of what froze here.
165
+ */
166
+ function walkChildren(
167
+ children: readonly IRNode[],
168
+ parentPath: readonly number[],
169
+ forceCloseAncestors: ReadonlySet<number>,
170
+ state: ElisionState,
171
+ ): void {
172
+ let frozen = false
173
+ let idx = 0
174
+ let pendingText = false
175
+
176
+ for (let i = 0; i < children.length; i++) {
177
+ if (state.bailed) return
178
+ const child = children[i]
179
+
180
+ switch (child.type) {
181
+ case 'text': {
182
+ if (child.value === '') continue
183
+ if (!pendingText) idx += 1
184
+ pendingText = true
185
+ continue
186
+ }
187
+
188
+ case 'expression': {
189
+ if (child.expr === 'null' || child.expr === 'undefined') continue
190
+ if (child.clientOnly && child.slotId) {
191
+ const adjacent = isTextLike(children[i - 1]) || isTextLike(children[i + 1])
192
+ if (!frozen && !adjacent) {
193
+ markElided(child, [...parentPath, idx])
194
+ frozen = true // one elision per level — see module docstring
195
+ } else {
196
+ frozen = true // still freeze: an un-elided expression here is
197
+ // no different from any other data-dependent-width sibling.
198
+ }
199
+ idx += 1
200
+ pendingText = false
201
+ continue
202
+ }
203
+ // Any other expression — data-dependent width. Freeze the rest of
204
+ // this level; still consume ONE index (best-effort bookkeeping —
205
+ // nothing downstream reads `idx` past this point since `frozen`
206
+ // blocks any further assignment).
207
+ frozen = true
208
+ idx += 1
209
+ pendingText = false
210
+ continue
211
+ }
212
+
213
+ case 'element': {
214
+ if (!elementIsPathSafe(child.tag, flattenSkeletonChildren(child.children))) {
215
+ // Hazard shape: bail the WHOLE walk (matches
216
+ // `computeSkeletonSlotPaths`'s "don't guess" stance), not just
217
+ // this level — a parser hazard means indices anywhere in this
218
+ // tree are untrustworthy, not only the ones near it.
219
+ state.bailed = true
220
+ return
221
+ }
222
+ if (!frozen) {
223
+ walkNode(child, [...parentPath, idx], forceCloseAncestors, state)
224
+ }
225
+ idx += 1
226
+ pendingText = false
227
+ continue
228
+ }
229
+
230
+ case 'fragment':
231
+ continue // already flattened
232
+
233
+ default:
234
+ // conditional / loop / component / async / provider / if-statement /
235
+ // slot: opaque or variable width. Freeze the rest of this level.
236
+ frozen = true
237
+ idx += 1
238
+ pendingText = false
239
+ continue
240
+ }
241
+ }
242
+ }
243
+
244
+ /** True for a node that would merge (per HTML text-node-merging rules) with
245
+ * an adjacent bare-text-position slot if that slot's markers were removed —
246
+ * rule (i) of §3(b). `undefined` (no sibling — start/end of the children
247
+ * list) is never adjacent. */
248
+ function isTextLike(node: IRNode | undefined): boolean {
249
+ if (!node) return false
250
+ if (node.type === 'text') return node.value !== ''
251
+ if (node.type === 'expression') return node.expr !== 'null' && node.expr !== 'undefined'
252
+ return false
253
+ }
254
+
255
+ function markElided(expr: IRExpression, path: readonly number[]): void {
256
+ expr.markerless = true
257
+ expr.elidedPath = path
258
+ }
259
+
260
+ /**
261
+ * Mirrors `computeSkeletonSlotPaths`'s per-element safety checks (hazard
262
+ * tags, void elements, `<tr>` foster-parenting) without re-deriving the
263
+ * underlying tag sets — imported from `html-template.ts` so the two walks
264
+ * can never silently diverge on which shapes are hazardous. Force-close
265
+ * groups are checked separately by the caller (`walkNode`), which has the
266
+ * ancestor set this check does not.
267
+ */
268
+ function elementIsPathSafe(tag: string, flatChildren: readonly IRNode[]): boolean {
269
+ if (SKELETON_PATH_HAZARD_TAGS.has(tag)) return false
270
+ if (VOID_ELEMENTS.has(tag) && flatChildren.length > 0) return false
271
+ if (tag === 'tr' && hasForeignTableRowContent(flatChildren)) return false
272
+ return true
273
+ }
@@ -6,7 +6,7 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, type SkeletonSlotPaths } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
12
  import { walkIR, stopAt } from './walker.ts'
@@ -414,8 +414,8 @@ export function collectInnerLoops(
414
414
 
415
415
  /**
416
416
  * Decide whether a loop's runtime rendering needs element reconciliation
417
- * (reconcileElements + composite item rendering) rather than the simple
418
- * template-per-item path, and collect inner-loop metadata for its body.
417
+ * (mapArray/mapArrayAnchored + composite item rendering) rather than the
418
+ * simple template-per-item path, and collect inner-loop metadata for its body.
419
419
  *
420
420
  * Used by both the top-level `case 'loop'` in `collectElements` and the
421
421
  * branch-loop collector in `collectBranchLoops`. Each call site applies
@@ -595,7 +595,11 @@ export function collectElements(
595
595
  },
596
596
  expression: ({ node: ex, scope: inCond }) => {
597
597
  if (ex.clientOnly && ex.slotId) {
598
- ctx.clientOnlyElements.push({ slotId: ex.slotId, expression: ex.expr })
598
+ ctx.clientOnlyElements.push({
599
+ slotId: ex.slotId,
600
+ expression: ex.expr,
601
+ elidedPath: ex.markerless ? ex.elidedPath : undefined,
602
+ })
599
603
  return
600
604
  }
601
605
  if (!ex.slotId || inCond) return
@@ -650,10 +654,26 @@ export function collectElements(
650
654
  // are extracted explicitly below for the closure capture set.
651
655
  if (!l.slotId || inCond) return
652
656
 
657
+ // flatMap PROJECTION loop: the only child is a nested IRLoop lowered
658
+ // from `flatMap(it => it.tags.map(...))`. The children exist for SSR
659
+ // templatization (nested {{range}} on DSL adapters); the CLIENT
660
+ // reconciles the flattened leaves through the descriptor mapArray
661
+ // path synthesized from the same inner loop, so none of the
662
+ // children-derived client machinery (bindings, inner-loop plans,
663
+ // per-row template) applies — leaf wiring was refused at IR build.
664
+ const projectionInner =
665
+ l.method === 'flatMap' && l.children.length === 1 && l.children[0].type === 'loop'
666
+ ? l.children[0]
667
+ : undefined
668
+
653
669
  const childHandlers: string[] = []
654
- const bindings = collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
655
- for (const child of l.children) {
656
- childHandlers.push(...collectEventHandlersFromIR(child))
670
+ const bindings = projectionInner
671
+ ? emptyLoopChildBindings()
672
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
673
+ if (!projectionInner) {
674
+ for (const child of l.children) {
675
+ childHandlers.push(...collectEventHandlersFromIR(child))
676
+ }
657
677
  }
658
678
 
659
679
  if (l.childComponent) {
@@ -668,7 +688,9 @@ export function collectElements(
668
688
  // Determine rendering strategy for dynamic arrays:
669
689
  // Use element reconciliation when the loop body has nested components,
670
690
  // or when inner loops need their own mapArray for events/reactive text.
671
- const { useElementReconciliation, innerLoops } = decideLoopRendering(l, siblingOffsets, ctx)
691
+ const { useElementReconciliation, innerLoops } = projectionInner
692
+ ? { useElementReconciliation: false, innerLoops: undefined }
693
+ : decideLoopRendering(l, siblingOffsets, ctx)
672
694
 
673
695
  let template = ''
674
696
  let staticItemTemplate: string | undefined
@@ -691,7 +713,7 @@ export function collectElements(
691
713
  // `data-key`, mirroring the SSR template's renderChild emit.
692
714
  staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined, /* insideLoop */ true)
693
715
  }
694
- } else if (l.children[0]) {
716
+ } else if (l.children[0] && !projectionInner) {
695
717
  // Pass loopParams so expressions are wrapped at generation time,
696
718
  // avoiding post-hoc regex wrapping that corrupts literal attribute values.
697
719
  // Forward destructured bindings (#951) so references like `cfg.color`
@@ -777,6 +799,20 @@ export function collectElements(
777
799
  } : undefined,
778
800
  chainOrder: l.chainOrder,
779
801
  preamble: l.preamble,
802
+ preambleRegions: l.preambleRegions,
803
+ flatMapClient: projectionInner
804
+ ? {
805
+ params: l.index ? `(${l.param}, ${l.index})` : `(${l.param})`,
806
+ body: renderFlatMapProjectionClientBody(projectionInner, buildRestSpreadNames(ctx)),
807
+ keyed: projectionInner.key !== null,
808
+ }
809
+ : l.flatMapCallback
810
+ ? {
811
+ params: l.flatMapCallback.params,
812
+ body: renderFlatMapClientBody(l.flatMapCallback, buildRestSpreadNames(ctx)),
813
+ keyed: flatMapCallbackHasKeyedLeaf(l.flatMapCallback),
814
+ }
815
+ : undefined,
780
816
  })
781
817
  // Don't descend — loop-scoped variables are only available inside the iteration.
782
818
  },
@@ -1069,18 +1105,28 @@ function collectBranchLoops(
1069
1105
  // reactive-text collection on inner loops reached from this call path
1070
1106
  // is handled at the enclosing branch-loop level below (`if (ctx)` block
1071
1107
  // around `collectLoopChildReactiveTexts`), not per inner loop.
1072
- const { useElementReconciliation, innerLoops: innerLoopsCollected } =
1073
- decideLoopRendering(n, siblingOffsets, undefined)
1108
+ // flatMap PROJECTION loop see the top-level handler's note: children
1109
+ // are SSR-only; the client rides the descriptor mapArray path.
1110
+ const projectionInner =
1111
+ n.method === 'flatMap' && n.children.length === 1 && n.children[0].type === 'loop'
1112
+ ? n.children[0]
1113
+ : undefined
1114
+
1115
+ const { useElementReconciliation, innerLoops: innerLoopsCollected } = projectionInner
1116
+ ? { useElementReconciliation: false, innerLoops: undefined }
1117
+ : decideLoopRendering(n, siblingOffsets, undefined)
1074
1118
 
1075
1119
  // Build the item template from loop children.
1076
- // Use loopDepth=0: this loop gets its own reconcileElements (independent
1120
+ // Use loopDepth=0: this loop gets its own mapArray/mapArrayAnchored call (independent
1077
1121
  // from the conditional's template), so items use data-key (not data-key-1).
1078
1122
  // Pass loopParams so expressions reference the per-item signal accessor,
1079
1123
  // keeping the template consistent with reactive effect expressions that
1080
1124
  // use `param()` to read the current item value.
1081
1125
  let childTemplate: string
1082
1126
  const branchLoopParamSpec = [{ param: n.param, bindings: n.paramBindings }]
1083
- if (useElementReconciliation && n.children[0]) {
1127
+ if (projectionInner) {
1128
+ childTemplate = '' // descriptor renderItem builds from d.h, not a row template
1129
+ } else if (useElementReconciliation && n.children[0]) {
1084
1130
  childTemplate = irToPlaceholderTemplate(n.children[0], restNames, 0, branchLoopParamSpec)
1085
1131
  } else {
1086
1132
  childTemplate = n.children.map(c => irToHtmlTemplate(c, undefined, 0, branchLoopParamSpec)).join('')
@@ -1092,7 +1138,7 @@ function collectBranchLoops(
1092
1138
  // memos). Previously these were only collected for composite loops,
1093
1139
  // which caused reactive reads inside simple loop bodies to silently
1094
1140
  // no-op for existing items.
1095
- const branchBindings = ctx
1141
+ const branchBindings = ctx && !projectionInner
1096
1142
  ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings)
1097
1143
  : emptyLoopChildBindings()
1098
1144
 
@@ -1112,6 +1158,7 @@ function collectBranchLoops(
1112
1158
  template: childTemplate,
1113
1159
  containerSlotId: containerSlot,
1114
1160
  preamble: n.preamble,
1161
+ preambleRegions: n.preambleRegions,
1115
1162
  nestedComponents: useElementReconciliation ? n.nestedComponents : undefined,
1116
1163
  bindings: branchBindings,
1117
1164
  innerLoops: useElementReconciliation ? innerLoopsCollected : undefined,
@@ -1126,6 +1173,19 @@ function collectBranchLoops(
1126
1173
  raw: n.sortComparator.raw,
1127
1174
  } : undefined,
1128
1175
  chainOrder: n.chainOrder,
1176
+ flatMapClient: projectionInner
1177
+ ? {
1178
+ params: n.index ? `(${n.param}, ${n.index})` : `(${n.param})`,
1179
+ body: renderFlatMapProjectionClientBody(projectionInner, restNames),
1180
+ keyed: projectionInner.key !== null,
1181
+ }
1182
+ : n.flatMapCallback
1183
+ ? {
1184
+ params: n.flatMapCallback.params,
1185
+ body: renderFlatMapClientBody(n.flatMapCallback, restNames),
1186
+ keyed: flatMapCallbackHasKeyedLeaf(n.flatMapCallback),
1187
+ }
1188
+ : undefined,
1129
1189
  })
1130
1190
  // Don't recurse into the loop — nested loops are handled by the loop's own reconciliation.
1131
1191
  },
@@ -15,11 +15,17 @@
15
15
  * pair.
16
16
  */
17
17
 
18
- import type { CompositeLoopPlan, LoopChildRefBinding } from './loop.ts'
18
+ import type { CompositeLoopPlan, LoopChildRefBinding, PreambleRegionPlan } from './loop.ts'
19
19
  import type { EventDelegationPlan } from './event-delegation.ts'
20
20
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
21
21
 
22
22
  export interface BranchPlainLoopPlan {
23
+ /**
24
+ * flatMap descriptor mode — see `PlainLoopVariant.flatMapLeafItem`
25
+ * (plan/loop.ts): `arrayExpr` already flattens through the descriptor
26
+ * body and the stringifier emits the build-or-patch renderItem.
27
+ */
28
+ flatMapLeafItem?: boolean
23
29
  kind: 'plain'
24
30
  /** See LoopPlanCommon.rowConstruction — required on every variant. */
25
31
  rowConstruction: 'string-template' | 'dom-ops'
@@ -78,6 +84,11 @@ export interface BranchPlainLoopPlan {
78
84
  * Undefined off → byte-identical (SR8).
79
85
  */
80
86
  profileLoopId?: string
87
+ /**
88
+ * Preamble-patched regions (#2389) — non-empty forces the multi-line
89
+ * renderItem layout, mirroring the top-level plain plan.
90
+ */
91
+ preambleRegions: readonly PreambleRegionPlan[]
81
92
  }
82
93
 
83
94
  export interface BranchCompositeLoopPlan {
@@ -15,7 +15,7 @@ import { buildChainedArrayExpr, varSlotId, wrapLoopParamAsAccessor } from '../..
15
15
  import { buildBranchCompositePlan } from './build-composite-loop.ts'
16
16
  import { buildBranchLoopDelegationPlan } from './build-event-delegation.ts'
17
17
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
18
- import { destructureLoopParam, loopKeyFn, buildChildRefBindings } from '../shared.ts'
18
+ import { destructureLoopParam, loopKeyFn, buildChildRefBindings, buildPreambleRegionPlans } from '../shared.ts'
19
19
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
20
20
  import type {
21
21
  BranchCompositeLoopPlan,
@@ -43,14 +43,21 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
43
43
  || loop.bindings.reactiveTexts.length > 0
44
44
  || loop.bindings.conditionals.length > 0
45
45
 
46
+ // flatMap descriptor mode — see buildPlainLoopPlan (build-loop.ts).
47
+ const fm = loop.flatMapClient
46
48
  const plan: BranchPlainLoopPlan = {
47
49
  kind: 'plain',
48
50
  rowConstruction: 'string-template',
49
51
  containerSlotId,
50
52
  containerVar,
51
53
  markerId: loop.markerId,
52
- arrayExpr: buildChainedArrayExpr(loop),
53
- keyFn: loopKeyFn(loop),
54
+ flatMapLeafItem: fm ? true : undefined,
55
+ arrayExpr: fm
56
+ ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
57
+ : buildChainedArrayExpr(loop),
58
+ keyFn: fm
59
+ ? (fm.keyed ? '(__bfD, __bfI) => String(__bfD.k ?? __bfI)' : 'null')
60
+ : loopKeyFn(loop),
54
61
  paramHead,
55
62
  paramUnwrap,
56
63
  indexParam: loop.index || '__idx',
@@ -75,6 +82,7 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
75
82
  : null,
76
83
  eventDelegation: buildBranchLoopDelegationPlan(loop, cv, profileComponentName),
77
84
  childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
85
+ preambleRegions: buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings),
78
86
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
79
87
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${containerSlotId}` : undefined,
80
88
  }
@@ -36,11 +36,18 @@ export function buildDynamicLoopDelegationPlan(
36
36
  paramBindings: elem.paramBindings,
37
37
  key: elem.key,
38
38
  index: elem.index,
39
+ // No loopParams spec here (unlike the row-render context) — in the
40
+ // delegated handler `elem.param` is bound to the plain `.find()`/
41
+ // indexed result, not a signal accessor, so leaf refs must stay in
42
+ // their literal (`t.name`) form. Passing a loopParams spec here was
43
+ // BUG-3: it rewrote leaf refs to accessor-call form (`t().name`),
44
+ // which throws since `t` is a plain object in this scope.
39
45
  mapPreamble: elem.preamble
40
46
  ? renderPreamble(elem.preamble, {
41
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
47
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
42
48
  })
43
49
  : null,
50
+ mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
44
51
  }),
45
52
  }
46
53
  }
@@ -65,11 +72,14 @@ export function buildBranchLoopDelegationPlan(
65
72
  paramBindings: loop.paramBindings,
66
73
  key: loop.key,
67
74
  index: loop.index,
75
+ // See note in `buildDynamicLoopDelegationPlan` above (BUG-3): no
76
+ // loopParams spec — leaf refs must stay in plain-object form here.
68
77
  mapPreamble: loop.preamble
69
78
  ? renderPreamble(loop.preamble, {
70
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
79
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
71
80
  })
72
81
  : null,
82
+ mapPreambleDeclaredNames: loop.preamble?.declaredNames ?? [],
73
83
  }),
74
84
  }
75
85
  }
@@ -95,11 +105,14 @@ export function buildStaticArrayDelegationPlan(
95
105
  // array too (#1434).
96
106
  arrayExpr: buildChainedArrayExpr(elem),
97
107
  param: elem.param,
108
+ // See note in `buildDynamicLoopDelegationPlan` above (BUG-3): no
109
+ // loopParams spec — leaf refs must stay in plain-object form here.
98
110
  mapPreamble: elem.preamble
99
111
  ? renderPreamble(elem.preamble, {
100
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
112
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
101
113
  })
102
114
  : null,
115
+ mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
103
116
  offset: elem.offset ?? null,
104
117
  indexParam: elem.index ?? null,
105
118
  },
@@ -117,6 +130,7 @@ function buildKeyedOrIndexLookup(args: {
117
130
  key: string | null
118
131
  index: string | null
119
132
  mapPreamble: string | null
133
+ mapPreambleDeclaredNames: readonly string[]
120
134
  }): ItemLookup {
121
135
  const hasBindings = (args.paramBindings?.length ?? 0) > 0
122
136
  if (args.key !== null) {
@@ -132,6 +146,7 @@ function buildKeyedOrIndexLookup(args: {
132
146
  paramBindings: args.paramBindings,
133
147
  keyWithItem,
134
148
  mapPreamble: args.mapPreamble,
149
+ mapPreambleDeclaredNames: args.mapPreambleDeclaredNames,
135
150
  hasBindings,
136
151
  indexParam: args.index,
137
152
  }
@@ -141,6 +156,7 @@ function buildKeyedOrIndexLookup(args: {
141
156
  arrayExpr: args.array,
142
157
  param: args.param,
143
158
  mapPreamble: args.mapPreamble,
159
+ mapPreambleDeclaredNames: args.mapPreambleDeclaredNames,
144
160
  hasBindings,
145
161
  indexParam: args.index,
146
162
  }
@@ -35,6 +35,7 @@ import {
35
35
  destructureLoopParam,
36
36
  buildChildRefBindings,
37
37
  buildStaticChildRefBindings,
38
+ buildPreambleRegionPlans,
38
39
  } from '../shared.ts'
39
40
  import { buildLoopReactiveEffectsPlan } from './build-reactive-effects.ts'
40
41
  import { buildComponentLoopPlan } from './build-component-loop.ts'
@@ -91,6 +92,40 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
91
92
  || elem.bindings.reactiveTexts.length > 0
92
93
  || elem.bindings.conditionals.length > 0
93
94
 
95
+ // flatMap descriptor mode: reconcile the FLATTENED leaves. The source
96
+ // accessor runs the flatMap body (plain items — per-item signals don't
97
+ // exist yet) producing `({ k, h })` descriptors; the keyFn reads the
98
+ // leaf-declared key with an index fallback. Everything row-template-
99
+ // related is bypassed — the stringifier emits the build-or-patch
100
+ // renderItem instead. Pre-fix this fell into the generic plain shape
101
+ // with the UN-flattened source, a null keyFn, and an EMPTY template
102
+ // (leaf loss at hydration; cloneNode(null) crash on adds).
103
+ if (elem.flatMapClient) {
104
+ return {
105
+ kind: 'plain',
106
+ rowConstruction: 'string-template',
107
+ containerVar: `_${varSlotId(elem.slotId)}`,
108
+ markerId: elem.markerId,
109
+ profileLoopId: profileComponentName ? `${profileComponentName}#binding:${elem.slotId}` : undefined,
110
+ arrayExpr: `(${buildChainedArrayExpr(elem)}).flatMap(${elem.flatMapClient.params} => ${elem.flatMapClient.body})`,
111
+ keyFn: elem.flatMapClient.keyed ? '(__bfD, __bfI) => String(__bfD.k ?? __bfI)' : 'null',
112
+ paramHead: '__bfD',
113
+ paramUnwrap: '',
114
+ indexParam: '__idx',
115
+ mapPreambleWrapped: '',
116
+ template: '',
117
+ reactiveEffects: null,
118
+ childRefs: [],
119
+ bodyIsMultiRoot: false,
120
+ anchored: false,
121
+ anchorKeyExpr: '__idx',
122
+ flatMapLeafItem: true,
123
+ // flatMap loops carry no `MapCallbackPreamble` (jsx-to-ir.ts drops it
124
+ // for flatMapCallback), so there is nothing to patch here.
125
+ preambleRegions: [],
126
+ }
127
+ }
128
+
94
129
  return {
95
130
  kind: 'plain',
96
131
  rowConstruction: 'string-template',
@@ -116,6 +151,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
116
151
  skeletonPaths: elem.skeletonPaths,
117
152
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
118
153
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
154
+ preambleRegions: buildPreambleRegionPlans(elem.preambleRegions, elem.param, elem.paramBindings),
119
155
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
120
156
  anchored: elem.bodyIsItemConditional ?? false,
121
157
  // Fall back to the iteration index when the loop has no key. A whole-item