@barefootjs/jsx 0.27.0 → 0.28.1

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 (65) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/compiler.d.ts.map +1 -1
  3. package/dist/errors.d.ts +1 -0
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +955 -273
  8. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -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-insert.d.ts +7 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  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.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +26 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/value-references.d.ts +48 -0
  32. package/dist/value-references.d.ts.map +1 -0
  33. package/package.json +2 -2
  34. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +162 -36
  35. package/src/__tests__/client-js-generation.test.ts +4 -1
  36. package/src/__tests__/composite-branch-loop.test.ts +12 -3
  37. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  38. package/src/__tests__/create-selector.test.ts +21 -7
  39. package/src/__tests__/ir-to-client-js/imports.test.ts +98 -1
  40. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  41. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  42. package/src/__tests__/loop-fallback-wrap.test.ts +31 -12
  43. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  44. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  45. package/src/__tests__/type-only-import-leak.test.ts +60 -0
  46. package/src/analyzer.ts +3 -0
  47. package/src/compiler.ts +6 -2
  48. package/src/errors.ts +11 -0
  49. package/src/index.ts +4 -0
  50. package/src/ir-to-client-js/collect-elements.ts +48 -13
  51. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  52. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  53. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  54. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  55. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  56. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  57. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  58. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +22 -0
  59. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  60. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +6 -1
  61. package/src/ir-to-client-js/control-flow/stringify/loop.ts +23 -0
  62. package/src/ir-to-client-js/control-flow.ts +7 -2
  63. package/src/ir-to-client-js/imports.ts +53 -4
  64. package/src/jsx-to-ir.ts +18 -1
  65. package/src/value-references.ts +108 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Emission pin for the loop-branch-stale-text fix.
3
+ *
4
+ * `summarizeLoopChildBranch` (ir-to-client-js/collect-elements.ts) used to
5
+ * collect NO `reactiveTexts` for ANY loop-row conditional branch whose whole
6
+ * content is a bare `expression` (no wrapping element) — including a plain
7
+ * property read like `row.label`, which can never construct a live DOM
8
+ * node. That blanket skip meant a keyed row's branch text silently froze at
9
+ * its mount-time value whenever the item changed without the conditional's
10
+ * OWN condition flipping (`insert()`, runtime/insert.ts, correctly no-ops
11
+ * when its condition is unchanged — branch-internal updates are the effect
12
+ * system's job).
13
+ *
14
+ * The fix narrows the skip to `node.hasFunctionCalls` (jsx-to-ir.ts's
15
+ * `exprHasFunctionCalls`, a recursive AST walk — catches a call nested
16
+ * anywhere, not just a top-level one) — a call can return a live DOM node
17
+ * (a hoisted `renderNode={(n) => <Pill/>}` callback lowered to a component
18
+ * call, #1211/#1213, pinned by `nested-loop-conditional.test.ts`) and the
19
+ * compiler cannot tell from syntax whether it does, so that shape must keep
20
+ * the skip. Everything call-free is provably incapable of yielding a Node
21
+ * and is now collected.
22
+ *
23
+ * The companion half of the fix lives in `transformConditionalBranch`
24
+ * (jsx-to-ir.ts): a bare loop-item read like `row.label` previously got NO
25
+ * `slotId` at all (`needsSlot` didn't consider a `render-item` free
26
+ * reference), so even with the collect-elements.ts skip narrowed there was
27
+ * nothing for the new effect — or the `<!--bf:sN-->` marker
28
+ * `irToHtmlTemplate` emits for it — to attach to. Both are exercised here
29
+ * together because neither half alone reproduces the fix.
30
+ */
31
+ import { describe, test, expect } from 'bun:test'
32
+ import { compileJSX } from '../compiler'
33
+ import { TestAdapter } from '../adapters/test-adapter'
34
+
35
+ const adapter = new TestAdapter()
36
+
37
+ function clientJsFor(branchTrue: string): string {
38
+ const source = `
39
+ 'use client'
40
+ import { createSignal } from '@barefootjs/client'
41
+ type Props = { format?: (s: string) => string }
42
+ type Row = { id: number; label: string; done: boolean }
43
+ export function C(_p: Props) {
44
+ const [rows] = createSignal<Row[]>([])
45
+ return <ul>{rows().map(row => <li key={row.id}>{row.done ? ${branchTrue} : 'pending'}</li>)}</ul>
46
+ }
47
+ `
48
+ const result = compileJSX(source, 'C.tsx', { adapter })
49
+ const errors = result.errors.filter(e => e.severity === 'error')
50
+ if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
51
+ return result.files.find(f => f.type === 'clientJs')!.content
52
+ }
53
+
54
+ describe('loop-branch-stale-text — reactiveTexts narrowing (collect-elements.ts)', () => {
55
+ test('a bare property-read branch (no call anywhere) gets its own text effect', () => {
56
+ const js = clientJsFor('row.label')
57
+ expect(js).toContain("escapeTextOrNode(row().label)")
58
+ // And a slotId was actually allocated for it — `transformConditionalBranch`'s
59
+ // `refsLoopParam` half of the fix, without which the effect above would
60
+ // have nothing to `lazySlots(...)` claim.
61
+ expect(js).toMatch(/lazySlots\(__branchScope, \[\{ id: 's\d+', kind: 'markup', path: \[\] \}\]\)/)
62
+ })
63
+
64
+ test('a template-literal branch built from property reads only (no call) also gets a text effect', () => {
65
+ const js = clientJsFor('`[${row.label}]`')
66
+ expect(js).toContain('escapeTextOrNode(`[${row().label}]`)')
67
+ })
68
+
69
+ test('a branch with a call ANYWHERE (top-level) keeps the skip — no per-item text effect', () => {
70
+ const js = clientJsFor('_p.format(row.label)')
71
+ expect(js).not.toContain('escapeTextOrNode(_p.format(row().label))')
72
+ // bindEvents for that arm stays empty (mirrors the pre-fix shape exactly).
73
+ expect(js).toMatch(/bindEvents: \(__branchScope, \{ isFirstRun: __bfFirstRun = false \} = \{\}\) => \{\s*\}/m)
74
+ })
75
+
76
+ test('a branch with a call NESTED inside a template literal also keeps the skip', () => {
77
+ // The call is not at the top level of the expression — `exprHasFunctionCalls`
78
+ // is a full recursive AST walk, so this must be caught too.
79
+ const js = clientJsFor('`${_p.format(row.label)}!`')
80
+ expect(js).not.toContain("escapeTextOrNode(`${_p.format(row().label)}!`)")
81
+ })
82
+
83
+ test('a branch with a call nested inside a sub-ternary keeps the skip', () => {
84
+ const js = clientJsFor("row.id > 0 ? _p.format(row.label) : row.label")
85
+ // The outer branch (itself a nested conditional) is unaffected by this
86
+ // narrowing — nested conditionals already get their own `insert()` via
87
+ // `collectLoopChildConditionals`, never through `reactiveTexts`. This
88
+ // case exists to confirm compilation succeeds and the call-bearing leaf
89
+ // still doesn't get a bare reactiveTexts effect wrapping the whole
90
+ // nested-ternary expression text.
91
+ expect(js).not.toContain('escapeTextOrNode(row.id > 0 ? _p.format(row().label) : row().label)')
92
+ })
93
+
94
+ test('an inert literal branch (no loop-param ref, no call) stays exactly as before — no slotId, no effect', () => {
95
+ const js = clientJsFor("'yes'")
96
+ expect(js).not.toContain("escapeTextOrNode('yes')")
97
+ })
98
+ })
@@ -58,7 +58,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
58
58
  const clientJs = getClientJs(source, 'List.tsx')
59
59
  // Dynamic loops emit a `mapArray(` call site. Tighter than
60
60
  // `/mapArray|reconcile/` which would fire on any stray mention.
61
- expect(clientJs).toContain('mapArray(')
61
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
62
62
  expect(clientJs).toContain('items()')
63
63
  })
64
64
 
@@ -83,7 +83,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
83
83
  `
84
84
 
85
85
  const clientJs = getClientJs(source, 'List.tsx')
86
- expect(clientJs).toContain('mapArray(')
86
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
87
87
  expect(clientJs).toContain('getItems()')
88
88
  })
89
89
 
@@ -124,7 +124,11 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
124
124
  `
125
125
 
126
126
  const clientJs = getClientJs(source, 'List.tsx')
127
- expect(clientJs).toMatch(/\bmapArray\s*\(/)
127
+ // `mapArrayLazy` counts: a keyed, single-root, conditional-free row over a
128
+ // PROP array is lazy-row-eligible (spec/slot-unification.md §9.4 — props
129
+ // are the canonical hydration-consistent source), so it takes the lazy
130
+ // reconciler. What #1586 pins is "reconciles at all", not which family.
131
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
128
132
  })
129
133
 
130
134
  test('unrecognised-call chain with filter now reconciles', () => {
@@ -149,7 +153,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
149
153
  `
150
154
 
151
155
  const clientJs = getClientJs(source, 'DoneList.tsx')
152
- expect(clientJs).toContain('mapArray(')
156
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
153
157
  expect(clientJs).toContain('computeItems()')
154
158
  })
155
159
 
@@ -181,7 +185,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
181
185
  // Outer reconciles via mapArray. Also assert the inner loop's array
182
186
  // expression (`o.children`) appears in the emitted template — without
183
187
  // this, the test would pass even if the inner loop were dropped.
184
- expect(clientJs).toContain('mapArray(')
188
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
185
189
  expect(clientJs).toContain('outer()')
186
190
  expect(clientJs).toContain('o.children')
187
191
  })
@@ -219,8 +223,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
219
223
  // mapArray is emitted (widening now applies). renderItem uses the
220
224
  // synthetic `__bfItem` param; references to `cfg` are rewritten
221
225
  // inline and no unwrap statement is emitted.
222
- expect(clientJs).toContain('mapArray(')
223
- expect(clientJs).toContain('(__bfItem, ')
226
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
227
+ // The synthetic accessor param, in whichever reconciler shape applies:
228
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
229
+ // lazy row plan's `const __bfItem = () => __e.item` binding
230
+ // (spec/slot-unification.md §9). Either way the destructured references
231
+ // below must read through the accessor, which is what this pins.
232
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
224
233
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
225
234
  expect(clientJs).toContain('__bfItem()[1].color')
226
235
  })
@@ -247,7 +256,7 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
247
256
  `
248
257
 
249
258
  const clientJs = getClientJs(source, 'Shelf.tsx')
250
- expect(clientJs).toContain('mapArray(')
259
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
251
260
  expect(clientJs).toContain('listOf()')
252
261
  })
253
262
 
@@ -278,8 +287,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
278
287
  `
279
288
 
280
289
  const clientJs = getClientJs(source, 'TypedLegend.tsx')
281
- expect(clientJs).toContain('mapArray(')
282
- expect(clientJs).toContain('(__bfItem, ')
290
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
291
+ // The synthetic accessor param, in whichever reconciler shape applies:
292
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
293
+ // lazy row plan's `const __bfItem = () => __e.item` binding
294
+ // (spec/slot-unification.md §9). Either way the destructured references
295
+ // below must read through the accessor, which is what this pins.
296
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
283
297
  expect(clientJs).not.toContain('const [, cfg] = __bfItem();')
284
298
  expect(clientJs).toContain('__bfItem()[1].color')
285
299
  })
@@ -309,8 +323,13 @@ describe('Solid-style wrap-by-default fallback for loops (#943)', () => {
309
323
  `
310
324
 
311
325
  const clientJs = getClientJs(source, 'Fields.tsx')
312
- expect(clientJs).toContain('mapArray(')
313
- expect(clientJs).toContain('(__bfItem, ')
326
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
327
+ // The synthetic accessor param, in whichever reconciler shape applies:
328
+ // `mapArray`'s renderItem head `(__bfItem, __idx, __existing) =>`, or a
329
+ // lazy row plan's `const __bfItem = () => __e.item` binding
330
+ // (spec/slot-unification.md §9). Either way the destructured references
331
+ // below must read through the accessor, which is what this pins.
332
+ expect(clientJs).toMatch(/\(__bfItem, |const __bfItem = \(\) => __e\.item/)
314
333
  expect(clientJs).not.toContain('const { label, value } = __bfItem();')
315
334
  expect(clientJs).toContain('__bfItem().label')
316
335
  expect(clientJs).toContain('__bfItem().value')
@@ -72,16 +72,20 @@ describe('hoisted loop-body skeleton template (perf)', () => {
72
72
  `
73
73
  const { clientJs, template } = compile(source, 'Bench.tsx')
74
74
 
75
- // Hoisted declaration present, before the mapArray call.
75
+ // Hoisted declaration present, before the reconciler call. This row shape
76
+ // is also lazy-row-eligible (spec/slot-unification.md §9.4), so the
77
+ // reconciler is `mapArrayLazy` and the clone happens in the row plan's
78
+ // `createRow` rather than in a `mapArray` renderItem — the hoisting
79
+ // question this suite pins is orthogonal to which reconciler consumes it.
76
80
  const declIdx = clientJs.indexOf('.innerHTML = `<tr')
77
- const mapArrayIdx = clientJs.indexOf('mapArray(')
81
+ const mapArrayIdx = clientJs.search(/\bmapArray(Lazy)?\(/)
78
82
  expect(declIdx).toBeGreaterThan(-1)
79
83
  expect(mapArrayIdx).toBeGreaterThan(-1)
80
84
  expect(declIdx).toBeLessThan(mapArrayIdx)
81
85
 
82
- // renderItem clones from the hoisted template instead of re-parsing
86
+ // The row is built by cloning the hoisted template instead of re-parsing
83
87
  // innerHTML per row.
84
- expect(clientJs).toMatch(/const __el = __existing \?\? __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
88
+ expect(clientJs).toMatch(/const __el = __tpl_\w+\.content\.firstElementChild\.cloneNode\(true\)/)
85
89
 
86
90
  // The hoisted template itself carries no interpolations and no dynamic
87
91
  // `class` attribute (that's covered by the reactive-attr createEffect).
@@ -172,7 +176,7 @@ describe('hoisted loop-body skeleton template (perf)', () => {
172
176
  }
173
177
  `
174
178
  const { clientJs } = compile(source, 'SpreadList.tsx')
175
- expect(clientJs).toContain('mapArray(')
179
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
176
180
  expect(clientJs).not.toMatch(/__tpl_\w+ = document\.createElement\('template'\)/)
177
181
  })
178
182
 
@@ -195,7 +199,7 @@ describe('hoisted loop-body skeleton template (perf)', () => {
195
199
  }
196
200
  `
197
201
  const { clientJs } = compile(source, 'Dots.tsx')
198
- expect(clientJs).toContain('mapArray(')
202
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\(/)
199
203
  // A `<circle>` root with only dynamic attrs (all covered by reactive-attr
200
204
  // createEffects) and a literal `r="4"` is exactly the shape the fast path
201
205
  // proves safe — this DOES hoist, and it must wrap in a synthetic `<svg>`
@@ -95,7 +95,12 @@ describe('#1247 — static-loop CSR self-heal', () => {
95
95
  }
96
96
  `
97
97
  const clientJs = getClientJs(source, 'PropList.tsx')
98
- expect(clientJs).toMatch(/\bmapArray\s*\(/)
98
+ // Either reconciler shape satisfies the point of this test — the loop must
99
+ // go through the keyed `mapArray` family, not the static `forEach` path.
100
+ // A keyed, single-root, conditional-free row over a prop array is
101
+ // lazy-row-eligible (spec/slot-unification.md §9.4), so it emits
102
+ // `mapArrayLazy`; widen (don't narrow) the assertion.
103
+ expect(clientJs).toMatch(/\bmapArray(Lazy)?\s*\(/)
99
104
  expect(clientJs).not.toMatch(/\.forEach\s*\(/)
100
105
  })
101
106
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * End-to-end compiler pin for issue #2432's minimal reproduction.
3
+ *
4
+ * `collectExternalImports` used to decide which imported specifiers to
5
+ * re-emit into generated client JS with a `\bname\b` text scan over the
6
+ * generated code, never consulting the PER-SPECIFIER `isTypeOnly` flag
7
+ * (only the whole-declaration `import type { ... }` flag). So
8
+ * `import { paperColor, type Theme } from '../lib/theme'` re-emitted
9
+ * `Theme` as a VALUE import whenever the word "Theme" merely appeared in
10
+ * the emitted code — here, as an object key (`{ Theme: 'テーマ' }`).
11
+ *
12
+ * The CLI's relative-import inliner then places every requested name in
13
+ * the target module's top-level IIFE `return { … }`. `Theme` is a
14
+ * type-only export with no runtime binding, so the `return` referenced an
15
+ * undeclared name — `ReferenceError: Theme is not defined` at load,
16
+ * which kills the *entire* page's client JS before hydrate ever runs.
17
+ * The failure is silent from the user's perspective: the SSR-rendered
18
+ * markup is already on screen, so the page looks fine even though no
19
+ * interactivity ever wires up. See piconic-ai/barefootjs#2432.
20
+ */
21
+
22
+ import { describe, test, expect } from 'bun:test'
23
+ import { compileJSX } from '../compiler'
24
+ import { TestAdapter } from '../adapters/test-adapter'
25
+
26
+ const adapter = new TestAdapter()
27
+
28
+ describe('type-only import leak (#2432)', () => {
29
+ test('a per-specifier type-only import is never re-emitted into client JS', () => {
30
+ const source = `
31
+ 'use client'
32
+ import { createSignal } from '@barefootjs/client'
33
+ import { paperColor, type Theme } from '../lib/theme'
34
+
35
+ export function ThemedBadge() {
36
+ const labels = { Theme: 'テーマ' }
37
+ const [color, setColor] = createSignal(paperColor({ paper: '#fff' }))
38
+ return <div title={labels.Theme}>{color()}</div>
39
+ }
40
+ `
41
+
42
+ const result = compileJSX(source, 'ThemedBadge.tsx', { adapter })
43
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
44
+
45
+ const clientJs = result.files.find(f => f.type === 'clientJs')
46
+ expect(clientJs).toBeDefined()
47
+ const code = clientJs!.content
48
+
49
+ // The value specifier survives as a real import.
50
+ expect(code).toContain("import { paperColor } from '../lib/theme'")
51
+
52
+ // No import statement anywhere in the bundle mentions `Theme` — the
53
+ // only legal destination for a value import of that name would be a
54
+ // binding that doesn't exist in the compiled `../lib/theme` module.
55
+ const importLines = code.split('\n').filter(line => line.trim().startsWith('import'))
56
+ for (const line of importLines) {
57
+ expect(line).not.toContain('Theme')
58
+ }
59
+ })
60
+ })
package/src/analyzer.ts CHANGED
@@ -3974,6 +3974,9 @@ function importsBrowserOnlyClientApi(ctx: AnalyzerContext): boolean {
3974
3974
  if (imp.source !== '@barefootjs/client') continue
3975
3975
  if (imp.isTypeOnly) continue
3976
3976
  for (const spec of imp.specifiers) {
3977
+ // A per-specifier type-only import (`import { type onMount }`) is not
3978
+ // a browser-only API use — it has no runtime binding (#2432).
3979
+ if (spec.isTypeOnly) continue
3977
3980
  const importedName = spec.name
3978
3981
  if (BROWSER_ONLY_CLIENT_APIS.has(importedName)) return true
3979
3982
  }
package/src/compiler.ts CHANGED
@@ -18,7 +18,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
18
18
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
19
19
  import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
20
20
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
21
- import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode } from './ir-to-client-js/imports.ts'
21
+ import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
22
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
23
23
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
24
24
  import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
@@ -583,6 +583,7 @@ export function compileJSX(
583
583
  // in the generated body (e.g. an initializer that calls an imported
584
584
  // helper: `createSignal(defaultValue())`).
585
585
  const externalImportLines: string[] = []
586
+ const isUsedAsValue = makeValueUsageTest(body)
586
587
  for (const imp of ctx.imports) {
587
588
  if (imp.isTypeOnly) continue
588
589
  if (imp.source === '@barefootjs/client' || imp.source === RUNTIME_MODULE) continue
@@ -591,7 +592,7 @@ export function compileJSX(
591
592
  continue
592
593
  }
593
594
  const used = imp.specifiers
594
- .filter(s => !s.isDefault && !s.isNamespace && new RegExp(`\\b${s.alias || s.name}\\b`).test(body))
595
+ .filter(s => !s.isDefault && !s.isNamespace && !s.isTypeOnly && isUsedAsValue(s.alias || s.name))
595
596
  .map(s => s.alias ? `${s.name} as ${s.alias}` : s.name)
596
597
  if (used.length > 0) {
597
598
  externalImportLines.push(`import { ${used.join(', ')} } from '${imp.source}'`)
@@ -638,6 +639,9 @@ export function compileJSX(
638
639
  if (imp.isTypeOnly) continue
639
640
  if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
640
641
  for (const spec of imp.specifiers) {
642
+ // A type-only specifier must not force a `.client.js` source
643
+ // rewrite — it has no runtime binding (#2432).
644
+ if (spec.isTypeOnly) continue
641
645
  if (ctx.importedClientSignalNames.has(spec.alias ?? spec.name)) {
642
646
  sources.add(imp.source)
643
647
  break
package/src/errors.ts CHANGED
@@ -53,6 +53,14 @@ export const ErrorCodes = {
53
53
  // or an undeclared component — fail loud with the import to add.
54
54
  BUILTIN_REQUIRES_IMPORT: 'BF054',
55
55
 
56
+ // A relative `.ts` module inlined into a client bundle (`resolveRelativeImports`'s
57
+ // top-level IIFE wrap) was asked for a name it does not export. The IIFE's
58
+ // `return { … }` has no binding for that name, so the reference throws
59
+ // `ReferenceError: <name> is not defined` at load — killing the page's
60
+ // client JS before hydrate. Fail the build instead of shipping the
61
+ // dangling reference (#2432).
62
+ INLINED_IMPORT_MISSING_EXPORT: 'BF055',
63
+
56
64
  // Init statement errors (BF052)
57
65
  UNDECLARED_INIT_STATEMENT_REFERENCE: 'BF052',
58
66
 
@@ -155,6 +163,9 @@ const errorMessages: Record<ErrorCode, string> = {
155
163
  [ErrorCodes.STRIPPED_CLIENT_IMPORT_REFERENCED]:
156
164
  "Import was stripped from the client bundle but its binding is still referenced. Client components ('use client' .tsx) are not callable as plain functions from imperative .ts modules — render them as JSX from a 'use client' parent instead. If the flagged name is a local shadow rather than the stripped import, please file an issue.",
157
165
 
166
+ [ErrorCodes.INLINED_IMPORT_MISSING_EXPORT]:
167
+ 'An inlined relative import requests a name the target module does not export. The client bundle would throw ReferenceError at load.',
168
+
158
169
  [ErrorCodes.STAGE_REACTIVE_IN_TEMPLATE]:
159
170
  'Reactive binding (signal getter or memo) referenced from template scope. The template lambda runs at module scope without the reactive context, so the value cannot be evaluated at SSR. Wrap the JSX expression in /* @client */ to defer it to hydrate, or restructure so the template uses a prop or static value.',
160
171
 
package/src/index.ts CHANGED
@@ -320,6 +320,10 @@ export {
320
320
  // Errors
321
321
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
322
322
 
323
+ // Value-reference classifier (#2432) — shared "is this a real value use"
324
+ // door for import-emission sites and the CLI's stripped-reference scan.
325
+ export { isValueReferenceIdentifier, collectValueReferencedNames } from './value-references.ts'
326
+
323
327
  // Expression Parser
324
328
  export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
325
329
  export type { StyleObjectEntry } from './expression-parser.ts'
@@ -1413,19 +1413,54 @@ function summarizeLoopChildBranch(
1413
1413
  // on the branch root yields exactly this branch's direct bindings
1414
1414
  // without re-collecting what a nested arm already owns (#2347).
1415
1415
  reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1416
- // Skip when the branch's ENTIRE content is a single bare `expression`
1417
- // (no wrapping element) e.g. a hoisted `renderNode={(n) => <Pill/>}`
1418
- // callback (#1211/#1213). That value is already fully re-evaluated and
1419
- // spliced via `__bfSlot` whenever `insert()` (re-)mounts this branch;
1420
- // an *additional* nested createEffect for the same expression re-calls
1421
- // it and creates a second, independent live element (a JSX-callback
1422
- // result isn't idempotent to re-invoke the way a plain signal read is),
1423
- // and the loop-child arm's `$t()`-based anchor lookup designed for
1424
- // text nodes doesn't cleanly displace an already-mounted Element,
1425
- // so the second instance lands beside the first instead of replacing
1426
- // it. A text *nested inside* a static wrapper element in the branch
1427
- // (the element-descent case) is unaffected and still collected below.
1428
- reactiveTexts: node.type === 'expression'
1416
+ // Skip ONLY when the branch's entire content is a single bare
1417
+ // `expression` (no wrapping element) that MAY yield a live DOM node
1418
+ // i.e. it contains a call anywhere (`node.hasFunctionCalls`, computed
1419
+ // by the AST walk in `exprHasFunctionCalls`/jsx-to-ir.ts, recursively
1420
+ // catches a call nested in a template literal or a nested ternary, not
1421
+ // just a top-level call). A call can return an Element (a hoisted
1422
+ // `renderNode={(n) => <Pill/>}` callback lowered to a component call,
1423
+ // #1211/#1213) and the compiler cannot tell from syntax whether it does
1424
+ // — both `renderChild(...)` and `_p.renderCell(...)` are a
1425
+ // `CallExpression` so this stays conservative for ANY call, not just
1426
+ // ones known to return JSX. Re-evaluating a non-idempotent call inside
1427
+ // an *additional* nested createEffect (on top of the eval already done
1428
+ // for the `__bfSlot`-wrapped splice whenever `insert()` (re-)mounts this
1429
+ // branch) would call it again on every unrelated tick and discard the
1430
+ // previous element's listeners/state.
1431
+ //
1432
+ // Everything else — a property access (`row.label`), an identifier, a
1433
+ // literal, a template literal, string concatenation, or a nested
1434
+ // ternary of those — cannot itself construct a DOM node (only a JSX
1435
+ // literal or a call can, and a JSX literal branch is never `type:
1436
+ // 'expression'` in the first place — see `transformJsxExpression`), so
1437
+ // it is safe to collect. This is the fix for the loop-branch-stale-text
1438
+ // defect: a keyed loop row whose branch is e.g. `row.done ? row.label :
1439
+ // 'pending'` previously had NO update path at all when `row.label`
1440
+ // changed without `row.done` flipping — `insert()` (runtime/insert.ts)
1441
+ // correctly no-ops when its condition is unchanged (branch-internal
1442
+ // updates are deliberately the effect system's job, not DOM
1443
+ // replacement's), but with reactiveTexts always `[]` for this shape,
1444
+ // no effect existed either, so the branch was frozen at its mount-time
1445
+ // value. The stale rationale in a prior version of this comment blamed
1446
+ // "the loop-child arm's `$t()`-based anchor lookup", but `$t()` no
1447
+ // longer exists — it was one of four content mechanisms deleted by the
1448
+ // slot-unification work. The current claim door
1449
+ // (`stringifyLoopChildArm` → `lazySlots(..., kind: 'markup')` →
1450
+ // `writeMarkup`, runtime/claim-slots.ts) clears the claimed range and
1451
+ // splices the Node by identity, so "lands beside the first" cannot
1452
+ // happen through it; only the call's non-idempotence still applies, and
1453
+ // that alone is why the skip narrows rather than disappears.
1454
+ //
1455
+ // Collecting here is necessary but not sufficient: `n.slotId` must also
1456
+ // be set for `collectLoopChildReactiveTexts` (ir-to-client-js/
1457
+ // reactivity.ts) to do anything — see `transformConditionalBranch`'s
1458
+ // `refsLoopParam` check (jsx-to-ir.ts) for the matching half of this fix
1459
+ // that gives a bare loop-item-reading branch a slotId at all, which also
1460
+ // makes `irToHtmlTemplate` emit its `<!--bf:sN-->…<!--/-->` marker (the
1461
+ // same call builds both the SSR and the CSR/hydration template, so the
1462
+ // two can't disagree on shape).
1463
+ reactiveTexts: node.type === 'expression' && node.hasFunctionCalls
1429
1464
  ? []
1430
1465
  : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1431
1466
  }
@@ -18,6 +18,7 @@
18
18
  import type { CompositeLoopPlan, LoopChildRefBinding, PreambleRegionPlan } from './loop.ts'
19
19
  import type { EventDelegationPlan } from './event-delegation.ts'
20
20
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
21
+ import type { LazyRowPlanData } from './build-lazy-row.ts'
21
22
 
22
23
  export interface BranchPlainLoopPlan {
23
24
  /**
@@ -89,6 +90,11 @@ export interface BranchPlainLoopPlan {
89
90
  * renderItem layout, mirroring the top-level plain plan.
90
91
  */
91
92
  preambleRegions: readonly PreambleRegionPlan[]
93
+ /**
94
+ * Lazy row graph plan (`spec/slot-unification.md` §9, L3) — see
95
+ * `PlainLoopVariant.lazyRow`. Undefined ⇒ today's eager emission.
96
+ */
97
+ lazyRow?: LazyRowPlanData
92
98
  }
93
99
 
94
100
  export interface BranchCompositeLoopPlan {
@@ -15,6 +15,8 @@ import { buildChainedArrayExpr, varSlotId, wrapLoopParamAsAccessor } from '../..
15
15
  import { buildBranchCompositePlan } from './build-composite-loop.ts'
16
16
  import { buildBranchLoopDelegationPlan } from './build-event-delegation.ts'
17
17
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
18
+ import { buildLazyRowPlan } from './build-lazy-row.ts'
19
+ import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
18
20
  import { destructureLoopParam, loopKeyFn, buildChildRefBindings, buildPreambleRegionPlans } from '../shared.ts'
19
21
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
20
22
  import type {
@@ -23,7 +25,11 @@ import type {
23
25
  BranchPlainLoopPlan,
24
26
  } from './branch-loop.ts'
25
27
 
26
- export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: string): BranchLoopPlan {
28
+ export function buildBranchLoopPlan(
29
+ loop: BranchLoop,
30
+ profileComponentName?: string,
31
+ lazyScope?: LazyRowScopeInfo,
32
+ ): BranchLoopPlan {
27
33
  const containerSlotId = loop.containerSlotId
28
34
  const cv = varSlotId(containerSlotId)
29
35
  const containerVar = `__loop_${cv}`
@@ -45,6 +51,17 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
45
51
 
46
52
  // flatMap descriptor mode — see buildPlainLoopPlan (build-loop.ts).
47
53
  const fm = loop.flatMapClient
54
+ const arrayExpr = fm
55
+ ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
56
+ : buildChainedArrayExpr(loop)
57
+ const indexParam = loop.index || '__idx'
58
+ const mapPreambleWrapped = loop.preamble
59
+ ? renderPreamble(loop.preamble, {
60
+ transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
61
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
62
+ })
63
+ : ''
64
+ const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings)
48
65
  const plan: BranchPlainLoopPlan = {
49
66
  kind: 'plain',
50
67
  rowConstruction: 'string-template',
@@ -52,23 +69,29 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
52
69
  containerVar,
53
70
  markerId: loop.markerId,
54
71
  flatMapLeafItem: fm ? true : undefined,
55
- arrayExpr: fm
56
- ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
57
- : buildChainedArrayExpr(loop),
72
+ arrayExpr,
58
73
  keyFn: fm
59
74
  ? (fm.keyed ? '(__bfD, __bfI) => String(__bfD.k ?? __bfI)' : 'null')
60
75
  : loopKeyFn(loop),
61
76
  paramHead,
62
77
  paramUnwrap,
63
- indexParam: loop.index || '__idx',
78
+ indexParam,
64
79
  // Wrap loop-param references to signal-accessor form so the preamble
65
80
  // matches the template literal's already-wrapped reads (#1065).
66
- mapPreambleWrapped: loop.preamble
67
- ? renderPreamble(loop.preamble, {
68
- transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
69
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
70
- })
71
- : '',
81
+ mapPreambleWrapped,
82
+ // Lazy row graph (§9, L3) — undefined for every ineligible loop.
83
+ lazyRow: buildLazyRowPlan({
84
+ loop,
85
+ arrayExpr,
86
+ indexParam,
87
+ paramUnwrap,
88
+ mapPreambleWrapped,
89
+ preambleRegionCount: preambleRegions.length,
90
+ callSite: 'branch-plain',
91
+ flatMapLeafItem: Boolean(fm),
92
+ anchored: false,
93
+ scope: lazyScope,
94
+ }) ?? undefined,
72
95
  template: loop.template,
73
96
  reactiveEffects: hasReactiveEffects
74
97
  ? buildReactiveEffectsPlan({
@@ -82,7 +105,7 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
82
105
  : null,
83
106
  eventDelegation: buildBranchLoopDelegationPlan(loop, cv, profileComponentName),
84
107
  childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
85
- preambleRegions: buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings),
108
+ preambleRegions,
86
109
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
87
110
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${containerSlotId}` : undefined,
88
111
  }
@@ -14,6 +14,7 @@ import type {
14
14
  } from '../../types.ts'
15
15
  import { addCondAttrToTemplate } from '../../html-template.ts'
16
16
  import { buildBranchLoopPlan } from './build-branch-loop.ts'
17
+ import type { LazyRowScopeInfo } from './lazy-row-eligibility.ts'
17
18
  import type {
18
19
  InsertPlan,
19
20
  InsertArm,
@@ -26,6 +27,12 @@ export interface BuildInsertOptions {
26
27
  eventNameMode: 'dom' | 'raw'
27
28
  /** Owning component name in profile mode (#1690, SR3) — else undefined. */
28
29
  profileComponentName?: string
30
+ /**
31
+ * Component-scope name facts for the lazy row graph gate
32
+ * (`spec/slot-unification.md` §9.4). Threaded down to branch-scoped plain
33
+ * loops; omitted → those loops keep today's eager emission.
34
+ */
35
+ lazyScope?: LazyRowScopeInfo
29
36
  }
30
37
 
31
38
  export function buildInsertPlan(
@@ -88,13 +95,13 @@ function buildArmBody(branch: BranchSummary, options: BuildInsertOptions): ArmBo
88
95
  expression: t.expression,
89
96
  })),
90
97
  // Branch-scoped loops, fully Plan-built (Item 2 final migration).
91
- loops: branch.loops.map(l => buildBranchLoopPlan(l, pc)),
98
+ loops: branch.loops.map(l => buildBranchLoopPlan(l, pc, options.lazyScope)),
92
99
  // Nested conditionals are themselves InsertPlans — built recursively so
93
100
  // the same stringifier handles arbitrary depth. Their scope is always
94
101
  // `__branchScope` (the parent arm's bindEvents argument), regardless of
95
102
  // the outer scope; only the eventNameMode is inherited.
96
103
  conditionals: branch.conditionals.map(c =>
97
- buildInsertPlan(c, { scope: { kind: 'branchScope' }, eventNameMode: options.eventNameMode, profileComponentName: pc }),
104
+ buildInsertPlan(c, { scope: { kind: 'branchScope' }, eventNameMode: options.eventNameMode, profileComponentName: pc, lazyScope: options.lazyScope }),
98
105
  ),
99
106
  }
100
107
  }