@barefootjs/jsx 0.21.4 → 0.24.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 (46) hide show
  1. package/dist/adapters/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +3 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +30 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1124 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +111 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +47 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +125 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +1012 -93
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +19 -0
  38. package/src/format-date-lowering.ts +55 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +90 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +90 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +563 -0
  46. package/src/types.ts +49 -1
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Cross-file reactive-factory resolution (#931 round 2, #2325).
3
+ *
4
+ * Context: `reactive-factory-inlining.test.ts` covers same-file factory
5
+ * helpers (`function createCounter() { ... }` defined in the same module as
6
+ * the component that calls it). Real-world factory helpers usually live in
7
+ * their own file (e.g. Sora's `useListStore`) and are imported — until this
8
+ * round, that shape silently fell through to the generic BF110 unrecognised-
9
+ * callee path (or worse, produced client JS with a dangling reference).
10
+ *
11
+ * This file pins `prescanImportedReactiveFactories`: resolving a factory
12
+ * defined in a relative-imported helper file, inlining its body at the
13
+ * call site exactly as a same-file factory would be, and provisioning the
14
+ * `createSignal` import from usage (not from the consumer's own imports —
15
+ * see C1 in the #2325 spec) so the consumer file never needs to import
16
+ * `@barefootjs/client` itself just to destructure a factory's result.
17
+ *
18
+ * Harness: `mkdtempSync`/`beforeAll`/`afterAll`/fixture-writing pattern from
19
+ * `cross-file-client-signal.test.ts`, combined with `analyzeComponent` /
20
+ * `compileJSX` / `TestAdapter` from `reactive-factory-inlining.test.ts` —
21
+ * cross-file resolution requires the consumer's real on-disk path so
22
+ * `resolveRelativeImportToFile` can find the helper file.
23
+ */
24
+
25
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
26
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'
27
+ import { tmpdir } from 'os'
28
+ import path from 'path'
29
+ import { analyzeComponent } from '../analyzer'
30
+ import { compileJSX } from '../compiler'
31
+ import { TestAdapter } from '../adapters/test-adapter'
32
+
33
+ const adapter = new TestAdapter()
34
+
35
+ let fixtureDir: string
36
+
37
+ beforeAll(() => {
38
+ fixtureDir = mkdtempSync(path.join(tmpdir(), 'bf-factory-cross-file-'))
39
+ })
40
+
41
+ afterAll(() => {
42
+ rmSync(fixtureDir, { recursive: true, force: true })
43
+ })
44
+
45
+ function writeFixture(name: string, content: string): string {
46
+ const p = path.join(fixtureDir, name)
47
+ mkdirSync(path.dirname(p), { recursive: true })
48
+ writeFileSync(p, content, 'utf8')
49
+ return p
50
+ }
51
+
52
+ describe('Cross-file reactive factories (#2325)', () => {
53
+ test('cross-file tuple factory inlines and provisions the createSignal import (C1)', () => {
54
+ writeFixture('hooks-tuple.tsx', `'use client'
55
+ import { createSignal } from '@barefootjs/client'
56
+
57
+ export function createCounter(initial: number) {
58
+ const [count, setCount] = createSignal(initial)
59
+ return [count, setCount] as const
60
+ }
61
+ `)
62
+ // The consumer never imports @barefootjs/client itself — only the
63
+ // factory. Import provisioning must come from usage in the generated
64
+ // code, not from the consumer's own source-level imports.
65
+ const consumerSource = `'use client'
66
+ import { createCounter } from './hooks-tuple'
67
+
68
+ export function Counter() {
69
+ const [count, setCount] = createCounter(0)
70
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
71
+ }
72
+ `
73
+ const consumerPath = writeFixture('counter-tuple.tsx', consumerSource)
74
+
75
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'Counter')
76
+ expect(ctx.signals.length).toBe(1)
77
+ expect(ctx.signals[0].getter).toBe('count')
78
+ expect(ctx.signals[0].setter).toBe('setCount')
79
+
80
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
81
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
82
+ const clientJs = result.files.find(f => f.type === 'clientJs')
83
+ expect(clientJs).toBeDefined()
84
+ expect(clientJs!.content).toMatch(/import\s*\{[^}]*createSignal[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
85
+ // The helper's own relative path must not leak into the output — its
86
+ // body was inlined, not imported.
87
+ expect(clientJs!.content).not.toContain('./hooks-tuple')
88
+ })
89
+
90
+ test('cross-file object-return factory + subset destructure (Sora useListStore shape)', () => {
91
+ writeFixture('hooks-object.tsx', `'use client'
92
+ import { createSignal, createMemo } from '@barefootjs/client'
93
+
94
+ export function useListStore(initial: string[]) {
95
+ const [items, setItems] = createSignal(initial)
96
+ const count = createMemo(() => items().length)
97
+ return { items, setItems, count }
98
+ }
99
+ `)
100
+ const consumerSource = `'use client'
101
+ import { useListStore } from './hooks-object'
102
+
103
+ export function ListSummary() {
104
+ const { items, count } = useListStore([])
105
+ return <p>{items().length} / {count()}</p>
106
+ }
107
+ `
108
+ const consumerPath = writeFixture('list-summary.tsx', consumerSource)
109
+
110
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'ListSummary')
111
+ expect(ctx.signals.length).toBe(1)
112
+ expect(ctx.signals[0].getter).toBe('items')
113
+ expect(ctx.memos.length).toBe(1)
114
+ expect(ctx.memos[0].name).toBe('count')
115
+
116
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
117
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
118
+ const clientJs = result.files.find(f => f.type === 'clientJs')
119
+ expect(clientJs).toBeDefined()
120
+ expect(clientJs!.content).toContain('createSignal')
121
+ })
122
+
123
+ test('aliased cross-file factory import inlines under the local alias', () => {
124
+ writeFixture('hooks-alias.tsx', `'use client'
125
+ import { createSignal } from '@barefootjs/client'
126
+
127
+ export function useCounter(initial: number) {
128
+ const [count, setCount] = createSignal(initial)
129
+ return [count, setCount] as const
130
+ }
131
+ `)
132
+ const consumerSource = `'use client'
133
+ import { useCounter as useC } from './hooks-alias'
134
+
135
+ export function Counter() {
136
+ const [count, setCount] = useC(0)
137
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
138
+ }
139
+ `
140
+ const consumerPath = writeFixture('counter-alias.tsx', consumerSource)
141
+
142
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'Counter')
143
+ expect(ctx.signals.length).toBe(1)
144
+ expect(ctx.signals[0].getter).toBe('count')
145
+
146
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
147
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
148
+ })
149
+
150
+ test('non-relative import object-destructure emits BF110 (uninspectable-import heuristic)', () => {
151
+ const consumerSource = `'use client'
152
+ import { useStore } from '@app/hooks'
153
+
154
+ export function Comp() {
155
+ const { value, setValue } = useStore(0)
156
+ return <button onClick={() => setValue(value() + 1)}>{value()}</button>
157
+ }
158
+ `
159
+ const consumerPath = writeFixture('non-relative-consumer.tsx', consumerSource)
160
+
161
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
162
+ const bf110 = result.errors.find(e => e.code === 'BF110')
163
+ expect(bf110).toBeDefined()
164
+ expect(bf110!.message).toContain('useStore')
165
+ })
166
+
167
+ test('module-scope capture → BF112: helper-local function reference blocks inlining', () => {
168
+ writeFixture('hooks-capture.tsx', `'use client'
169
+ import { createSignal } from '@barefootjs/client'
170
+
171
+ const KEY = 'stored-value'
172
+
173
+ function readStored() {
174
+ return KEY.length
175
+ }
176
+
177
+ export function useStoredCounter() {
178
+ const [count, setCount] = createSignal(readStored())
179
+ return { count, setCount }
180
+ }
181
+ `)
182
+ const consumerSource = `'use client'
183
+ import { useStoredCounter } from './hooks-capture'
184
+
185
+ export function Counter() {
186
+ const { count, setCount } = useStoredCounter()
187
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
188
+ }
189
+ `
190
+ const consumerPath = writeFixture('counter-capture.tsx', consumerSource)
191
+
192
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'Counter')
193
+ expect(ctx.signals.length).toBe(0)
194
+
195
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
196
+ const bf112 = result.errors.find(e => e.code === 'BF112')
197
+ expect(bf112).toBeDefined()
198
+ expect(bf112!.message).toContain('readStored')
199
+ const clientJs = result.files.find(f => f.type === 'clientJs')
200
+ if (clientJs) {
201
+ expect(clientJs.content).not.toContain('readStored')
202
+ }
203
+ })
204
+
205
+ test('reactive-primitive-free helper: tuple destructure gets BF110, object destructure stays silent (cleanFactoryImports)', () => {
206
+ writeFixture('hooks-clean.tsx', `
207
+ export function makePair(a: number, b: number) {
208
+ return [a, b] as const
209
+ }
210
+ export function makeConfig(a: number, b: number) {
211
+ return { a, b }
212
+ }
213
+ `)
214
+ const tupleConsumerSource = `'use client'
215
+ import { makePair } from './hooks-clean'
216
+
217
+ export function Pair() {
218
+ const [x, y] = makePair(1, 2)
219
+ return <p>{x} {y}</p>
220
+ }
221
+ `
222
+ const tupleConsumerPath = writeFixture('pair-consumer.tsx', tupleConsumerSource)
223
+ const tupleResult = compileJSX(tupleConsumerSource, tupleConsumerPath, { adapter })
224
+ expect(tupleResult.errors.find(e => e.code === 'BF110')).toBeDefined()
225
+
226
+ const objectConsumerSource = `'use client'
227
+ import { makeConfig } from './hooks-clean'
228
+
229
+ export function Config() {
230
+ const { a, b } = makeConfig(1, 2)
231
+ return <p>{a} {b}</p>
232
+ }
233
+ `
234
+ const objectConsumerPath = writeFixture('config-consumer.tsx', objectConsumerSource)
235
+ const objectResult = compileJSX(objectConsumerSource, objectConsumerPath, { adapter })
236
+ expect(objectResult.errors.find(e => e.code === 'BF110')).toBeUndefined()
237
+ expect(objectResult.errors.find(e => e.code === 'BF111')).toBeUndefined()
238
+ })
239
+ })
240
+
241
+ describe('Cross-file factory import re-provisioning (#2332)', () => {
242
+ beforeAll(() => {
243
+ // lib/mathmod.ts — the third module, deliberately .ts and in a different
244
+ // directory than both hook and component so the relative-path math is real.
245
+ writeFixture('lib/mathmod.ts', `export function doubleIt(x: number): number {
246
+ return x * 2
247
+ }
248
+ `)
249
+ // hooks/useDouble.tsx — the helper: factory body calls an import.
250
+ writeFixture('hooks/useDouble.tsx', `'use client'
251
+ import { createSignal } from '@barefootjs/client'
252
+ import { doubleIt } from '../lib/mathmod'
253
+
254
+ export function useDouble(initial: number) {
255
+ const [value, setValue] = createSignal(doubleIt(initial))
256
+ const bump = () => setValue(doubleIt(value()))
257
+ return { value, bump }
258
+ }
259
+ `)
260
+ })
261
+
262
+ test('matrix 1: re-provisioned import, different directory depths', () => {
263
+ const consumerSource = `'use client'
264
+ import { useDouble } from '../hooks/useDouble'
265
+
266
+ export function Doubler() {
267
+ const { value, bump } = useDouble(21)
268
+ return <button onClick={bump}>{value()}</button>
269
+ }
270
+ `
271
+ const consumerPath = writeFixture('components/Doubler.tsx', consumerSource)
272
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'Doubler')
273
+ expect(ctx.signals.length).toBe(1)
274
+ expect(ctx.signals[0].getter).toBe('value')
275
+
276
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
277
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
278
+ const clientJs = result.files.find(f => f.type === 'clientJs')
279
+ expect(clientJs).toBeDefined()
280
+ // Specifier rewritten relative to components/, not hooks/ — the off-by-one trap.
281
+ expect(clientJs!.content).toMatch(/import\s*\{\s*doubleIt\s*\}\s*from\s*'\.\.\/lib\/mathmod'/)
282
+ expect(clientJs!.content).not.toContain('../hooks/useDouble') // factory inlined, not imported
283
+ })
284
+
285
+ test('matrix 2: bare specifier passes through unchanged', () => {
286
+ writeFixture('hooks/useClamped.tsx', `'use client'
287
+ import { createSignal } from '@barefootjs/client'
288
+ import { clamp } from 'tiny-clamp'
289
+
290
+ export function useClamped(initial: number) {
291
+ const [value, setValue] = createSignal(clamp(initial))
292
+ const bump = () => setValue(clamp(value() + 1))
293
+ return { value, bump }
294
+ }
295
+ `)
296
+ const consumerSource = `'use client'
297
+ import { useClamped } from '../hooks/useClamped'
298
+
299
+ export function Clamped() {
300
+ const { value, bump } = useClamped(21)
301
+ return <button onClick={bump}>{value()}</button>
302
+ }
303
+ `
304
+ const consumerPath = writeFixture('components/Clamped.tsx', consumerSource)
305
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
306
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
307
+ const clientJs = result.files.find(f => f.type === 'clientJs')
308
+ expect(clientJs).toBeDefined()
309
+ // Bare specifiers skip resolution entirely, so the package need not exist on disk.
310
+ expect(clientJs!.content).toMatch(/import\s*\{\s*clamp\s*\}\s*from\s*'tiny-clamp'/)
311
+ })
312
+
313
+ test('matrix 3: dedupe across two factories sharing the same helper import', () => {
314
+ writeFixture('hooks/pair.tsx', `'use client'
315
+ import { createSignal } from '@barefootjs/client'
316
+ import { doubleIt } from '../lib/mathmod'
317
+
318
+ export function useA(initial: number) {
319
+ const [value, setValue] = createSignal(doubleIt(initial))
320
+ const bump = () => setValue(doubleIt(value()))
321
+ return { value, bump }
322
+ }
323
+
324
+ export function useB(initial: number) {
325
+ const [other, setOther] = createSignal(doubleIt(initial))
326
+ const bumpOther = () => setOther(doubleIt(other()))
327
+ return { other, bumpOther }
328
+ }
329
+ `)
330
+ const consumerSource = `'use client'
331
+ import { useA, useB } from '../hooks/pair'
332
+
333
+ export function Pair() {
334
+ const { value, bump } = useA(1)
335
+ const { other, bumpOther } = useB(2)
336
+ return <button onClick={() => { bump(); bumpOther() }}>{value()} / {other()}</button>
337
+ }
338
+ `
339
+ const consumerPath = writeFixture('components/Pair.tsx', consumerSource)
340
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
341
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
342
+ const clientJs = result.files.find(f => f.type === 'clientJs')
343
+ expect(clientJs).toBeDefined()
344
+ expect((clientJs!.content.match(/from '\.\.\/lib\/mathmod'/g) ?? []).length).toBe(1)
345
+ })
346
+
347
+ test('re-provisioned imports are emitted in sorted order regardless of inlining order (Copilot review, PR #2338)', () => {
348
+ // `importsBySpecifier`/`inlinedFactories` are populated in AST-traversal/
349
+ // insertion order — deliberately adversarial here: the component calls
350
+ // the factory needing '../lib/zmod' BEFORE the one needing
351
+ // '../lib/amod', so insertion order is z-then-a. Emission must still be
352
+ // alphabetical (a-then-z), or the generated import block would be
353
+ // order-unstable across unrelated analyzer refactors.
354
+ writeFixture('lib/zmod.ts', `export function zHelper(x: number): number {
355
+ return x
356
+ }
357
+ `)
358
+ writeFixture('lib/amod.ts', `export function aHelper(x: number): number {
359
+ return x
360
+ }
361
+ `)
362
+ writeFixture('hooks/useZFactory.tsx', `'use client'
363
+ import { createSignal } from '@barefootjs/client'
364
+ import { zHelper } from '../lib/zmod'
365
+
366
+ export function useZFactory(initial: number) {
367
+ const [zValue, setZValue] = createSignal(zHelper(initial))
368
+ return { zValue, setZValue }
369
+ }
370
+ `)
371
+ writeFixture('hooks/useAFactory.tsx', `'use client'
372
+ import { createSignal } from '@barefootjs/client'
373
+ import { aHelper } from '../lib/amod'
374
+
375
+ export function useAFactory(initial: number) {
376
+ const [aValue, setAValue] = createSignal(aHelper(initial))
377
+ return { aValue, setAValue }
378
+ }
379
+ `)
380
+ const consumerSource = `'use client'
381
+ import { useZFactory } from '../hooks/useZFactory'
382
+ import { useAFactory } from '../hooks/useAFactory'
383
+
384
+ export function Ordered() {
385
+ const { zValue } = useZFactory(1)
386
+ const { aValue } = useAFactory(2)
387
+ return <p>{zValue()} / {aValue()}</p>
388
+ }
389
+ `
390
+ const consumerPath = writeFixture('components/Ordered.tsx', consumerSource)
391
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
392
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
393
+ const clientJs = result.files.find(f => f.type === 'clientJs')
394
+ expect(clientJs).toBeDefined()
395
+ const amodIndex = clientJs!.content.indexOf("from '../lib/amod'")
396
+ const zmodIndex = clientJs!.content.indexOf("from '../lib/zmod'")
397
+ expect(amodIndex).toBeGreaterThan(-1)
398
+ expect(zmodIndex).toBeGreaterThan(-1)
399
+ expect(amodIndex).toBeLessThan(zmodIndex)
400
+ })
401
+
402
+ test('matrix 4: local-name collision with a re-provisioned import declines with BF113', () => {
403
+ const consumerSource = `'use client'
404
+ import { useDouble } from '../hooks/useDouble'
405
+
406
+ function doubleIt(): number {
407
+ return 1
408
+ }
409
+
410
+ export function Collide() {
411
+ const { value, bump } = useDouble(21)
412
+ return <button onClick={bump}>{value()} {doubleIt()}</button>
413
+ }
414
+ `
415
+ const consumerPath = writeFixture('components/Collide.tsx', consumerSource)
416
+ const ctx = analyzeComponent(consumerSource, consumerPath, 'Collide')
417
+ expect(ctx.signals.length).toBe(0)
418
+
419
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
420
+ const bf113 = result.errors.find(e => e.code === 'BF113')
421
+ expect(bf113).toBeDefined()
422
+ expect(bf113!.message).toContain('doubleIt')
423
+ expect(bf113!.message).toContain('../lib/mathmod')
424
+ const clientJs = result.files.find(f => f.type === 'clientJs')
425
+ if (clientJs) {
426
+ expect(clientJs.content).not.toContain(`from '../lib/mathmod'`)
427
+ }
428
+ })
429
+
430
+ test('matrix 5b: default-import reference stays BF112, not re-provisioned', () => {
431
+ writeFixture('lib/mathmod-default.ts', `export default {
432
+ doubleIt(x: number): number {
433
+ return x * 2
434
+ },
435
+ }
436
+ `)
437
+ writeFixture('hooks/useDefault.tsx', `'use client'
438
+ import { createSignal } from '@barefootjs/client'
439
+ import mathmod from '../lib/mathmod-default'
440
+
441
+ export function useDefault(initial: number) {
442
+ const [value, setValue] = createSignal(mathmod.doubleIt(initial))
443
+ const bump = () => setValue(mathmod.doubleIt(value()))
444
+ return { value, bump }
445
+ }
446
+ `)
447
+ const consumerSource = `'use client'
448
+ import { useDefault } from '../hooks/useDefault'
449
+
450
+ export function Defaulter() {
451
+ const { value, bump } = useDefault(21)
452
+ return <button onClick={bump}>{value()}</button>
453
+ }
454
+ `
455
+ const consumerPath = writeFixture('components/Defaulter.tsx', consumerSource)
456
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
457
+ const bf112 = result.errors.find(e => e.code === 'BF112')
458
+ expect(bf112).toBeDefined()
459
+ expect(bf112!.message).toContain('mathmod')
460
+ expect(result.errors.find(e => e.code === 'BF113')).toBeUndefined()
461
+ })
462
+
463
+ test('matrix 6: SSR output reflects the re-provisioned import', () => {
464
+ const consumerSource = `'use client'
465
+ import { useDouble } from '../hooks/useDouble'
466
+
467
+ export function DoublerSSR() {
468
+ const { value, bump } = useDouble(21)
469
+ return <button onClick={bump}>{value()}</button>
470
+ }
471
+ `
472
+ const consumerPath = writeFixture('components/DoublerSSR.tsx', consumerSource)
473
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
474
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
475
+ const template = result.files.find(f => f.type === 'markedTemplate')
476
+ expect(template).toBeDefined()
477
+ // The injected import is part of the same rewritten source both passes read:
478
+ // the adapter re-emits it via metadata.templateImports…
479
+ expect(template!.content).toMatch(/import\s*\{\s*doubleIt\s*\}\s*from\s*'\.\.\/lib\/mathmod'/)
480
+ // …and the inlined signal initializer calls it at SSR render.
481
+ expect(template!.content).toContain('doubleIt(')
482
+ })
483
+
484
+ test('already-satisfied import dedupes: no BF113, no duplicate declaration', () => {
485
+ const consumerSource = `'use client'
486
+ import { useDouble } from '../hooks/useDouble'
487
+ import { doubleIt } from '../lib/mathmod'
488
+
489
+ export function DoublerSelfImporting() {
490
+ const { value, bump } = useDouble(21)
491
+ return <button onClick={() => { bump(); doubleIt(value()) }}>{value()}</button>
492
+ }
493
+ `
494
+ const consumerPath = writeFixture('components/DoublerSelfImporting.tsx', consumerSource)
495
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
496
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
497
+ expect(result.errors.find(e => e.code === 'BF113')).toBeUndefined()
498
+ const clientJs = result.files.find(f => f.type === 'clientJs')
499
+ expect(clientJs).toBeDefined()
500
+ expect((clientJs!.content.match(/from '\.\.\/lib\/mathmod'/g) ?? []).length).toBe(1)
501
+ })
502
+ })