@barefootjs/jsx 0.24.1 → 0.26.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 (46) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +52 -24
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/errors.d.ts +1 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.js +473 -165
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  11. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  13. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  15. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  18. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/types.d.ts +13 -0
  20. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  21. package/dist/to-locale-date-lowering.d.ts +31 -10
  22. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  23. package/dist/types.d.ts +29 -5
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  27. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  28. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +833 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
  31. package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
  32. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  33. package/src/adapters/dangerous-inner-html.ts +101 -48
  34. package/src/analyzer.ts +607 -142
  35. package/src/errors.ts +4 -0
  36. package/src/ir-to-client-js/collect-elements.ts +28 -2
  37. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  38. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  39. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  40. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  41. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  42. package/src/ir-to-client-js/reactivity.ts +61 -7
  43. package/src/ir-to-client-js/types.ts +13 -0
  44. package/src/rich-type-refusal.ts +3 -2
  45. package/src/to-locale-date-lowering.ts +50 -13
  46. package/src/types.ts +30 -5
package/src/errors.ts CHANGED
@@ -90,6 +90,7 @@ export const ErrorCodes = {
90
90
  REACTIVE_FACTORY_RENAME_UNSUPPORTED: 'BF111',
91
91
  REACTIVE_FACTORY_MODULE_CAPTURE: 'BF112',
92
92
  REACTIVE_FACTORY_IMPORT_COLLISION: 'BF113',
93
+ REACTIVE_FACTORY_PARAM_SHADOWED: 'BF114',
93
94
  } as const
94
95
 
95
96
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
@@ -184,6 +185,9 @@ const errorMessages: Record<ErrorCode, string> = {
184
185
  'Inlining an imported reactive factory requires re-importing one of its helper ' +
185
186
  'imports into this file, but that name is already bound here to something else. ' +
186
187
  "Rename the conflicting binding in this file, or alias the import in the factory's own file.",
188
+
189
+ [ErrorCodes.REACTIVE_FACTORY_PARAM_SHADOWED]:
190
+ 'Reactive factory parameter is shadowed by a nested declaration inside the factory body, so argument substitution at the inline site would be ambiguous. Rename the inner binding so it does not collide with the parameter.',
187
191
  }
188
192
 
189
193
  // =============================================================================
@@ -1246,8 +1246,13 @@ export function collectLoopChildBindings(
1246
1246
  const bindings = emptyLoopChildBindings()
1247
1247
  for (const child of children) {
1248
1248
  bindings.events.push(...collectLoopChildEventsWithNesting(child))
1249
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings))
1250
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings))
1249
+ // stopAtReactiveConditionals=true (#2347): this function always also
1250
+ // collects nested reactive conditionals below via
1251
+ // `collectLoopChildConditionals`, which gives each its own insert() +
1252
+ // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1253
+ // `.reactiveTexts`) — descending into them here too would double-bind.
1254
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true))
1255
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true))
1251
1256
  bindings.refs.push(...collectLoopChildRefs(child))
1252
1257
  bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
1253
1258
  }
@@ -1342,5 +1347,26 @@ function summarizeLoopChildBranch(
1342
1347
  innerLoops: inner.length > 0 ? inner : undefined,
1343
1348
  conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings),
1344
1349
  events: collectConditionalBranchEvents(node),
1350
+ // Loop-param-aware — reuses the flat loop-item collectors scoped to just
1351
+ // this branch's subtree. Both already stop descending into any further
1352
+ // nested reactive conditional (own insert()/arm), so calling them here
1353
+ // on the branch root yields exactly this branch's direct bindings
1354
+ // without re-collecting what a nested arm already owns (#2347).
1355
+ reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1356
+ // Skip when the branch's ENTIRE content is a single bare `expression`
1357
+ // (no wrapping element) — e.g. a hoisted `renderNode={(n) => <Pill/>}`
1358
+ // callback (#1211/#1213). That value is already fully re-evaluated and
1359
+ // spliced via `__bfSlot` whenever `insert()` (re-)mounts this branch;
1360
+ // an *additional* nested createEffect for the same expression re-calls
1361
+ // it and creates a second, independent live element (a JSX-callback
1362
+ // result isn't idempotent to re-invoke the way a plain signal read is),
1363
+ // and the loop-child arm's `$t()`-based anchor lookup — designed for
1364
+ // text nodes — doesn't cleanly displace an already-mounted Element,
1365
+ // so the second instance lands beside the first instead of replacing
1366
+ // it. A text *nested inside* a static wrapper element in the branch
1367
+ // (the element-descent case) is unaffected and still collected below.
1368
+ reactiveTexts: node.type === 'expression'
1369
+ ? []
1370
+ : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1345
1371
  }
1346
1372
  }
@@ -11,6 +11,8 @@ import type {
11
11
  LoopChildBranchSummary,
12
12
  LoopChildConditional,
13
13
  LoopChildEvent,
14
+ LoopChildReactiveAttr,
15
+ LoopChildReactiveText,
14
16
  NestedLoop,
15
17
  } from '../../types.ts'
16
18
  import type {
@@ -20,9 +22,10 @@ import type {
20
22
  IRProp,
21
23
  LoopParamBinding,
22
24
  } from '../../../types.ts'
23
- import { AttrValueOf } from '../../../types.ts'
25
+ import { AttrValueOf, pickAttrMeta } from '../../../types.ts'
24
26
  import { quotePropName, wrapLoopParamAsAccessor, attrValueToString } from '../../utils.ts'
25
27
  import { addCondAttrToTemplate, irChildrenToJsExpr } from '../../html-template.ts'
28
+ import type { ReactiveAttrSlot } from './reactive-effects.ts'
26
29
 
27
30
  /**
28
31
  * Apply a string-level expression rewriter (loop-param-accessor wrap, prop
@@ -381,6 +384,56 @@ export function buildLoopChildConditionalsPlan(
381
384
  return plans
382
385
  }
383
386
 
387
+ /**
388
+ * Group a branch's reactive attrs by child slot (one qsa per slot),
389
+ * pre-wrapping each expression via the supplied loop-param wrap closure.
390
+ * Shared by every arm builder — outer conditional arms and recursively
391
+ * nested ones alike — so an attr binds inside whichever arm directly owns
392
+ * its element, never a stale outer scope (#2347).
393
+ */
394
+ export function buildArmAttrsPlan(
395
+ attrs: readonly LoopChildReactiveAttr[] | undefined,
396
+ wrap: (expr: string) => string,
397
+ ): readonly ReactiveAttrSlot[] {
398
+ if (!attrs || attrs.length === 0) return []
399
+ const bySlot = new Map<string, LoopChildReactiveAttr[]>()
400
+ for (const attr of attrs) {
401
+ let bucket = bySlot.get(attr.childSlotId)
402
+ if (!bucket) {
403
+ bucket = []
404
+ bySlot.set(attr.childSlotId, bucket)
405
+ }
406
+ bucket.push(attr)
407
+ }
408
+ const slots: ReactiveAttrSlot[] = []
409
+ for (const [slotId, slotAttrs] of bySlot) {
410
+ slots.push({
411
+ slotId,
412
+ attrs: slotAttrs.map(attr => ({
413
+ attrName: attr.attrName,
414
+ wrappedExpression: wrap(attr.expression),
415
+ meta: pickAttrMeta(attr),
416
+ })),
417
+ })
418
+ }
419
+ return slots
420
+ }
421
+
422
+ /**
423
+ * Pre-wrap a branch's reactive text interpolations via the supplied
424
+ * loop-param wrap closure. Shared by every arm builder (#2347).
425
+ */
426
+ export function buildArmTextsPlan(
427
+ texts: readonly LoopChildReactiveText[] | undefined,
428
+ wrap: (expr: string) => string,
429
+ ): readonly import('./loop-child-arm.ts').LoopChildArmText[] {
430
+ if (!texts || texts.length === 0) return []
431
+ return texts.map(text => ({
432
+ slotId: text.slotId,
433
+ wrappedExpression: wrap(text.expression),
434
+ }))
435
+ }
436
+
384
437
  interface BuildLoopChildArmArgs {
385
438
  branch: LoopChildBranchSummary
386
439
  wrap: (expr: string) => string
@@ -413,6 +466,7 @@ function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
413
466
  loopParam,
414
467
  loopParamBindings,
415
468
  }),
416
- texts: [],
469
+ attrs: buildArmAttrsPlan(branch.reactiveAttrs, wrap),
470
+ texts: buildArmTextsPlan(branch.reactiveTexts, wrap),
417
471
  }
418
472
  }
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * Decisions resolved at build time (no longer in the stringifier):
5
5
  * 1. Group reactive attrs by `childSlotId` (one qsa() lookup per slot).
6
+ * `attrs` only ever carries outer-scope entries — the collector that
7
+ * produces it already stops descending into reactive conditionals
8
+ * (#2347), so no branch/outer partition is needed here.
6
9
  * 2. Wrap every attr / text / condition expression via
7
10
  * `wrapLoopParamAsAccessor` so the stringifier never touches the wrap.
8
- * 3. Partition reactive texts: those whose slot id appears inside any
9
- * conditional branch HTML must be emitted *inside* that branch's
10
- * `bindEvents` (insert() may replace the DOM nodes), the rest stay in
11
- * the outer renderItem scope.
11
+ * 3. Each conditional arm gets its own reactive attrs / texts directly
12
+ * from `LoopChildBranchSummary.reactiveAttrs` / `.reactiveTexts`
13
+ * collected per-branch, recursively, so a doubly-nested conditional's
14
+ * arm carries its own bindings instead of an outer scope reaching in.
12
15
  * 4. Apply `addCondAttrToTemplate` to the wrapped branch HTML so the
13
16
  * stringifier emits a ready-to-interpolate template literal.
14
17
  * 5. Recurse into the per-arm sub-plans via `LoopChildArmPlan` —
15
- * events, child component inits, inner loops, nested conditionals.
16
- * No legacy passthrough remains.
18
+ * events, child component inits, inner loops, nested conditionals,
19
+ * attrs, texts. No legacy passthrough remains.
17
20
  */
18
21
 
19
22
  import type {
@@ -28,15 +31,14 @@ import { pickAttrMeta } from '../../../types.ts'
28
31
  import { wrapLoopParamAsAccessor } from '../../utils.ts'
29
32
  import { addCondAttrToTemplate } from '../../html-template.ts'
30
33
  import {
34
+ buildArmAttrsPlan,
35
+ buildArmTextsPlan,
31
36
  buildBranchChildComponentInitsPlan,
32
37
  buildBranchEventBindingsPlan,
33
38
  buildBranchInnerLoopsPlan,
34
39
  buildLoopChildConditionalsPlan,
35
40
  } from './build-loop-child-arm.ts'
36
- import type {
37
- LoopChildArmPlan,
38
- LoopChildArmText,
39
- } from './loop-child-arm.ts'
41
+ import type { LoopChildArmPlan } from './loop-child-arm.ts'
40
42
  import type {
41
43
  NestedConditionalPlan,
42
44
  ReactiveAttrSlot,
@@ -84,57 +86,31 @@ export function buildReactiveEffectsPlan(
84
86
  })
85
87
  }
86
88
 
87
- // 2. Identify text slots that must be deferred into a conditional branch's
88
- // bindEvents. The check mirrors the legacy `cond.whenXxxHtml.includes(
89
- // 'bf:' + slotId)` heuristicthe rendered template HTML is the only
90
- // pre-runtime signal available.
91
- const textSlotsInConditionals = new Set<string>()
92
- if (conditionals) {
93
- for (const cond of conditionals) {
94
- for (const text of texts) {
95
- if (
96
- cond.whenTrueHtml.includes(`bf:${text.slotId}`) ||
97
- cond.whenFalseHtml.includes(`bf:${text.slotId}`)
98
- ) {
99
- textSlotsInConditionals.add(text.slotId)
100
- }
101
- }
102
- }
103
- }
104
-
105
- const outerTexts: ReactiveTextEffect[] = []
106
- for (const text of texts) {
107
- if (textSlotsInConditionals.has(text.slotId)) continue
108
- outerTexts.push({
109
- slotId: text.slotId,
110
- wrappedExpression: wrap(text.expression),
111
- })
112
- }
89
+ // 2. Outer text effects. `texts` (elem.bindings.reactiveTexts) is produced
90
+ // by `collectLoopChildReactiveTexts`, which already stops descending
91
+ // into any reactive (slotId'd) conditionalso every entry here is
92
+ // genuinely outer-scope, no HTML-substring partition needed (#2347).
93
+ const outerTexts: ReactiveTextEffect[] = texts.map(text => ({
94
+ slotId: text.slotId,
95
+ wrappedExpression: wrap(text.expression),
96
+ }))
113
97
 
114
- // 3. Per-conditional plans. Branch-scoped texts are partitioned by which
115
- // branch HTML mentions the slot (declaration order preserved). The
116
- // arm bodies are fully Plan-builtno legacy passthrough.
98
+ // 3. Per-conditional plans. Each arm's own reactive attrs / texts come
99
+ // from `LoopChildBranchSummary.reactiveAttrs` / `.reactiveTexts`
100
+ // collected directly on that branch's subtree (#2347) instead of a
101
+ // flat list partitioned by searching the rendered branch HTML for a
102
+ // `bf:<slotId>` marker. The arm bodies are fully Plan-built — no
103
+ // legacy passthrough.
117
104
  const conditionalPlans: NestedConditionalPlan[] = []
118
105
  if (conditionals) {
119
106
  for (const cond of conditionals) {
120
- const trueTexts: LoopChildArmText[] = []
121
- const falseTexts: LoopChildArmText[] = []
122
- for (const text of texts) {
123
- if (!textSlotsInConditionals.has(text.slotId)) continue
124
- const wrapped: LoopChildArmText = {
125
- slotId: text.slotId,
126
- wrappedExpression: wrap(text.expression),
127
- }
128
- if (cond.whenTrueHtml.includes(`bf:${text.slotId}`)) trueTexts.push(wrapped)
129
- if (cond.whenFalseHtml.includes(`bf:${text.slotId}`)) falseTexts.push(wrapped)
130
- }
131
107
  conditionalPlans.push({
132
108
  slotId: cond.slotId,
133
109
  wrappedCondition: wrap(cond.condition),
134
110
  whenTrueTemplateHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
135
111
  whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
136
- whenTrueArm: buildOuterArm(cond.whenTrue, trueTexts, wrap, loopParam, loopParamBindings, profileComponentName),
137
- whenFalseArm: buildOuterArm(cond.whenFalse, falseTexts, wrap, loopParam, loopParamBindings, profileComponentName),
112
+ whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, profileComponentName),
113
+ whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, profileComponentName),
138
114
  })
139
115
  }
140
116
  }
@@ -149,7 +125,6 @@ export function buildReactiveEffectsPlan(
149
125
 
150
126
  function buildOuterArm(
151
127
  branch: LoopChildBranchSummary,
152
- texts: readonly LoopChildArmText[],
153
128
  wrap: (expr: string) => string,
154
129
  loopParam: string,
155
130
  loopParamBindings: readonly LoopParamBinding[] | undefined,
@@ -179,7 +154,8 @@ function buildOuterArm(
179
154
  loopParam,
180
155
  loopParamBindings,
181
156
  }),
182
- texts,
157
+ attrs: buildArmAttrsPlan(branch.reactiveAttrs, wrap),
158
+ texts: buildArmTextsPlan(branch.reactiveTexts, wrap),
183
159
  }
184
160
  }
185
161
 
@@ -152,8 +152,7 @@ export type BranchInnerLoopsPlan = readonly BranchInnerLoop[]
152
152
 
153
153
  /**
154
154
  * One branch-scoped reactive text effect (slotId + already-wrapped
155
- * expression). The top-level outer conditional's arms emit these inside
156
- * `bindEvents`; recursive nested conditionals never carry texts.
155
+ * expression), attached inside the arm's `bindEvents`.
157
156
  */
158
157
  export interface LoopChildArmText {
159
158
  slotId: string
@@ -169,16 +168,23 @@ export interface LoopChildArmText {
169
168
  * - `childComponents` — `buildBranchChildComponentInitsPlan`
170
169
  * - `innerLoops` — `buildBranchInnerLoopsPlan`
171
170
  * - `nestedConditionals`— `buildLoopChildConditionalPlan` (recursive)
171
+ * - `attrs` — reactive attrs on elements directly inside the
172
+ * arm, grouped by slot (#2347)
173
+ * - `texts` — reactive text interpolations on elements
174
+ * directly inside the arm (#2347)
172
175
  *
173
- * `texts` is only populated for the *outer* conditional the recursion
174
- * branch (`emitNestedLoopChildConditionals` legacy) never threaded text
175
- * effects through, so nested arms always carry an empty list.
176
+ * Both `attrs` and `texts` are collected per-arm at every nesting depth
177
+ * an element inside a doubly-nested conditional gets its bindings from the
178
+ * innermost enclosing arm, never from an outer scope's own initial-clone
179
+ * query, so a branch swap (`insert()` mounting a fresh node) never leaves a
180
+ * binding pointed at a detached element.
176
181
  */
177
182
  export interface LoopChildArmPlan {
178
183
  events: BranchEventBindingsPlan
179
184
  childComponents: BranchChildComponentInitsPlan
180
185
  innerLoops: BranchInnerLoopsPlan
181
186
  nestedConditionals: readonly LoopChildConditionalPlan[]
187
+ attrs: readonly import('./reactive-effects.ts').ReactiveAttrSlot[]
182
188
  texts: readonly LoopChildArmText[]
183
189
  }
184
190
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { varSlotId, DATA_BF_PH, keyAttrName, profileBindingId } from '../../utils.ts'
15
15
  import { emitComponentAndEventSetup } from '../shared.ts'
16
+ import { emitAttrUpdate } from '../../emit-reactive.ts'
16
17
  import { templateRootIsSvg } from './template-parse.ts'
17
18
  import { emitListenerLine } from './event-listener.ts'
18
19
  import { nameForRegistryRef } from '../../component-scope.ts'
@@ -23,6 +24,43 @@ import type {
23
24
  LoopChildArmPlan,
24
25
  LoopChildConditionalPlan,
25
26
  } from '../plan/loop-child-arm.ts'
27
+ import type { ReactiveAttrSlot } from '../plan/reactive-effects.ts'
28
+
29
+ /**
30
+ * Emit reactive attribute effects for one arm — one qsa() per slot, then one
31
+ * `createDisposableEffect` per attr on that slot, pushed onto the caller's
32
+ * `__disposers` array. Mirrors the outer renderItem-scope attr emission in
33
+ * `stringifyReactiveEffects` (#2347): binding attrs inside the arm that owns
34
+ * the element (instead of an outer scope's own initial clone) means a branch
35
+ * swap's fresh `insert()`-mounted node is always the one the effect targets.
36
+ *
37
+ * `createDisposableEffect` (not `createEffect`) so the caller's `bindEvents`
38
+ * can return a cleanup that disposes this effect on the NEXT branch swap —
39
+ * otherwise it would keep running against the node this branch swap just
40
+ * detached, and every subsequent swap stacks another orphaned effect.
41
+ * Callers must declare `const __disposers = []` before emitting this and
42
+ * return `() => __disposers.forEach(d => d())` from `bindEvents`.
43
+ */
44
+ export function stringifyBranchReactiveAttrs(
45
+ lines: string[],
46
+ plan: readonly ReactiveAttrSlot[],
47
+ indent: string,
48
+ pc?: string,
49
+ ): void {
50
+ for (const slot of plan) {
51
+ const varName = `__ra_${varSlotId(slot.slotId)}`
52
+ lines.push(`${indent}{ const ${varName} = qsa(__branchScope, '[bf="${slot.slotId}"]')`)
53
+ lines.push(`${indent}if (${varName}) {`)
54
+ for (const attr of slot.attrs) {
55
+ lines.push(`${indent} __disposers.push(createDisposableEffect(() => {`)
56
+ for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
57
+ lines.push(`${indent} ${stmt}`)
58
+ }
59
+ lines.push(`${indent} }${profileBindingId(pc, slot.slotId)}))`)
60
+ }
61
+ lines.push(`${indent}} }`)
62
+ }
63
+ }
26
64
 
27
65
  /**
28
66
  * Emit `addEventListener` setup for a loop-cond branch arm. One qsa() per
@@ -176,7 +214,15 @@ function stringifyLoopChildConditional(
176
214
  lines.push(`${indent}}${profileBindingId(pc, cond.slotId)})`)
177
215
  }
178
216
 
179
- function stringifyLoopChildArm(
217
+ /**
218
+ * Emit one arm's full body: events, child component inits, inner loops,
219
+ * then a disposable section (reactive attrs, nested conditionals, texts)
220
+ * whose aggregate cleanup `bindEvents` returns to `insert()` (#2347 follow-up).
221
+ * Shared by both the loop-scoped-conditional stringifier in this file and
222
+ * the outer (top-of-loop-item) conditional stringifier in
223
+ * `reactive-effects.ts` — the two arm shapes are identical.
224
+ */
225
+ export function stringifyLoopChildArm(
180
226
  lines: string[],
181
227
  arm: LoopChildArmPlan,
182
228
  armIndent: string,
@@ -185,10 +231,38 @@ function stringifyLoopChildArm(
185
231
  stringifyBranchEventBindings(lines, arm.events, armIndent)
186
232
  stringifyBranchChildComponentInits(lines, arm.childComponents, armIndent)
187
233
  stringifyBranchInnerLoops(lines, arm.innerLoops, armIndent, pc)
188
- stringifyLoopChildConditionals(lines, arm.nestedConditionals, armIndent, pc)
234
+
235
+ // Disposable section: reactive attrs + text effects + nested conditionals.
236
+ // `bindEvents` returns the aggregate cleanup so `insert()` disposes this
237
+ // arm's scoped effects (and any nested insert()'s own effect tree) before
238
+ // the NEXT branch swap — otherwise they'd keep running against the node
239
+ // this swap just detached, stacking another orphaned effect on every
240
+ // subsequent toggle (#2347 follow-up).
241
+ const hasDisposables = arm.attrs.length > 0 || arm.texts.length > 0 || arm.nestedConditionals.length > 0
242
+ if (!hasDisposables) return
243
+
244
+ lines.push(`${armIndent}const __disposers = []`)
245
+ stringifyBranchReactiveAttrs(lines, arm.attrs, armIndent, pc)
246
+
247
+ // Nested conditionals: each inner `insert()` is wrapped in its own
248
+ // disposable effect so disposing THIS entry cascades to whatever
249
+ // reactive state that insert() set up internally (mirrors the top-level
250
+ // conditional's nested-conditional handling in stringify/insert.ts).
251
+ for (const cond of arm.nestedConditionals) {
252
+ lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => {`)
253
+ stringifyLoopChildConditional(lines, cond, `${armIndent} `, pc)
254
+ lines.push(`${armIndent}}))`)
255
+ }
256
+
189
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).
190
262
  const varName = `__rt_${varSlotId(text.slotId)}`
191
- lines.push(`${armIndent}{ const [${varName}] = $t(__branchScope, '${text.slotId}')`)
192
- lines.push(`${armIndent}if (${varName}) createEffect(() => { ${varName}.textContent = String(${text.wrappedExpression}) }${profileBindingId(pc, 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)}))`)
193
265
  }
266
+
267
+ lines.push(`${armIndent}return () => __disposers.forEach(d => d())`)
194
268
  }
@@ -10,13 +10,7 @@
10
10
 
11
11
  import { varSlotId, profileBindingId } from '../../utils.ts'
12
12
  import { emitAttrUpdate } from '../../emit-reactive.ts'
13
- import {
14
- stringifyBranchChildComponentInits,
15
- stringifyBranchEventBindings,
16
- stringifyBranchInnerLoops,
17
- stringifyLoopChildConditionals,
18
- } from './loop-child-arm.ts'
19
- import type { LoopChildArmPlan, LoopChildArmText } from '../plan/loop-child-arm.ts'
13
+ import { stringifyLoopChildArm } from './loop-child-arm.ts'
20
14
  import type {
21
15
  NestedConditionalPlan,
22
16
  ReactiveEffectsPlan,
@@ -126,28 +120,12 @@ function emitOuterConditional(
126
120
  lines.push(`${indent}insert(${elVar}, '${cond.slotId}', () => ${cond.wrappedCondition}, {`)
127
121
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenTrueTemplateHtml}\`, slots: __slots } },`)
128
122
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
129
- emitArmBody(lines, cond.whenTrueArm, armIndent, pc)
123
+ stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc)
130
124
  lines.push(`${indent} }`)
131
125
  lines.push(`${indent}}, {`)
132
126
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenFalseTemplateHtml}\`, slots: __slots } },`)
133
127
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
134
- emitArmBody(lines, cond.whenFalseArm, armIndent, pc)
128
+ stringifyLoopChildArm(lines, cond.whenFalseArm, armIndent, pc)
135
129
  lines.push(`${indent} }`)
136
130
  lines.push(`${indent}}${profileBindingId(pc, cond.slotId)})`)
137
131
  }
138
-
139
- function emitArmBody(lines: string[], arm: LoopChildArmPlan, armIndent: string, pc: string | undefined): void {
140
- stringifyBranchEventBindings(lines, arm.events, armIndent)
141
- stringifyBranchChildComponentInits(lines, arm.childComponents, armIndent)
142
- stringifyBranchInnerLoops(lines, arm.innerLoops, armIndent, pc)
143
- stringifyLoopChildConditionals(lines, arm.nestedConditionals, armIndent, pc)
144
- for (const text of arm.texts) {
145
- emitArmText(lines, armIndent, text, pc)
146
- }
147
- }
148
-
149
- function emitArmText(lines: string[], indent: string, text: LoopChildArmText, pc: string | undefined): void {
150
- const varName = `__rt_${varSlotId(text.slotId)}`
151
- lines.push(`${indent}{ const [${varName}] = $t(__branchScope, '${text.slotId}')`)
152
- lines.push(`${indent}if (${varName}) createEffect(() => { ${varName}.textContent = String(${text.wrappedExpression}) }${profileBindingId(pc, text.slotId)}) }`)
153
- }
@@ -265,14 +265,32 @@ export function collectEventHandlersFromIR(node: IRNode): string[] {
265
265
  /**
266
266
  * Traverse an IR tree depth-first, calling visitor for each element node.
267
267
  * Shared by collectConditionalBranchEvents, collectConditionalBranchRefs,
268
- * and collectLoopChildEvents to avoid duplicating the traversal logic.
268
+ * collectLoopChildRefs, and collectLoopChildEvents to avoid duplicating the
269
+ * traversal logic.
269
270
  *
270
271
  * 'loop' is intentionally skipped. Nested .map() event delegation requires
271
272
  * a different approach (nested data-key lookup + inner loop variable
272
273
  * resolution) that isn't implemented yet. See memory:
273
274
  * compiler-reconcile-templates-events.md
275
+ *
276
+ * `stopAtReactiveConditionals` (#2347): when true, a nested `conditional`
277
+ * with a `slotId` is NOT descended into — the caller is one that also
278
+ * separately collects that nested conditional via `collectBranchConditionals`
279
+ * / `collectLoopChildConditionals` and gives it its own `insert()` call, so
280
+ * descending here too would double-bind (against the enclosing scope's own
281
+ * initial template node, in addition to the nested conditional's own
282
+ * `bindEvents` against whatever node `insert()` mounts). Defaults to false
283
+ * because several callers (e.g. a plain nested `.map()`'s own per-item
284
+ * bindings, `collectInnerLoops`'s non-branch path) have no such sibling
285
+ * collector — for those, a slotId'd conditional's content is ONLY ever
286
+ * reachable by unconditionally descending here, since it's inlined directly
287
+ * into that scope's template rather than switched via its own `insert()`.
274
288
  */
275
- function traverseElements(node: IRNode, visitor: (el: IRElement, domDepth: number) => void): void {
289
+ function traverseElements(
290
+ node: IRNode,
291
+ visitor: (el: IRElement, domDepth: number) => void,
292
+ stopAtReactiveConditionals = false,
293
+ ): void {
276
294
  walkIR(node, 0, {
277
295
  element: ({ node: el, scope: domDepth, descend }) => {
278
296
  visitor(el, domDepth)
@@ -281,12 +299,25 @@ function traverseElements(node: IRNode, visitor: (el: IRElement, domDepth: numbe
281
299
  loop: () => {
282
300
  // skip: nested-loop event delegation is handled separately
283
301
  },
302
+ ...(stopAtReactiveConditionals
303
+ ? {
304
+ conditional: ({ node: c, scope: domDepth, descend }: { node: IRNode & { type: 'conditional' }; scope: number; descend: (s: number) => void }) => {
305
+ if (!c.slotId) descend(domDepth)
306
+ },
307
+ }
308
+ : {}),
284
309
  })
285
310
  }
286
311
 
287
312
  /**
288
313
  * Collect events from a conditional branch for use with insert().
289
314
  * These events will be bound via the branch's bindEvents function.
315
+ *
316
+ * Always stops at a nested reactive conditional (#2347): every caller
317
+ * (`summarizeBranch`, `summarizeLoopChildBranch`) also collects that nested
318
+ * conditional separately (`collectBranchConditionals` /
319
+ * `collectLoopChildConditionals`), so its own `insert()`/bindEvents owns
320
+ * its events.
290
321
  */
291
322
  export function collectConditionalBranchEvents(node: IRNode): ConditionalBranchEvent[] {
292
323
  const events: ConditionalBranchEvent[] = []
@@ -300,13 +331,17 @@ export function collectConditionalBranchEvents(node: IRNode): ConditionalBranchE
300
331
  })
301
332
  }
302
333
  }
303
- })
334
+ }, true)
304
335
  return events
305
336
  }
306
337
 
307
338
  /**
308
339
  * Collect refs from a conditional branch for use with insert().
309
340
  * These refs will be called via the branch's bindEvents function.
341
+ *
342
+ * Always stops at a nested reactive conditional (#2347): its sole caller
343
+ * (`summarizeBranch`) also collects that nested conditional separately via
344
+ * `collectBranchConditionals`, so its own `insert()`/bindEvents owns its refs.
310
345
  */
311
346
  export function collectConditionalBranchRefs(node: IRNode): ConditionalBranchRef[] {
312
347
  const refs: ConditionalBranchRef[] = []
@@ -317,7 +352,7 @@ export function collectConditionalBranchRefs(node: IRNode): ConditionalBranchRef
317
352
  callback: el.ref,
318
353
  })
319
354
  }
320
- })
355
+ }, true)
321
356
  return refs
322
357
  }
323
358
 
@@ -511,12 +546,21 @@ function traverseForComponents(
511
546
  * Includes expressions that read signals OR reference the loop parameter.
512
547
  * With per-item signals, loop param access (item().text) is reactive
513
548
  * because item is a signal accessor.
549
+ *
550
+ * `stopAtReactiveConditionals` (#2347): pass true only when the caller also
551
+ * separately collects nested reactive conditionals (via
552
+ * `collectLoopChildConditionals`) and gives each its own `insert()` /
553
+ * bindEvents — otherwise a text inside such a conditional would bind twice.
554
+ * Defaults to false: some callers (e.g. a plain nested `.map()`'s own
555
+ * per-item bindings) have no sibling conditional collector, so a slotId'd
556
+ * conditional's inlined content is only ever reachable by descending here.
514
557
  */
515
558
  export function collectLoopChildReactiveTexts(
516
559
  node: IRNode,
517
560
  ctx: ClientJsContext,
518
561
  loopParam?: string,
519
562
  loopParamBindings?: readonly LoopParamBinding[],
563
+ stopAtReactiveConditionals = false,
520
564
  ): LoopChildReactiveText[] {
521
565
  const texts: LoopChildReactiveText[] = []
522
566
  walkIR(node, false, {
@@ -549,8 +593,13 @@ export function collectLoopChildReactiveTexts(
549
593
  ...(expanded.freeIds !== undefined && { freeIdentifiers: expanded.freeIds }),
550
594
  })
551
595
  },
552
- conditional: ({ descend }) => {
553
- descend(true)
596
+ conditional: ({ node: c, descend }) => {
597
+ // When stopAtReactiveConditionals is set, a reactive conditional
598
+ // (slotId set) gets its own insert() call that rebinds its branch
599
+ // text effects independently — descending here too would bind the
600
+ // same expression twice, once against the enclosing scope's initial
601
+ // node and once against insert()'s mounted node (#2347).
602
+ if (!stopAtReactiveConditionals || !c.slotId) descend(true)
554
603
  },
555
604
  })
556
605
  return texts
@@ -560,12 +609,17 @@ export function collectLoopChildReactiveTexts(
560
609
  * Collect reactive attributes from loop children.
561
610
  * These are dynamic attributes that read signals and need createEffect
562
611
  * to update the DOM when signals change.
612
+ *
613
+ * `stopAtReactiveConditionals` (#2347): see `collectLoopChildReactiveTexts` —
614
+ * same semantics. Pass true only when the caller also separately collects
615
+ * nested reactive conditionals and gives each its own `insert()`.
563
616
  */
564
617
  export function collectLoopChildReactiveAttrs(
565
618
  node: IRNode,
566
619
  ctx: ClientJsContext,
567
620
  loopParam?: string,
568
621
  loopParamBindings?: readonly LoopParamBinding[],
622
+ stopAtReactiveConditionals = false,
569
623
  ): LoopChildReactiveAttr[] {
570
624
  const attrs: LoopChildReactiveAttr[] = []
571
625
  traverseElements(node, (el) => {
@@ -617,6 +671,6 @@ export function collectLoopChildReactiveAttrs(
617
671
  })
618
672
  }
619
673
  }
620
- })
674
+ }, stopAtReactiveConditionals)
621
675
  return attrs
622
676
  }
@@ -512,6 +512,19 @@ export interface LoopChildBranchSummary {
512
512
  conditionals?: LoopChildConditional[]
513
513
  /** Events on elements inside the branch — attached via insert() bindEvents (#839). */
514
514
  events?: ConditionalBranchEvent[]
515
+ /**
516
+ * Reactive attrs on elements directly inside the branch — attached via
517
+ * insert() bindEvents (#2347). Collected loop-param-aware so `u().active`
518
+ * style loop-item accessors classify as reactive; stops at any further
519
+ * nested reactive conditional (which gets its own insert() + arm).
520
+ */
521
+ reactiveAttrs?: LoopChildReactiveAttr[]
522
+ /**
523
+ * Reactive text interpolations on elements directly inside the branch —
524
+ * attached via insert() bindEvents (#2347). Same loop-param-aware /
525
+ * nested-conditional-stopping semantics as `reactiveAttrs`.
526
+ */
527
+ reactiveTexts?: LoopChildReactiveText[]
515
528
  }
516
529
 
517
530
  export interface LoopChildConditional {