@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
@@ -66,8 +66,19 @@ export interface KeyedItemLookup {
66
66
  * decision.
67
67
  */
68
68
  keyWithItem: string
69
- /** Optional preamble line — emitted before the handler call. */
69
+ /** Optional preamble line — emitted before the handler call, inside the item guard. */
70
70
  mapPreamble: string | null
71
+ /**
72
+ * `const`/`let`/function names `mapPreamble` declares (from
73
+ * `MapCallbackPreamble.declaredNames`). The stringifier only splices
74
+ * `mapPreamble` for an event whose handler text actually references one of
75
+ * these — a preamble that only feeds unused local state (e.g. a `cells`
76
+ * array builder never read by the click handler) is dead weight the
77
+ * delegated handler shouldn't pay for, and emitting it unconditionally is
78
+ * what let BUG-3's stale-context leaf render go unnoticed. Empty when
79
+ * `mapPreamble` is `null`.
80
+ */
81
+ mapPreambleDeclaredNames: readonly string[]
71
82
  /** True when `paramBindings` is non-empty — drives TDZ-safe lookup shape. */
72
83
  hasBindings: boolean
73
84
  }
@@ -77,6 +88,8 @@ export interface DynamicIndexItemLookup {
77
88
  arrayExpr: string
78
89
  param: string
79
90
  mapPreamble: string | null
91
+ /** See `KeyedItemLookup.mapPreambleDeclaredNames`. */
92
+ mapPreambleDeclaredNames: readonly string[]
80
93
  hasBindings: boolean
81
94
  /** Loop index param name — see `KeyedItemLookup.indexParam` (#2189). */
82
95
  indexParam: string | null
@@ -87,6 +100,8 @@ export interface StaticIndexItemLookup {
87
100
  arrayExpr: string
88
101
  param: string
89
102
  mapPreamble: string | null
103
+ /** See `KeyedItemLookup.mapPreambleDeclaredNames`. */
104
+ mapPreambleDeclaredNames: readonly string[]
90
105
  /** Loop index param name — see `KeyedItemLookup.indexParam` (#2189). */
91
106
  indexParam: string | null
92
107
  /**
@@ -78,6 +78,19 @@ interface DynamicLoopCommon extends LoopPlanCommon {
78
78
  paramUnwrap: string
79
79
  }
80
80
 
81
+ /**
82
+ * A resolved preamble-patched region (#2389), ready to emit: `valueExpr` is
83
+ * already wrapped with the loop-param accessor and, for a `joinArrayChild`
84
+ * source, already carries the array-join ternary — the exact value
85
+ * `irToHtmlTemplate` computes for the same node in the row template, so a
86
+ * same-key update's re-patch stays byte-identical to what a fresh mount
87
+ * would render.
88
+ */
89
+ export interface PreambleRegionPlan {
90
+ slotId: string
91
+ valueExpr: string
92
+ }
93
+
81
94
  /** Per-item ref callback resolved against a child slot for emission (#1244). */
82
95
  export interface LoopChildRefBinding {
83
96
  /** bf slot ID of the target element (root or descendant of the body). */
@@ -146,6 +159,21 @@ interface PlainLoopVariant extends DynamicLoopCommon {
146
159
  * Empty when the loop has no key (only meaningful when `anchored`).
147
160
  */
148
161
  anchorKeyExpr: string
162
+ /**
163
+ * flatMap descriptor mode (`LoopCore.flatMapClient`): `arrayExpr` already
164
+ * includes the `.flatMap(params => descriptorBody)` projection — the
165
+ * runtime reconciles the FLATTENED `({ k, h })` leaves — and `keyFn` keys
166
+ * on `d.k` (index fallback). The stringifier emits the build-or-patch
167
+ * renderItem (`patchLeaf`) instead of the template-clone shape; `template`
168
+ * is empty and `mapPreambleWrapped` unused in this mode.
169
+ */
170
+ flatMapLeafItem?: boolean
171
+ /**
172
+ * Preamble-patched regions (#2389) — non-empty forces the multi-line
173
+ * renderItem layout (mirrors `childRefs`) so the region-patch effects have
174
+ * `__el` to query against.
175
+ */
176
+ preambleRegions: readonly PreambleRegionPlan[]
149
177
  }
150
178
 
151
179
  /**
@@ -20,7 +20,8 @@ import { irChildrenToJsExpr } from '../html-template.ts'
20
20
  import { emitListenerBlock } from './stringify/event-listener.ts'
21
21
  import { nameForRegistryRef } from '../component-scope.ts'
22
22
  import { BF_SCOPE, BF_HOST, BF_AT } from '@barefootjs/shared'
23
- import type { LoopChildRefBinding } from './plan/loop.ts'
23
+ import type { LoopChildRefBinding, PreambleRegionPlan } from './plan/loop.ts'
24
+ import type { PreambleRegionSource } from '../../types.ts'
24
25
  import {
25
26
  extractFreeIdentifiersFromText,
26
27
  extractFreeIdentifiersFromStatementText,
@@ -28,7 +29,7 @@ import {
28
29
  } from '../csr-substitute.ts'
29
30
 
30
31
  /**
31
- * Build the `keyFn` argument for mapArray / reconcileElements. `null` when
32
+ * Build the `keyFn` argument for mapArray / mapArrayAnchored. `null` when
32
33
  * the loop has no key expression. Every `CollectedLoop` variant (top-level /
33
34
  * branch / nested) carries an `index: string | null` field (#2218 threaded
34
35
  * it onto `NestedLoop` too), so the index param — when present — is always
@@ -237,6 +238,30 @@ export function destructureLoopParam(
237
238
  return { head: param, unwrap: '' }
238
239
  }
239
240
 
241
+ /**
242
+ * Resolve `IRLoop.preambleRegions` (#2389) into ready-to-emit
243
+ * `PreambleRegionPlan`s: wrap each source expression with the loop-param
244
+ * accessor and, for a `joinArrayChild` source, apply the same array-join
245
+ * ternary `irToHtmlTemplate` uses for the identical node in the row
246
+ * template — so the region-patch effect's re-render matches what a fresh
247
+ * mount would have produced. Shared by the top-level (`build-loop.ts`) and
248
+ * branch (`build-branch-loop.ts`) plain-loop-plan builders.
249
+ */
250
+ export function buildPreambleRegionPlans(
251
+ regions: readonly PreambleRegionSource[] | undefined,
252
+ loopParam: string,
253
+ loopParamBindings: readonly LoopParamBinding[] | undefined,
254
+ ): readonly PreambleRegionPlan[] {
255
+ if (!regions || regions.length === 0) return []
256
+ return regions.map((r) => {
257
+ const wrapped = wrapLoopParamAsAccessor(r.expr, loopParam, loopParamBindings)
258
+ const valueExpr = r.joinArrayChild
259
+ ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
260
+ : `escapeText(${wrapped})`
261
+ return { slotId: r.slotId, valueExpr }
262
+ })
263
+ }
264
+
240
265
  /**
241
266
  * Build a props object expression string from component prop definitions.
242
267
  * Shared by emitComponentLoopReconciliation and emitCompositeElementReconciliation.
@@ -58,6 +58,7 @@ function emitPlain(lines: string[], plan: BranchPlainLoopPlan): void {
58
58
  childRefs,
59
59
  bodyIsMultiRoot,
60
60
  profileLoopId,
61
+ preambleRegions,
61
62
  } = plan
62
63
 
63
64
  const loopBfId = profileLoopId ? `, ${JSON.stringify(profileLoopId)}` : ''
@@ -69,9 +70,29 @@ function emitPlain(lines: string[], plan: BranchPlainLoopPlan): void {
69
70
  // both the effect and its dependency subscriptions (observation O-2).
70
71
  lines.push(` __disposers.push(createDisposableEffect(() => {`)
71
72
 
73
+ // flatMap descriptor mode — build-or-patch renderItem, mirrors the
74
+ // top-level shape in stringify/loop.ts (see comment there).
75
+ if (plan.flatMapLeafItem) {
76
+ lines.push(` if (${containerVar}) mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (__bfD, ${indexParam}, __existing) => {`)
77
+ lines.push(` let __el = __existing`)
78
+ lines.push(` if (!__el) { const __tpl = document.createElement('template'); __tpl.innerHTML = __bfD().h; __el = __tpl.content.firstElementChild }`)
79
+ lines.push(` let __last = __existing ? undefined : __bfD().h`)
80
+ lines.push(` createEffect(() => {`)
81
+ lines.push(` const __html = __bfD().h`)
82
+ lines.push(` if (__last === undefined) { __last = __html; return }`)
83
+ lines.push(` if (__html !== __last) { __last = __html; patchLeaf(__el, __html) }`)
84
+ lines.push(` })`)
85
+ lines.push(` return __el`)
86
+ lines.push(` }, '${markerId}'${loopBfId})`)
87
+ lines.push(` }))`)
88
+ stringifyEventDelegation(lines, eventDelegation)
89
+ return
90
+ }
91
+
72
92
  // Non-empty `childRefs` need `__el` as a handle inside the factory body,
73
- // so force the multi-line layout (#1244).
74
- if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0) {
93
+ // so force the multi-line layout (#1244) — preamble-patched regions
94
+ // (#2389) need the same handle for their own effect.
95
+ if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0 && preambleRegions.length === 0) {
75
96
  // Simple case: single-line renderItem (single root, no reactive effects).
76
97
  const cloneExpr = emitTemplateCloneInline(template)
77
98
  if (mapPreambleWrapped) {
@@ -94,8 +115,17 @@ function emitPlain(lines: string[], plan: BranchPlainLoopPlan): void {
94
115
  indent: ' ',
95
116
  singleRootLayout: 'inline',
96
117
  })
97
- if (reactiveEffects !== null) {
98
- stringifyReactiveEffects(lines, reactiveEffects, { indent: ' ', elVar: '__el', bodyIsMultiRoot })
118
+ if (reactiveEffects !== null || preambleRegions.length > 0) {
119
+ // Row-granularity effects (§3(c)): mirrors `stringifyPlainLoop`'s
120
+ // top-level call — attrs, outer texts, and preamble regions merge
121
+ // into ONE row effect (see `stringifyReactiveEffects`'s docstring).
122
+ stringifyReactiveEffects(lines, reactiveEffects, {
123
+ indent: ' ',
124
+ elVar: '__el',
125
+ bodyIsMultiRoot,
126
+ preambleRegions,
127
+ mapPreambleWrapped,
128
+ })
99
129
  }
100
130
  emitLoopChildRefs(lines, childRefs, { indent: ' ', elVar: '__el', bodyIsMultiRoot })
101
131
  lines.push(` return __el`)
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Claim-plan literal rendering — the compiler-side half of slot unification
3
+ * A3 (`spec/slot-unification.md` §4/§5-A3). The runtime's `SlotSpec`/
4
+ * `ClaimPlan` (`@barefootjs/client/runtime/claim-slots.ts`, A2) are pure
5
+ * data: `{ id, kind, path }` triples. This module is the single place that
6
+ * turns compile-time slot descriptors into that data's source-text form —
7
+ * every content-slot emission site (loop rows, preamble regions, dynamic
8
+ * text/JSX slots, `@client` expressions) renders its plan through
9
+ * `claimPlanLiteral`, never by hand-splicing strings, so the literal shape
10
+ * can only ever match `SlotSpec`.
11
+ *
12
+ * Paths are real root-relative child-index arrays wherever the caller
13
+ * already has them (the hoisted single-root loop skeleton, #2143's
14
+ * `SkeletonSlotPaths`); everywhere else a slot gets `path: []` and A2's
15
+ * `lazySlots`/`claimSlots` marker-scan fallback resolves it — sound, just a
16
+ * per-claim scan instead of an O(depth) walk. This is the explicit
17
+ * "cannot be statically pathed" escape valve `spec/slot-unification.md`
18
+ * §5-A3 sanctions, not a shortcut invented here.
19
+ */
20
+
21
+ export interface ClaimSlotSpec {
22
+ readonly id: string
23
+ readonly kind: 'text' | 'markup'
24
+ /** Root-relative child-index path, used when the path is unconditionally
25
+ * valid at claim time. Ignored when `pathExpr` is set. */
26
+ readonly path: readonly number[]
27
+ /**
28
+ * Raw JS expression (already rendered source text) evaluating to a
29
+ * `number[]` at runtime, for the one case a plain literal can't express:
30
+ * a hoisted-skeleton path that's valid ONLY on the fresh-CSR-clone branch,
31
+ * never on the hydration-adopts-SSR branch (#2143 precedent — `__p` is
32
+ * nulled when `__existing` is truthy because the skeleton's simplified
33
+ * markup doesn't describe the real SSR-rendered tree). Typically
34
+ * `__existing ? [] : [1, 0]`. Overrides `path` when present.
35
+ */
36
+ readonly pathExpr?: string
37
+ /**
38
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
39
+ * true when NO marker was emitted for this slot — `path`'s last index is
40
+ * the slot's own position, not an anchor comment. Only ever set by
41
+ * `client-only-elision.ts`-derived callers; every other emission site
42
+ * omits it (falsy = today's marker-based behavior, unchanged).
43
+ */
44
+ readonly markerless?: boolean
45
+ }
46
+
47
+ /** Render one `SlotSpec` as a source-text object literal. */
48
+ function slotSpecLiteral(slot: ClaimSlotSpec): string {
49
+ const pathSrc = slot.pathExpr ?? `[${slot.path.join(', ')}]`
50
+ const markerlessSrc = slot.markerless ? ', markerless: true' : ''
51
+ return `{ id: '${slot.id}', kind: '${slot.kind}', path: ${pathSrc}${markerlessSrc} }`
52
+ }
53
+
54
+ /** Render a `ClaimPlan` (an array of `SlotSpec`) as a source-text array literal. */
55
+ export function claimPlanLiteral(slots: readonly ClaimSlotSpec[]): string {
56
+ return `[${slots.map(slotSpecLiteral).join(', ')}]`
57
+ }
58
+
59
+ /**
60
+ * A stable JS identifier for the claimed-slot writer covering `slots`,
61
+ * derived from the first slot's id. Slot ids are unique per component (the
62
+ * `bf="sN"` marker namespace), so this can never collide across two
63
+ * distinct claim-plan call sites within the same emitted component.
64
+ */
65
+ export function claimWriterVarName(slots: readonly ClaimSlotSpec[], sanitize: (id: string) => string): string {
66
+ const first = slots[0]?.id ?? '0'
67
+ return `__bfw_${sanitize(first)}`
68
+ }
@@ -74,6 +74,26 @@ function indexBindingLine(handler: string, indexParam: string | null, indexExpr:
74
74
  return `const ${indexParam} = ${indexExpr}`
75
75
  }
76
76
 
77
+ /**
78
+ * Splice-only-when-referenced (#3, BUG-3 fix part 2): a preamble is dead
79
+ * weight for an event whose handler never reads one of the names it
80
+ * declares — the common case (an unused array builder like `cells` in the
81
+ * BUG-3 repro). Only emit `mapPreamble` when `handler`'s free identifiers
82
+ * intersect `declaredNames`; otherwise the delegated handler pays nothing
83
+ * for a preamble it never uses. `declaredNames` is empty whenever
84
+ * `mapPreamble` is `null`, so the `!mapPreamble` short-circuit is mostly
85
+ * redundant but keeps this correct-by-construction if that ever changes.
86
+ */
87
+ function preambleLineForHandler(
88
+ mapPreamble: string | null,
89
+ declaredNames: readonly string[],
90
+ handler: string,
91
+ ): string | null {
92
+ if (!mapPreamble || declaredNames.length === 0) return null
93
+ const free = extractFreeIdentifiersFromText(handler)
94
+ return declaredNames.some((name) => free.has(name)) ? mapPreamble : null
95
+ }
96
+
77
97
  export function stringifyEventDelegation(lines: string[], plan: EventDelegationPlan): void {
78
98
  const { containerVar, events, itemLookup, profileComponentName } = plan
79
99
  const eventsByName = new Map<string, LoopChildEvent[]>()
@@ -133,7 +153,8 @@ function emitKeyedLookup(
133
153
  handlerCall: string,
134
154
  lookup: KeyedItemLookup,
135
155
  ): void {
136
- const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings, indexParam } = lookup
156
+ const { arrayExpr, param, keyWithItem, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
157
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
137
158
 
138
159
  if (ev.nestedLoops.length === 0) {
139
160
  // Single-level keyed lookup.
@@ -146,15 +167,28 @@ function emitKeyedLookup(
146
167
  ls.push(` const __bfLoopItem = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
147
168
  ls.push(` if (__bfLoopItem) {`)
148
169
  ls.push(` const ${param} = __bfLoopItem`)
149
- if (mapPreamble) ls.push(` ${mapPreamble}`)
170
+ if (preambleLine) ls.push(` ${preambleLine}`)
150
171
  if (idxLine) ls.push(` ${idxLine}`)
151
- ls.push(` ${handlerCall}`)
172
+ // Leading `;` (not just relying on the preceding line's own
173
+ // semicolon): `handlerCall` always starts with `(` — an ASI hazard
174
+ // pre-existing in this branch even with neither optional line above
175
+ // (`const ${param} = __bfLoopItem` has no trailing `;`), which glues
176
+ // the call onto it as `__bfLoopItem(...)` and throws
177
+ // `TypeError: __bfLoopItem is not a function`. Defend at the one
178
+ // emission point rather than chasing every preceding-line shape.
179
+ ls.push(` ;${handlerCall}`)
152
180
  ls.push(` }`)
153
181
  } else {
182
+ // The preamble (when referenced) and the handler call both run INSIDE
183
+ // the item null guard — a `.find()` miss (stale-DOM race, e.g. the
184
+ // clicked row's key no longer in the current array) must short-circuit
185
+ // before a preamble that dereferences the item ever runs (BUG-4).
154
186
  ls.push(` const ${param} = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
155
- if (mapPreamble) ls.push(` ${mapPreamble}`)
156
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
157
- else ls.push(` if (${param}) ${handlerCall}`)
187
+ ls.push(` if (${param}) {`)
188
+ if (preambleLine) ls.push(` ${preambleLine}`)
189
+ if (idxLine) ls.push(` ${idxLine}`)
190
+ ls.push(` ;${handlerCall}`)
191
+ ls.push(` }`)
158
192
  }
159
193
  ls.push(` }`)
160
194
  return
@@ -187,10 +221,14 @@ function emitKeyedLookup(
187
221
  }
188
222
  const outerGuard = hasBindings ? '__bfLoopItem' : param
189
223
  const allParams = [outerGuard, ...ev.nestedLoops.map(n => n.param)]
190
- if (mapPreamble) ls.push(` ${mapPreamble}`)
224
+ // Preamble and idx binding run INSIDE the combined item guard (BUG-4)
225
+ // a nested `.find()` miss must short-circuit before the preamble runs.
191
226
  const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === outerKey)`)
192
- if (idxLine) ls.push(` if (${allParams.join(' && ')}) { ${idxLine}; ${handlerCall} }`)
193
- else ls.push(` if (${allParams.join(' && ')}) ${handlerCall}`)
227
+ ls.push(` if (${allParams.join(' && ')}) {`)
228
+ if (preambleLine) ls.push(` ${preambleLine}`)
229
+ if (idxLine) ls.push(` ${idxLine}`)
230
+ ls.push(` ;${handlerCall}`)
231
+ ls.push(` }`)
194
232
  }
195
233
 
196
234
  function emitDynamicIndexLookup(
@@ -199,7 +237,8 @@ function emitDynamicIndexLookup(
199
237
  handlerCall: string,
200
238
  lookup: DynamicIndexItemLookup,
201
239
  ): void {
202
- const { arrayExpr, param, mapPreamble, hasBindings, indexParam } = lookup
240
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
241
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
203
242
  const idxLine = indexBindingLine(ev.handler, indexParam, 'idx')
204
243
  ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('li, [bf-i]')`)
205
244
  ls.push(` if (li && li.parentElement) {`)
@@ -208,15 +247,19 @@ function emitDynamicIndexLookup(
208
247
  ls.push(` const __bfLoopItem = ${arrayExpr}[idx]`)
209
248
  ls.push(` if (__bfLoopItem) {`)
210
249
  ls.push(` const ${param} = __bfLoopItem`)
211
- if (mapPreamble) ls.push(` ${mapPreamble}`)
250
+ if (preambleLine) ls.push(` ${preambleLine}`)
212
251
  if (idxLine) ls.push(` ${idxLine}`)
213
- ls.push(` ${handlerCall}`)
252
+ ls.push(` ;${handlerCall}`)
214
253
  ls.push(` }`)
215
254
  } else {
255
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
256
+ // out-of-range index (stale-DOM race) must short-circuit first.
216
257
  ls.push(` const ${param} = ${arrayExpr}[idx]`)
217
- if (mapPreamble) ls.push(` ${mapPreamble}`)
218
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
219
- else ls.push(` if (${param}) ${handlerCall}`)
258
+ ls.push(` if (${param}) {`)
259
+ if (preambleLine) ls.push(` ${preambleLine}`)
260
+ if (idxLine) ls.push(` ${idxLine}`)
261
+ ls.push(` ;${handlerCall}`)
262
+ ls.push(` }`)
220
263
  }
221
264
  ls.push(` }`)
222
265
  }
@@ -228,7 +271,8 @@ function emitStaticIndexLookup(
228
271
  lookup: StaticIndexItemLookup,
229
272
  containerVar: string,
230
273
  ): void {
231
- const { arrayExpr, param, mapPreamble, offset, indexParam } = lookup
274
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, offset, indexParam } = lookup
275
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
232
276
  const idxLine = indexBindingLine(ev.handler, indexParam, '__idx')
233
277
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
234
278
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
@@ -236,8 +280,12 @@ function emitStaticIndexLookup(
236
280
  const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
237
281
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
238
282
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
239
- if (mapPreamble) ls.push(` ${mapPreamble}`)
240
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
241
- else ls.push(` if (${param}) ${handlerCall}`)
283
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
284
+ // out-of-range index (stale-DOM race) must short-circuit first.
285
+ ls.push(` if (${param}) {`)
286
+ if (preambleLine) ls.push(` ${preambleLine}`)
287
+ if (idxLine) ls.push(` ${idxLine}`)
288
+ ls.push(` ;${handlerCall}`)
289
+ ls.push(` }`)
242
290
  ls.push(` }`)
243
291
  }
@@ -30,11 +30,12 @@
30
30
  * <indent>}) }
31
31
  */
32
32
 
33
- import { keyAttrName, profileBindingId } from '../../utils.ts'
33
+ import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
34
  import { emitComponentAndEventSetup } from '../shared.ts'
35
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
36
36
  import { emitMultiRootTemplateCloneLines, templateRootIsSvg } from './template-parse.ts'
37
37
  import { emitLoopChildRefs } from './loop.ts'
38
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
38
39
  import type {
39
40
  InnerLoopPlan,
40
41
  InnerLoopsPlan,
@@ -110,15 +111,23 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
110
111
  if (inner.childLevels.length > 0) {
111
112
  stringifyInnerLoops(lines, inner.childLevels, `${indent} `, pc)
112
113
  }
113
- for (const text of emit.reactiveTexts) {
114
+ const conditionalTexts = emit.reactiveTexts.filter(t => t.insideConditional)
115
+ const plainTexts = emit.reactiveTexts.filter(t => !t.insideConditional)
116
+ for (const text of conditionalTexts) {
114
117
  const bf = profileBindingId(pc, text.slotId)
115
- if (text.insideConditional) {
116
- // Re-query $t inside the effect: insert() may swap the text node so a
117
- // captured reference would silently stop updating.
118
- lines.push(`${indent} createEffect(() => { const [__rt] = $t(__innerEl${uid}, '${text.slotId}'); if (__rt) __rt.textContent = String(${text.wrappedExpression}) }${bf})`)
119
- } else {
120
- lines.push(`${indent} { const [__rt] = $t(__innerEl${uid}, '${text.slotId}')`)
121
- lines.push(`${indent} if (__rt) createEffect(() => { __rt.textContent = String(${text.wrappedExpression}) }${bf}) }`)
118
+ // A fresh `claimSlots` claim on every run (not the cached `lazySlots`
119
+ // door): insert() may swap the branch's DOM between runs, and 'text'
120
+ // writes are a plain, idempotent `nodeValue` assignment with no
121
+ // dedup/trust-first-run state to go stale so re-claiming here is exactly
122
+ // as safe as, and replaces, the old re-query-$t-on-every-run discipline.
123
+ lines.push(`${indent} createEffect(() => { claimSlots(__innerEl${uid}, [{ id: '${text.slotId}', kind: 'text', path: [] }]).write('${text.slotId}', String(${text.wrappedExpression})) }${bf})`)
124
+ }
125
+ if (plainTexts.length > 0) {
126
+ const slots: ClaimSlotSpec[] = plainTexts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
127
+ const writer = claimWriterVarName(slots, varSlotId)
128
+ lines.push(`${indent} const ${writer} = lazySlots(__innerEl${uid}, ${claimPlanLiteral(slots)})`)
129
+ for (const text of plainTexts) {
130
+ lines.push(`${indent} createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)})`)
122
131
  }
123
132
  }
124
133
  for (const attr of emit.reactiveAttrs) {
@@ -34,6 +34,7 @@ import type { InsertPlan, InsertArm, ArmBody, ScopeRef } from '../plan/types.ts'
34
34
  import { stringifyBranchLoops } from './branch-loop.ts'
35
35
  import { emitListenerLine } from './event-listener.ts'
36
36
  import { nameForRegistryRef } from '../../component-scope.ts'
37
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
37
38
 
38
39
  export interface StringifyInsertOptions {
39
40
  /** Indent on the `insert(` line itself. */
@@ -173,18 +174,23 @@ function emitArmBody(
173
174
  lines.push(`${indent}} }`)
174
175
  }
175
176
 
176
- for (const te of body.textEffects) {
177
- const v = varSlotId(te.slotId)
178
- // Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
177
+ if (body.textEffects.length > 0) {
178
+ // 'markup' kind so a JSX-valued expression (`{cond && logo(id)}`)
179
179
  // re-splices the live element by identity instead of stringifying it to
180
- // "[object HTMLElement]" — the branch template already spliced it via
181
- // `__bfSlot`, and this effect re-renders it when its deps change (#1663).
182
- // The `let` tracker carries the replaced node across reactive re-runs.
183
- lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
184
- lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
185
- lines.push(`${indent} const __val = ${te.expression}`)
186
- lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
187
- lines.push(`${indent}}${bindingBfId(te.slotId)}))`)
180
+ // "[object HTMLElement]" — the same contract `__bfText` used to provide
181
+ // (#1663). Built fresh here, inside this arm's `bindEvents` body, so
182
+ // every branch activation re-claims against that swap's own
183
+ // `__branchScope` DOM (spec/slot-unification.md §5-A3's "branch-internal
184
+ // slots re-claim per branch activation").
185
+ const slots: ClaimSlotSpec[] = body.textEffects.map(te => ({ id: te.slotId, kind: 'markup', path: [] }))
186
+ const writer = claimWriterVarName(slots, varSlotId)
187
+ lines.push(`${indent}const ${writer} = lazySlots(__branchScope, ${claimPlanLiteral(slots)})`)
188
+ for (const te of body.textEffects) {
189
+ // `escapeTextOrNode`: see emit-reactive.ts's identical wrap for why a
190
+ // string value must be escaped before a 'markup' writer's
191
+ // `innerHTML =` insertion, while a live Node passes through untouched.
192
+ lines.push(`${indent}__disposers.push(createDisposableEffect(() => { ${writer}('${te.slotId}', escapeTextOrNode(${te.expression})) }${bindingBfId(te.slotId)}))`)
193
+ }
188
194
  }
189
195
 
190
196
  // Branch loops, now fully Plan-built. The stringifier writes its own
@@ -17,6 +17,7 @@ import { emitAttrUpdate } from '../../emit-reactive.ts'
17
17
  import { templateRootIsSvg } from './template-parse.ts'
18
18
  import { emitListenerLine } from './event-listener.ts'
19
19
  import { nameForRegistryRef } from '../../component-scope.ts'
20
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
20
21
  import type {
21
22
  BranchChildComponentInitsPlan,
22
23
  BranchEventBindingsPlan,
@@ -153,15 +154,22 @@ export function stringifyBranchInnerLoops(
153
154
  inner.outerLoopParamBindings,
154
155
  )
155
156
  }
156
- for (const text of inner.reactiveTexts) {
157
+ const conditionalTexts = inner.reactiveTexts.filter(t => t.insideConditional)
158
+ const plainTexts = inner.reactiveTexts.filter(t => !t.insideConditional)
159
+ for (const text of conditionalTexts) {
157
160
  const bf = profileBindingId(pc, text.slotId)
158
- if (text.insideConditional) {
159
- // Re-query $t inside the effect: insert() may swap the text node so a
160
- // captured reference would silently stop updating.
161
- lines.push(`${indent} createEffect(() => { const [__rt] = $t(__bel${uid}, '${text.slotId}'); if (__rt) __rt.textContent = String(${text.wrappedExpression}) }${bf})`)
162
- } else {
163
- lines.push(`${indent} { const [__rt] = $t(__bel${uid}, '${text.slotId}')`)
164
- lines.push(`${indent} if (__rt) createEffect(() => { __rt.textContent = String(${text.wrappedExpression}) }${bf}) }`)
161
+ // Fresh `claimSlots` claim every run (not the cached `lazySlots` door):
162
+ // insert() may swap the branch's DOM between runs, and a 'text' write
163
+ // is a plain, idempotent `nodeValue` assignment with no dedup state to
164
+ // go stale replaces the old re-query-$t-on-every-run discipline.
165
+ lines.push(`${indent} createEffect(() => { claimSlots(__bel${uid}, [{ id: '${text.slotId}', kind: 'text', path: [] }]).write('${text.slotId}', String(${text.wrappedExpression})) }${bf})`)
166
+ }
167
+ if (plainTexts.length > 0) {
168
+ const slots: ClaimSlotSpec[] = plainTexts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
169
+ const writer = claimWriterVarName(slots, varSlotId)
170
+ lines.push(`${indent} const ${writer} = lazySlots(__bel${uid}, ${claimPlanLiteral(slots)})`)
171
+ for (const text of plainTexts) {
172
+ lines.push(`${indent} createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)})`)
165
173
  }
166
174
  }
167
175
  if (inner.nestedConditionals.length > 0) {
@@ -254,14 +262,25 @@ export function stringifyLoopChildArm(
254
262
  lines.push(`${armIndent}}))`)
255
263
  }
256
264
 
257
- for (const text of arm.texts) {
258
- // __bfText (not a naive `.textContent = String(...)`) so a Child-position
259
- // expression whose value is a live Node (e.g. a hoisted `renderNode={(n)
260
- // => <PillNode/>}` callback, #1213) is spliced into the slot by identity
261
- // instead of being stringified to "[object HTMLElement]" (#2347).
262
- const varName = `__rt_${varSlotId(text.slotId)}`
263
- lines.push(`${armIndent}let ${varName} = $t(__branchScope, '${text.slotId}')[0]`)
264
- lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => { ${varName} = __bfText(${varName}, ${text.wrappedExpression}) }${profileBindingId(pc, text.slotId)}))`)
265
+ if (arm.texts.length > 0) {
266
+ // 'markup' kind (not 'text') because a Child-position expression's value
267
+ // may be a live Node (e.g. a hoisted `renderNode={(n) => <PillNode/>}`
268
+ // callback, #1213) claim-slots' markup writer splices a Node in by
269
+ // identity instead of stringifying it to "[object HTMLElement]" (#2347),
270
+ // the same contract `__bfText` used to provide. The writer is built
271
+ // fresh HERE, inside this arm's `bindEvents` body, so every branch
272
+ // activation re-claims against that swap's own `__branchScope` DOM
273
+ // (spec/slot-unification.md §5-A3's "branch-internal slots re-claim per
274
+ // branch activation").
275
+ const slots: ClaimSlotSpec[] = arm.texts.map(t => ({ id: t.slotId, kind: 'markup', path: [] }))
276
+ const writer = claimWriterVarName(slots, varSlotId)
277
+ lines.push(`${armIndent}const ${writer} = lazySlots(__branchScope, ${claimPlanLiteral(slots)})`)
278
+ for (const text of arm.texts) {
279
+ // `escapeTextOrNode`: see emit-reactive.ts's identical wrap for why a
280
+ // string value must be escaped before a 'markup' writer's
281
+ // `innerHTML =` insertion, while a live Node passes through untouched.
282
+ lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => { ${writer}('${text.slotId}', escapeTextOrNode(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)}))`)
283
+ }
265
284
  }
266
285
 
267
286
  lines.push(`${armIndent}return () => __disposers.forEach(d => d())`)