@barefootjs/jsx 0.25.0 → 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 (41) 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/index.js +248 -110
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  9. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  11. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  16. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/types.d.ts +13 -0
  18. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  19. package/dist/to-locale-date-lowering.d.ts +31 -10
  20. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  21. package/dist/types.d.ts +8 -4
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  25. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  26. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  27. package/src/__tests__/reactive-factory-cross-file.test.ts +252 -1
  28. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  29. package/src/adapters/dangerous-inner-html.ts +101 -48
  30. package/src/analyzer.ts +222 -58
  31. package/src/ir-to-client-js/collect-elements.ts +28 -2
  32. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  33. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  34. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  35. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  36. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  37. package/src/ir-to-client-js/reactivity.ts +61 -7
  38. package/src/ir-to-client-js/types.ts +13 -0
  39. package/src/rich-type-refusal.ts +3 -2
  40. package/src/to-locale-date-lowering.ts +50 -13
  41. package/src/types.ts +8 -4
@@ -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 {
@@ -99,10 +99,11 @@ function pushDiagnostic(
99
99
  // `toLocaleDateString` has a catalogued explicit-input form (#2324 slice
100
100
  // 2) — point the fix at it instead of the generic escape hatches alone.
101
101
  // The implicit-environment forms (zero-arg, locale-only, non-literal
102
- // locale, IANA timeZone) stay refused by design.
102
+ // locale, an unverifiable timeZone literal) stay refused by design;
103
+ // canonical IANA zone literals compile since #2344.
103
104
  const suggestion =
104
105
  method === 'toLocaleDateString' && typeName === 'Date'
105
- ? "Pass a literal locale and an explicit literal timeZone — .toLocaleDateString('ja-JP', { timeZone: 'UTC' }) (or a fixed '±HH:MM' offset) — to compile it to the format_date helper; for a runtime locale, resolve the pattern in your i18n layer and use formatDate(date, pattern, tz) from @barefootjs/client. Alternatively add /* @client */ or pre-compute server-side."
106
+ ? "Pass a literal locale and an explicit literal timeZone — .toLocaleDateString('ja-JP', { timeZone: 'UTC' }), a fixed '±HH:MM' offset, or a canonical IANA zone ID like 'Asia/Tokyo' (exact case; #2344) — to compile it to the format_date helper; for a runtime locale, resolve the pattern in your i18n layer and use formatDate(date, pattern, tz) from @barefootjs/client. Alternatively add /* @client */ or pre-compute server-side."
106
107
  : 'Add /* @client */ to evaluate this expression on the client only, or pre-compute the value server-side.'
107
108
  errors.push({
108
109
  code: ErrorCodes.UNSUPPORTED_JSX_PATTERN,
@@ -34,8 +34,15 @@
34
34
  * there, feeding `formatDate` directly. An OPTIONAL union prop also
35
35
  * declines: `undefined` at runtime makes real `toLocaleDateString` read
36
36
  * the host locale, which no frozen pattern table can reproduce;
37
- * - an IANA `timeZone` name couples output to the host's tzdata version
38
- * (only `'UTC'` and fixed `±HH:MM` offsets are deterministic);
37
+ * - an IANA `timeZone` literal the build machine cannot verify (#2344):
38
+ * a named zone IS admitted when the build's own `Intl` echoes it back
39
+ * verbatim from `resolvedOptions().timeZone` — the canonical primary
40
+ * ID, the one spelling every backend's tzdata resolves identically (the
41
+ * runtime helpers resolve it through Go `time.LoadLocation`, Ruby
42
+ * tzinfo, PHP `DateTimeZone`, Perl `DateTime::TimeZone`, Python
43
+ * `zoneinfo`, Rust `chrono-tz`). Non-canonical case, link/alias names
44
+ * the probe canonicalizes away, and unknown zones all decline — the
45
+ * probe-and-verify discipline of #2334, applied to tz;
39
46
  * - an options bag the probe cannot reproduce EXACTLY in the token+table
40
47
  * vocabulary — era, dayPeriod, 2-digit year, narrow name forms,
41
48
  * non-literal option values ("faithful or loud", never approximate);
@@ -57,17 +64,46 @@ import { resolveReceiverType, baseTypeName } from './rich-type-evidence.ts'
57
64
  import { typeReachesDate } from './date-lowering.ts'
58
65
 
59
66
  /**
60
- * `timeZone` literals the lowering admits: `'UTC'` or a fixed `±HH:MM`
61
- * offset **within ECMA-402's valid offset range** (hours 00–23, minutes
62
- * 00–59). An out-of-range shape like `'+25:00'` or `'+99:99'` must DECLINE
63
- * (→ BF021), not lower: real `toLocaleDateString` throws a RangeError on
64
- * it, so compiling it would render a nonsense offset on the template
65
- * adapters while the JS-native path (Hono, and the pre-rewrite semantics
66
- * the sugar stands in for) crashes — the exact divergence the sugar exists
67
- * to rule out.
67
+ * `timeZone` literals the lowering admits without probing: `'UTC'` or a
68
+ * fixed `±HH:MM` offset **within ECMA-402's valid offset range** (hours
69
+ * 00–23, minutes 00–59). An out-of-range shape like `'+25:00'` or
70
+ * `'+99:99'` must DECLINE (→ BF021), not lower: real `toLocaleDateString`
71
+ * throws a RangeError on it, so compiling it would render a nonsense
72
+ * offset on the template adapters while the JS-native path (Hono, and the
73
+ * pre-rewrite semantics the sugar stands in for) crashes — the exact
74
+ * divergence the sugar exists to rule out. Named IANA zones are admitted
75
+ * through {@link isBuildResolvableTimeZone} instead (#2344).
68
76
  */
69
77
  export const TO_LOCALE_TZ_RE = /^(?:UTC|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
70
78
 
79
+ /** Build-time probe cache: tz literal → verified-canonical? */
80
+ const tzProbeCache = new Map<string, boolean>()
81
+
82
+ /**
83
+ * Probe-and-verify a named-zone `timeZone` literal against the build
84
+ * machine's own `Intl` (#2344, the #2334 discipline applied to tz): admit
85
+ * it iff `resolvedOptions().timeZone` echoes the literal back VERBATIM.
86
+ * The echo check is what pins the canonical primary IANA ID — `Intl`
87
+ * itself accepts case-folded spellings and link/alias names but
88
+ * canonicalizes them in `resolvedOptions()`, and those non-canonical
89
+ * spellings are exactly where backend tzdata layers disagree (Go/Python/
90
+ * Ruby/Rust resolve the exact ID set; PHP folds case; link availability
91
+ * varies by tzdata vintage). Anything the probe can't verify declines
92
+ * (→ BF021) — the `formatDate` primitive stays available for edge cases.
93
+ */
94
+ export function isBuildResolvableTimeZone(value: string): boolean {
95
+ const cached = tzProbeCache.get(value)
96
+ if (cached !== undefined) return cached
97
+ let verified: boolean
98
+ try {
99
+ verified = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone === value
100
+ } catch {
101
+ verified = false
102
+ }
103
+ tzProbeCache.set(value, verified)
104
+ return verified
105
+ }
106
+
71
107
  /**
72
108
  * Probe instant for pattern derivation: 2001-02-03 UTC. Month and day are
73
109
  * distinct single-digit values, so the rendered part text distinguishes both
@@ -452,8 +488,9 @@ export function matchToLocaleDateStringCall(
452
488
  if (callee.kind !== 'member' || callee.computed) return null
453
489
  if (callee.property !== 'toLocaleDateString' || args.length !== 2) return null
454
490
  const [locale, options] = args
455
- // Options bag: `timeZone` is REQUIRED (a literal 'UTC' | valid ±HH:MM
456
- // its omission would read the host timezone); every OTHER key rides into
491
+ // Options bag: `timeZone` is REQUIRED (a literal 'UTC' | valid ±HH:MM |
492
+ // probe-verified canonical IANA zone (#2344) its omission would read
493
+ // the host timezone); every OTHER key rides into
457
494
  // the Intl probe as-is when its value is a string literal (#2334's
458
495
  // fidelity rule: admit any literal options bag the probe can reproduce
459
496
  // exactly, decline everything else — dateStyle, month/weekday forms, …).
@@ -464,7 +501,7 @@ export function matchToLocaleDateStringCall(
464
501
  if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
465
502
  const value = String(prop.value.value)
466
503
  if (prop.key === 'timeZone') {
467
- if (!TO_LOCALE_TZ_RE.test(value)) return null
504
+ if (!TO_LOCALE_TZ_RE.test(value) && !isBuildResolvableTimeZone(value)) return null
468
505
  tz = value
469
506
  } else {
470
507
  probeOptions[prop.key] = value
package/src/types.ts CHANGED
@@ -1525,10 +1525,11 @@ export interface ReactiveFactoryInfo {
1525
1525
 
1526
1526
  /**
1527
1527
  * An import the helper file holds that an inlined factory body references
1528
- * (#2332). Not a BF112 capture: the component file can import the same
1529
- * binding itself. Collected at prescan with the specifier ALREADY rewritten
1530
- * relative to the component file (or unchanged for bare/npm specifiers);
1531
- * the source rewriter injects one deduped import statement per specifier.
1528
+ * (#2332, type-only refs added #2350). Not a BF112 capture: the component
1529
+ * file can import the same binding itself. Collected at prescan with the
1530
+ * specifier ALREADY rewritten relative to the component file (or unchanged
1531
+ * for bare/npm specifiers); the source rewriter injects one deduped import
1532
+ * statement per (specifier, isTypeOnly) pair.
1532
1533
  */
1533
1534
  export interface RequiredFactoryImport {
1534
1535
  /** Local binding name as referenced inside the factory body. */
@@ -1537,6 +1538,9 @@ export interface RequiredFactoryImport {
1537
1538
  exportedName: string
1538
1539
  /** Component-file-relative specifier (`../lib/mathmod`), or the unchanged bare specifier. */
1539
1540
  specifier: string
1541
+ /** True when the factory body only ever references this in type position —
1542
+ * re-provisioned as `import type { ... }` instead of a value import. */
1543
+ isTypeOnly?: boolean
1540
1544
  }
1541
1545
 
1542
1546
  /**