@barefootjs/jsx 0.29.0 → 0.30.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 (87) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +876 -352
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  25. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  27. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  29. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/types.d.ts +12 -0
  31. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/rich-type-evidence.d.ts.map +1 -1
  34. package/dist/ssr-defaults.d.ts.map +1 -1
  35. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  36. package/dist/types.d.ts +78 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  40. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  41. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  42. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  43. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  44. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  45. package/src/__tests__/lazy-conditional.test.ts +190 -0
  46. package/src/__tests__/lazy-preamble.test.ts +297 -0
  47. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  48. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  50. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  51. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  52. package/src/__tests__/preamble-declarations.test.ts +207 -0
  53. package/src/__tests__/ssr-defaults.test.ts +44 -0
  54. package/src/adapters/child-scope.ts +22 -0
  55. package/src/adapters/loop-bound-names.ts +6 -0
  56. package/src/analyzer-context.ts +43 -0
  57. package/src/analyzer.ts +33 -3
  58. package/src/index.ts +1 -0
  59. package/src/ir-to-client-js/collect-elements.ts +51 -10
  60. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  61. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  62. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  63. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  65. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  68. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  70. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  71. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  72. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  73. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  74. package/src/ir-to-client-js/emit-registration.ts +1 -1
  75. package/src/ir-to-client-js/html-template.ts +51 -40
  76. package/src/ir-to-client-js/index.ts +1 -1
  77. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  78. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  79. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  80. package/src/ir-to-client-js/reactivity.ts +25 -0
  81. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  82. package/src/ir-to-client-js/types.ts +12 -0
  83. package/src/jsx-to-ir.ts +400 -24
  84. package/src/rich-type-evidence.ts +6 -7
  85. package/src/ssr-defaults.ts +7 -2
  86. package/src/to-locale-date-lowering.ts +8 -2
  87. package/src/types.ts +80 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * CSR template scope soundness (#2468 / adapter-tests scope-gate holes).
3
+ *
4
+ * The `hydrate('X', { template: (_p) => ... })` lambda runs at module
5
+ * scope, so every identifier it references must be either `_p`, a
6
+ * template-local binding, or a global. These tests pin the three
7
+ * emission paths that leaked init-scoped bindings into the lambda
8
+ * (each one a guaranteed `ReferenceError` on CSR mount):
9
+ *
10
+ * A. a memo body inlined into the template kept its bare destructured
11
+ * prop refs (`(value * 10)` instead of `(_p.value * 10)`);
12
+ * B. a component prop carrying a template literal over a destructured
13
+ * prop rendered into `renderChild(...)` un-rewritten
14
+ * (`${className}` instead of `${_p.className}`);
15
+ * C. a getter-elided signal (`const [, setActive] = createSignal(0)`)
16
+ * was dropped from init entirely while its setter stayed referenced
17
+ * by the emitted handler.
18
+ */
19
+
20
+ import { describe, test, expect } from 'bun:test'
21
+ import { compileJSX } from '../index'
22
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
23
+
24
+ function clientJsOf(source: string): string {
25
+ const result = compileJSX(source, 'scope-soundness.tsx', { adapter: new HonoAdapter() })
26
+ const errors = result.errors.filter(e => e.severity === 'error')
27
+ expect(errors).toEqual([])
28
+ const clientJs = result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n')
29
+ expect(clientJs).not.toBe('')
30
+ return clientJs
31
+ }
32
+
33
+ /** The registration template lambda's source, extracted per component. */
34
+ function templateLambdaOf(clientJs: string, name: string): string {
35
+ const start = clientJs.indexOf(`hydrate('${name}'`)
36
+ expect(start).toBeGreaterThan(-1)
37
+ const end = clientJs.indexOf('\n', start)
38
+ return clientJs.slice(start, end === -1 ? undefined : end)
39
+ }
40
+
41
+ describe('CSR template scope soundness (#2468)', () => {
42
+ test('A: memo body inlined into the template rewrites destructured prop refs to _p.*', () => {
43
+ const clientJs = clientJsOf(`
44
+ "use client"
45
+ import { createMemo } from '@barefootjs/client'
46
+
47
+ export function Derived({ value }: { value: number }) {
48
+ const doubled = createMemo(() => value * 10)
49
+ return <span>{doubled()}</span>
50
+ }
51
+ `)
52
+ const template = templateLambdaOf(clientJs, 'Derived')
53
+ expect(template).toContain('_p.value * 10')
54
+ expect(template).not.toMatch(/[^.\w]value \* 10/)
55
+ })
56
+
57
+ test('B: component-prop template literal rewrites destructured prop refs to _p.*', () => {
58
+ const clientJs = clientJsOf(`
59
+ "use client"
60
+ import { Slot } from './slot'
61
+
62
+ const base = 'chip'
63
+
64
+ interface BaseProps { className?: string }
65
+ interface ChipProps extends BaseProps { asChild?: boolean; children?: unknown }
66
+
67
+ export function Chip({ className = '', asChild = false, children, ...props }: ChipProps) {
68
+ const classes = \`\${base} \${className}\`
69
+ if (asChild) {
70
+ return <Slot className={classes} {...props}>{children}</Slot>
71
+ }
72
+ return <span class={classes} {...props}>{children}</span>
73
+ }
74
+ `)
75
+ const template = templateLambdaOf(clientJs, 'Chip')
76
+ // The renderChild props object must read the prop off _p, exactly
77
+ // like the sibling element attribute already does.
78
+ expect(template).not.toMatch(/\$\{className\}/)
79
+ expect(template).toContain('_p.className')
80
+ })
81
+
82
+ test('C: a getter-elided signal declaration still lands in init', () => {
83
+ const clientJs = clientJsOf(`
84
+ "use client"
85
+ import { createSignal } from '@barefootjs/client'
86
+
87
+ export function Fire() {
88
+ const [, setFlag] = createSignal(0)
89
+ return <button onClick={() => setFlag(1)}>go</button>
90
+ }
91
+ `)
92
+ // The setter is referenced by the click handler, so its declaring
93
+ // statement must exist in the emitted init.
94
+ expect(clientJs).toMatch(/setFlag\s*\]\s*=\s*createSignal\(0\)/)
95
+ })
96
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Signal-conditioned early return lowers to the root-ternary plan (#2463).
3
+ *
4
+ * `if (loading()) return <A/>; return <B/>` is semantically the root
5
+ * ternary `return loading() ? <A/> : <B/>`, and the IRIfStatement
6
+ * contract (spec/compiler.md) says the client JS handles all branches
7
+ * and switches at runtime. Before this fix the statement form emitted
8
+ * NO branch-switch effect — clicking called the setter and nothing
9
+ * subscribed, so the UI could never leave the SSR branch — and the CSR
10
+ * template lambda referenced the init-scoped signal (`ReferenceError`
11
+ * on CSR mount; the adapter-tests scope gate pinned it).
12
+ *
13
+ * The fix: a conditional-return chain whose conditions are ALL reactive
14
+ * (signal/memo reads) and that declares no branch-local scope variables
15
+ * is built as the same IRConditional chain the root ternary produces,
16
+ * wrapped in the synthetic display:contents scope element. Chains with
17
+ * static/prop conditions or branch-local declarations keep the
18
+ * IRIfStatement path (#1401/#1414 semantics) unchanged.
19
+ */
20
+
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../index'
23
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
24
+
25
+ function clientJsOf(source: string): string {
26
+ const result = compileJSX(source, 'early-return.tsx', { adapter: new HonoAdapter() })
27
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
28
+ return result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n')
29
+ }
30
+
31
+ const EARLY = `
32
+ "use client"
33
+ import { createSignal } from '@barefootjs/client'
34
+
35
+ export function LoadGate() {
36
+ const [loading, setLoading] = createSignal(true)
37
+ if (loading()) {
38
+ return <button onClick={() => setLoading(false)}>Loading...</button>
39
+ }
40
+ return <p>Ready</p>
41
+ }
42
+ `
43
+
44
+ const TERNARY = `
45
+ "use client"
46
+ import { createSignal } from '@barefootjs/client'
47
+
48
+ export function LoadGate() {
49
+ const [loading, setLoading] = createSignal(true)
50
+ return loading() ? <button onClick={() => setLoading(false)}>Loading...</button> : <p>Ready</p>
51
+ }
52
+ `
53
+
54
+ describe('signal-conditioned early return (#2463)', () => {
55
+ test('emits the branch-switch insert() plan', () => {
56
+ const clientJs = clientJsOf(EARLY)
57
+ // The statement form must subscribe to the signal and swap branches
58
+ // at runtime, exactly like the expression form.
59
+ expect(clientJs).toContain('insert(')
60
+ expect(clientJs).toContain('() => loading()')
61
+ })
62
+
63
+ test('CSR template lambda is scope-sound (signal substituted to its initial)', () => {
64
+ const clientJs = clientJsOf(EARLY)
65
+ const template = clientJs.match(/hydrate\('LoadGate'.*/)?.[0] ?? ''
66
+ // The module-scope lambda must not reference the init-scoped signal.
67
+ expect(template).not.toContain('loading()')
68
+ expect(template).toContain('(true) ?')
69
+ })
70
+
71
+ test('statement form and expression form lower to the same plan shape', () => {
72
+ const early = clientJsOf(EARLY)
73
+ const ternary = clientJsOf(TERNARY)
74
+ // Same runtime imports (insert present in both) and both templates
75
+ // wrapped in the synthetic scope element.
76
+ for (const js of [early, ternary]) {
77
+ expect(js).toContain('insert(')
78
+ expect(js).toContain('display:contents')
79
+ }
80
+ })
81
+
82
+ test('a prop-conditioned chain keeps the IRIfStatement path', () => {
83
+ const clientJs = clientJsOf(`
84
+ export function Badge({ active }: { active: boolean }) {
85
+ if (active) {
86
+ return <strong>on</strong>
87
+ }
88
+ return <em>off</em>
89
+ }
90
+ `)
91
+ // Static condition: no runtime branch switching, no synthetic wrapper —
92
+ // the pre-#2463 emission is unchanged.
93
+ expect(clientJs).not.toContain('insert(')
94
+ expect(clientJs).not.toContain('display:contents')
95
+ })
96
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * SSR projection of controlled form-control `value` (#2464 / #2465).
3
+ *
4
+ * `value` is not an attribute on `<textarea>` or `<select>` — emitting it
5
+ * verbatim ships invalid HTML that browsers ignore, so no-JS and
6
+ * pre-hydration users saw an empty textarea / the wrong option until the
7
+ * hydrate-time `.value` effect snapped it. The shared-IR lowering marks
8
+ * the attr `clientOnly` (SSR skips it; the property binding is unchanged)
9
+ * and projects the value into element content (textarea) or per-option
10
+ * `selected` comparisons (select — the shape `select-option-selected`
11
+ * already proves across every adapter).
12
+ */
13
+
14
+ import { describe, test, expect } from 'bun:test'
15
+ import { compileJSX } from '../index'
16
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter'
17
+
18
+ function compiled(source: string): { template: string; clientJs: string } {
19
+ const result = compileJSX(source, 'form-value.tsx', { adapter: new HonoAdapter() })
20
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
21
+ return {
22
+ template: result.files.find(f => f.type === 'markedTemplate')?.content ?? '',
23
+ clientJs: result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n'),
24
+ }
25
+ }
26
+
27
+ describe('controlled form-control value SSR lowering (#2464/#2465)', () => {
28
+ test('textarea value lowers to element content, keeps the .value binding', () => {
29
+ const { template, clientJs } = compiled(`
30
+ "use client"
31
+ import { createSignal } from '@barefootjs/client'
32
+ export function NoteBox() {
33
+ const [note, setNote] = createSignal('hi')
34
+ return <textarea value={note()} onInput={(e) => setNote(e.target.value)} />
35
+ }
36
+ `)
37
+ expect(template).toContain('<textarea')
38
+ expect(template).not.toContain('value={')
39
+ expect(template).toContain('{note()}')
40
+ expect(clientJs).toContain('.value = __val')
41
+ // CSR registration template interpolates slotless children raw, so the
42
+ // client-only variant must escape — a value containing `</textarea>`
43
+ // must not break out of the element on CSR mount (Copilot review).
44
+ expect(clientJs).toMatch(/<textarea[^`]*\$\{escapeText\(/)
45
+ })
46
+
47
+ test('select value lowers to selected on statically-valued options', () => {
48
+ const { template } = compiled(`
49
+ "use client"
50
+ import { createSignal } from '@barefootjs/client'
51
+ export function Pick() {
52
+ const [v, setV] = createSignal('b')
53
+ return (
54
+ <select value={v()} onChange={(e) => setV(e.target.value)}>
55
+ <option value="a">A</option>
56
+ <optgroup label="rest">
57
+ <option value="b">B</option>
58
+ </optgroup>
59
+ </select>
60
+ )
61
+ }
62
+ `)
63
+ expect(template).not.toMatch(/<select[^>]*value=/)
64
+ expect(template).toContain(`((v()) === "a")`)
65
+ expect(template).toContain(`((v()) === "b")`)
66
+ })
67
+
68
+ test('an authored selected wins; dynamic option loops are left to hydrate', () => {
69
+ const { template } = compiled(`
70
+ "use client"
71
+ import { createSignal } from '@barefootjs/client'
72
+ export function Pick() {
73
+ const [v, setV] = createSignal('b')
74
+ const [opts] = createSignal(['a', 'b'])
75
+ return (
76
+ <div>
77
+ <select value={v()}>
78
+ <option value="a" selected={false}>A</option>
79
+ </select>
80
+ <select value={v()}>
81
+ {opts().map((o) => <option key={o} value={o}>{o}</option>)}
82
+ </select>
83
+ </div>
84
+ )
85
+ }
86
+ `)
87
+ // The authored selected={false} is untouched (exactly one selected on
88
+ // that option), and the invalid select value attr is gone everywhere.
89
+ expect(template).toContain('selected={(false) || undefined}')
90
+ expect(template).not.toContain('(v()) === "a"')
91
+ expect(template).not.toMatch(/<select[^>]*value=/)
92
+ })
93
+
94
+ test('textarea with explicit children is left untouched', () => {
95
+ const { template } = compiled(`
96
+ export function Fixed() {
97
+ return <textarea value={undefined}>seed</textarea>
98
+ }
99
+ `)
100
+ expect(template).toContain('>seed</textarea>')
101
+ })
102
+ })
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `analyzeLazyConditional` — the §9.5 "row contains a reactive conditional"
3
+ * widening (`plan/lazy-conditional.ts`).
4
+ *
5
+ * Unit half over hand-built `LoopChildConditional` values so each refusal is
6
+ * pinned to the exact shape that produces it, plus emission assertions that an
7
+ * accepted conditional is driven from the apply bodies with its arms hoisted
8
+ * once per loop. The DOM behaviour (does the swap actually land, both ways)
9
+ * lives in `packages/client/__tests__/runtime/lazy-row-conditional.test.ts` —
10
+ * emitted shape and emitted behaviour are different claims.
11
+ */
12
+
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { compileJSX } from '../index.ts'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { analyzeLazyConditional } from '../ir-to-client-js/control-flow/plan/lazy-conditional.ts'
17
+ import type { LoopChildBranchSummary, LoopChildConditional } from '../ir-to-client-js/types.ts'
18
+
19
+ const bare = (over: Partial<LoopChildBranchSummary> = {}): LoopChildBranchSummary => ({
20
+ childComponents: [],
21
+ ...over,
22
+ })
23
+
24
+ function cond(over: Partial<LoopChildConditional> = {}): LoopChildConditional {
25
+ return {
26
+ slotId: 's0',
27
+ condition: 'row.done',
28
+ whenTrueHtml: '<span class="yes">done</span>',
29
+ whenFalseHtml: '<em class="no">open</em>',
30
+ whenTrue: bare(),
31
+ whenFalse: bare(),
32
+ conditionFreeIdentifiers: new Set(['row']),
33
+ ...over,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The caller prepares each arm through `addCondAttrToTemplate` before handing it
39
+ * over — that is where `bf-c` comes from, and where the element-vs-fragment
40
+ * decision is made. These helpers stand in for it.
41
+ */
42
+ const asElement = (html: string, slotId = 's0') => html.replace(/^(<\w+)/, `$1 bf-c="${slotId}"`)
43
+ const asFragment = (html: string, slotId = 's0') =>
44
+ `<!--bf-cond-start:${slotId}-->${html}<!--bf-cond-end:${slotId}-->`
45
+
46
+ const armsOf = (c: LoopChildConditional) => ({
47
+ whenTrueHtml: asElement(c.whenTrueHtml, c.slotId),
48
+ whenFalseHtml: asElement(c.whenFalseHtml, c.slotId),
49
+ })
50
+
51
+ describe('analyzeLazyConditional — accepted', () => {
52
+ test('two wiring-free static element arms', () => {
53
+ const c = cond()
54
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
55
+ expect(r.lazySafe).toBe(true)
56
+ if (r.lazySafe) {
57
+ expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
58
+ expect(r.facts.condition).toBe('row.done')
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('analyzeLazyConditional — refusals', () => {
64
+ const wiring: Array<[string, Partial<LoopChildBranchSummary>, RegExp]> = [
65
+ ['a child component', { childComponents: [{ name: 'X', slotId: null, props: [], children: [] }] }, /child components/],
66
+ ['an inner loop', { innerLoops: [{} as never] }, /an inner loop/],
67
+ ['a nested conditional', { conditionals: [{} as never] }, /a nested conditional/],
68
+ ['events', { events: [{} as never] }, /events/],
69
+ ['a reactive attr', { reactiveAttrs: [{} as never] }, /reactive attrs/],
70
+ ['reactive text', { reactiveTexts: [{} as never] }, /reactive text/],
71
+ ]
72
+
73
+ for (const [name, over, reason] of wiring) {
74
+ test(`the true arm owns ${name}`, () => {
75
+ const c = cond({ whenTrue: bare(over) })
76
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
77
+ expect(r.lazySafe).toBe(false)
78
+ if (!r.lazySafe) expect(r.reason).toMatch(reason)
79
+ })
80
+ }
81
+
82
+ test('the FALSE arm is checked too, not just the true one', () => {
83
+ const c = cond({ whenFalse: bare({ events: [{} as never] }) })
84
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
85
+ expect(r.lazySafe).toBe(false)
86
+ if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
87
+ })
88
+
89
+ test('a fragment conditional has no single node to replace', () => {
90
+ const c = cond()
91
+ const r = analyzeLazyConditional(c, '__idx', {
92
+ whenTrueHtml: asFragment('text only'),
93
+ whenFalseHtml: asElement(c.whenFalseHtml),
94
+ })
95
+ expect(r.lazySafe).toBe(false)
96
+ if (!r.lazySafe) expect(r.reason).toMatch(/fragment conditional/)
97
+ })
98
+
99
+ test('an arm that interpolates a value cannot be hoisted once per loop', () => {
100
+ const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
101
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
102
+ expect(r.lazySafe).toBe(false)
103
+ if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
104
+ })
105
+
106
+ test('a condition with no analyzable identifier set refuses rather than assumes', () => {
107
+ const c = cond({ conditionFreeIdentifiers: undefined })
108
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
109
+ expect(r.lazySafe).toBe(false)
110
+ if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
111
+ })
112
+
113
+ test('a condition reading the loop index refuses — the apply bodies have none', () => {
114
+ const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
115
+ const r = analyzeLazyConditional(c, '__idx', armsOf(c))
116
+ expect(r.lazySafe).toBe(false)
117
+ if (!r.lazySafe) expect(r.reason).toMatch(/reads the loop index parameter '__idx'/)
118
+ })
119
+ })
120
+
121
+ // --- emission ---------------------------------------------------------------
122
+
123
+ function clientJs(source: string, file: string): string {
124
+ const result = compileJSX(source, file, { adapter: new TestAdapter() })
125
+ const js = result.files.find(f => f.path.endsWith('.client.js'))
126
+ if (!js) throw new Error(`no client JS emitted for ${file}`)
127
+ return js.content
128
+ }
129
+
130
+ const ROWS = (arm: string) => `
131
+ 'use client'
132
+ import { createSignal } from '@barefootjs/client'
133
+ type Row = { id: number; label: string; done: boolean }
134
+ export function CondRows(props: { rows: Row[] }) {
135
+ const [rows, setRows] = createSignal<Row[]>(props.rows)
136
+ return (
137
+ <ul>
138
+ {rows().map(row => (
139
+ <li key={row.id}>${arm}</li>
140
+ ))}
141
+ </ul>
142
+ )
143
+ }
144
+ `
145
+
146
+ describe('lazy row emission — wiring-free row conditional', () => {
147
+ const js = clientJs(
148
+ ROWS('{row.done ? <span class="yes">done</span> : <em class="no">open</em>}'),
149
+ 'CondRows.tsx',
150
+ )
151
+
152
+ test('the loop is lazy and calls no per-row insert()', () => {
153
+ expect(js).toContain('mapArrayLazy(')
154
+ expect(js).not.toMatch(/\bmapArray\(/)
155
+ expect(js).not.toContain('insert(')
156
+ })
157
+
158
+ test('both arms are parsed once per LOOP, outside every plan body', () => {
159
+ const beforeCall = js.slice(0, js.indexOf('mapArrayLazy('))
160
+ expect(beforeCall).toMatch(/const __cbt_l0_s\d+ = document\.createElement\('template'\)/)
161
+ expect(beforeCall).toMatch(/const __cbf_l0_s\d+ = document\.createElement\('template'\)/)
162
+ })
163
+
164
+ test('createRow only records the dedup boolean — the clone already has the right arm', () => {
165
+ const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
166
+ expect(createRow).toMatch(/__l\[\d\] = !!\(row\(\)\.done\)/)
167
+ expect(createRow).not.toContain('replaceWith')
168
+ })
169
+
170
+ test('applyItem swaps by replaceWith and reassigns the ref', () => {
171
+ const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
172
+ expect(applyItem).toContain('__c.replaceWith(__n)')
173
+ // Without this the next flip writes into the node the previous one detached.
174
+ expect(applyItem).toMatch(/__r\[\d\] = __n/)
175
+ })
176
+ })
177
+
178
+ describe('lazy row emission — conditionals that keep the eager path', () => {
179
+ const cases: Array<[string, string]> = [
180
+ ['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
181
+ ['an arm reading the item', '{row.done ? <span>{row.label}</span> : <em>open</em>}'],
182
+ ['a bare-value ternary (fragment form)', '{row.done ? "yes" : "no"}'],
183
+ ]
184
+ for (const [name, arm] of cases) {
185
+ test(name, () => {
186
+ const js = clientJs(ROWS(arm), 'EagerCond.tsx')
187
+ expect(js).not.toContain('mapArrayLazy(')
188
+ })
189
+ }
190
+ })