@barefootjs/jsx 0.33.2 → 0.33.4

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 (61) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +804 -445
  8. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/imports.d.ts +60 -2
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  20. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +26 -2
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-to-ir.d.ts.map +1 -1
  24. package/dist/props-binding.d.ts +35 -0
  25. package/dist/props-binding.d.ts.map +1 -1
  26. package/dist/types.d.ts +63 -13
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  30. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  31. package/src/__tests__/fragment-body-loop-key.test.ts +95 -0
  32. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  33. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  34. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  35. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  36. package/src/__tests__/merge-template-imports.test.ts +41 -1
  37. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  38. package/src/__tests__/multi-root-loop-body.test.ts +7 -3
  39. package/src/__tests__/preamble-declarations.test.ts +42 -0
  40. package/src/__tests__/root-key-relay.test.ts +170 -0
  41. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  42. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  43. package/src/analyzer.ts +36 -0
  44. package/src/compiler.ts +94 -104
  45. package/src/index.ts +1 -1
  46. package/src/ir-to-client-js/build-references.ts +7 -0
  47. package/src/ir-to-client-js/collect-elements.ts +27 -5
  48. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
  49. package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
  50. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
  51. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  52. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  53. package/src/ir-to-client-js/control-flow.ts +12 -0
  54. package/src/ir-to-client-js/html-template.ts +174 -23
  55. package/src/ir-to-client-js/imports.ts +178 -5
  56. package/src/ir-to-client-js/index.ts +5 -0
  57. package/src/ir-to-client-js/prop-handling.ts +6 -17
  58. package/src/ir-to-client-js/utils.ts +30 -2
  59. package/src/jsx-to-ir.ts +592 -58
  60. package/src/props-binding.ts +51 -0
  61. package/src/types.ts +60 -13
@@ -266,4 +266,153 @@ describe('Signal Getter Not Called (BF044)', () => {
266
266
  expect(bf044[0].severity).toBe('error')
267
267
  })
268
268
  })
269
+
270
+ /**
271
+ * Nested descent (#2755 / #2751 upstream fix).
272
+ *
273
+ * The gate used to open with `if (!ts.isIdentifier(expr)) return`, so it saw
274
+ * only an expression's TOP-LEVEL node. Every shape below reaches a rendered
275
+ * position through some wrapper, and every one of them used to compile
276
+ * silently and then miscompile downstream — the accessor stringified into a
277
+ * DOM property (#2755) or referenced from a module-scope template thunk that
278
+ * cannot see it (#2751).
279
+ *
280
+ * The negative cases are the load-bearing half: descending EVERYWHERE would
281
+ * break the Context-Provider idiom, where handing a descendant an uncalled
282
+ * accessor is the whole point. The rule is "rendered position", not "nested".
283
+ */
284
+ describe('nested descent into rendered positions', () => {
285
+ // `Child` is declared AFTER `Counter` deliberately: `analyzeComponent`
286
+ // analyzes the FIRST function in the module, so hoisting the child above
287
+ // would silently analyze `Child` instead and make every case below report
288
+ // zero diagnostics — the negative cases would then pass for the wrong
289
+ // reason. The positive block is the control that proves the walk is
290
+ // actually live in this exact module shape.
291
+ const wrap = (body: string) => `
292
+ 'use client'
293
+ import { createSignal } from '@barefootjs/client'
294
+
295
+ export function Counter() {
296
+ const [count, setCount] = createSignal(0)
297
+ const [items, setItems] = createSignal([1, 2])
298
+ const obj: Record<string, unknown> = {}
299
+ return ${body}
300
+ }
301
+
302
+ function Child(props: { value?: unknown }) { return <span /> }
303
+ `
304
+ const bf044Of = (body: string) =>
305
+ compileToIR(wrap(body)).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
306
+
307
+ describe('fires — the getter reaches a rendered position', () => {
308
+ test.each([
309
+ ['ternary condition', '<div className={count ? "on" : "off"} />'],
310
+ ['template literal span', '<div className={`x-${count}`} />'],
311
+ ['call argument', '<div className={String(count)} />'],
312
+ ['array literal member', '<div className={[count].join("")} />'],
313
+ ['style object property value', '<div style={{ color: count }} />'],
314
+ ['JSX text child', '<div>{count ? "a" : "b"}</div>'],
315
+ ])('%s', (_label, body) => {
316
+ const bf044 = bf044Of(body)
317
+ expect(bf044).toHaveLength(1)
318
+ expect(bf044[0].message).toContain("'count'")
319
+ })
320
+ })
321
+
322
+ describe('stays silent — the getter is handed onward, not rendered', () => {
323
+ test.each([
324
+ // The `<SelectContext.Provider value={{ open, ... }}>` shape: every
325
+ // member is an accessor BY CONTRACT. Calling it here would freeze the
326
+ // value at provider-render time and break every consumer.
327
+ ['component prop, object literal member', '<Child value={{ x: count }} />'],
328
+ ['component prop, ternary', '<Child value={count ? 1 : 2} />'],
329
+ ['component prop, call argument', '<Child value={String(count)} />'],
330
+ ])('%s', (_label, body) => {
331
+ expect(bf044Of(body)).toHaveLength(0)
332
+ })
333
+
334
+ test('a loop-row param shadowing a same-named signal', () => {
335
+ // `count` here is the row item, not the signal — resolved through the
336
+ // ambient `BindingScope`, which sees bindings introduced OUTSIDE the
337
+ // checked expression.
338
+ expect(bf044Of('<ul>{items().map(count => <li className={count ? "a" : "b"} />)}</ul>')).toHaveLength(0)
339
+ })
340
+
341
+ test('correctly called getter in every nested shape', () => {
342
+ expect(bf044Of('<div className={count() ? "on" : "off"} />')).toHaveLength(0)
343
+ expect(bf044Of('<div style={{ color: count() }} />')).toHaveLength(0)
344
+ })
345
+ })
346
+
347
+ describe('binding and parameter defaults', () => {
348
+ // A default VALUE is an ordinary expression in the enclosing scope, but
349
+ // the walk used to visit only binding NAMES. Measured before the fix:
350
+ // both shapes compiled silently and emitted a module-scope `template`
351
+ // thunk referencing a component-scope binding — `ReferenceError` on CSR
352
+ // mount, i.e. #2751's mechanism surviving inside the very check meant to
353
+ // close it.
354
+ test('destructuring default in a rendered position', () => {
355
+ expect(bf044Of('<div className={(() => { const { x = count } = ({} as { x?: unknown }); return String(x) })()} />')).toHaveLength(1)
356
+ })
357
+
358
+ test('parameter default in a rendered position', () => {
359
+ expect(bf044Of('<div className={((f = count) => String(f))()} />')).toHaveLength(1)
360
+ })
361
+
362
+ test('a later default reading an EARLIER parameter stays silent', () => {
363
+ // JS binds parameters left to right: `(count, x = count) => …` reads
364
+ // the already-bound parameter, not the signal it shadows (verified
365
+ // against V8). Visiting every default before binding any parameter
366
+ // would flag this — a false positive on working code.
367
+ expect(bf044Of('<div className={((count2: unknown, x = count2) => String(x))(1)} />')).toHaveLength(0)
368
+ expect(bf044Of('<div className={((count: unknown, x = count) => String(x))(1)} />')).toHaveLength(0)
369
+ })
370
+
371
+ test('a later default reading an EARLIER pattern element stays silent', () => {
372
+ // The same left-to-right rule applies WITHIN a pattern.
373
+ //
374
+ // The declaration sits inside a NESTED block on purpose. At a function
375
+ // body's top level, `collectBlockDeclarations` pre-scans the whole
376
+ // `VariableStatement` and binds every name in the pattern up front,
377
+ // regardless of order — so a top-level version of this case passes
378
+ // even with the sequential threading removed, and pins nothing.
379
+ // `collectBlockDeclarations` does not descend into an `if` block, so
380
+ // here the only thing that can bind `c` before `x`'s default is read
381
+ // is `visitBindingDefaults` itself.
382
+ expect(bf044Of('<div className={(() => { if (obj) { const { count, x = count } = obj; return String(x) } return "" })()} />')).toHaveLength(0)
383
+ })
384
+
385
+ test('a literal default stays silent', () => {
386
+ // The overwhelmingly common shape (`{ size = 'md' }`): the default is
387
+ // not a reactive name, so widening the walk must not touch it.
388
+ expect(bf044Of(`<div className={(({ size = 'md' }: { size?: string }) => size)({})} />`)).toHaveLength(0)
389
+ })
390
+ })
391
+
392
+ test('does not misfire on a TYPE position', () => {
393
+ // A type is not a value. `({} as { count?: unknown })` in a rendered
394
+ // position used to read the type literal's property name as a bare
395
+ // reference to the same-named signal and refuse valid code.
396
+ expect(bf044Of('<div className={String(({} as { count?: unknown }).count)} />')).toHaveLength(0)
397
+ })
398
+
399
+ test('does not misfire on a nested element ATTRIBUTE NAME', () => {
400
+ // The walk stops at a nested JSX boundary. Without that guard, descending
401
+ // into a `.map()` body that returns JSX read the nested element's own
402
+ // attribute NAME (`checked=`) as a bare reference to the same-named
403
+ // signal — `transformNode` re-walks that element independently anyway.
404
+ const source = `
405
+ 'use client'
406
+ import { createSignal } from '@barefootjs/client'
407
+
408
+ export function Boxes() {
409
+ const [checked, setChecked] = createSignal(false)
410
+ const [items, setItems] = createSignal([1, 2])
411
+ return <ul>{items().map(n => <li><input checked={checked()} /></li>)}</ul>
412
+ }
413
+ `
414
+ const bf044 = compileToIR(source).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
415
+ expect(bf044).toHaveLength(0)
416
+ })
417
+ })
269
418
  })
@@ -0,0 +1,47 @@
1
+ /**
2
+ * #2767 follow-up: the state-only-file client-JS path (a `.tsx` with no
3
+ * JSX return but an exported `/* @client *\/` module signal) used to filter
4
+ * OUT every default- or namespace-imported specifier when deciding which
5
+ * external imports to preserve (`s => !s.isDefault && !s.isNamespace`,
6
+ * `compiler.ts`'s single-component early return) — not just render them
7
+ * wrong, but drop them entirely. A signal initializer that references a
8
+ * default- or namespace-imported helper compiled with zero diagnostics
9
+ * into client JS that throws `ReferenceError` in the browser, since the
10
+ * import never made it into the bundle at all.
11
+ */
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { compileJSX } from '../compiler'
14
+ import { TestAdapter } from '../adapters/test-adapter'
15
+
16
+ const adapter = new TestAdapter()
17
+
18
+ describe('state-only file: default/namespace imports feeding a @client signal', () => {
19
+ test('preserves a default-imported helper referenced by the signal initializer', () => {
20
+ const source = `'use client'
21
+ import defaults from './defaults.json' with { type: 'json' }
22
+ import { createSignal } from '@barefootjs/client'
23
+ /* @client */
24
+ export const [count, setCount] = createSignal(defaults.start)
25
+ `
26
+ const result = compileJSX(source, 'store.tsx', { adapter })
27
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
28
+ const clientJs = result.files.find(f => f.type === 'clientJs')
29
+ expect(clientJs).toBeDefined()
30
+ expect(clientJs!.content).toContain("import defaults from './defaults.json'")
31
+ expect(clientJs!.content).not.toContain('import { defaults }')
32
+ })
33
+
34
+ test('preserves a namespace-imported helper referenced by the signal initializer', () => {
35
+ const source = `'use client'
36
+ import * as util from './util'
37
+ import { createSignal } from '@barefootjs/client'
38
+ /* @client */
39
+ export const [count, setCount] = createSignal(util.base())
40
+ `
41
+ const result = compileJSX(source, 'store2.tsx', { adapter })
42
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
43
+ const clientJs = result.files.find(f => f.type === 'clientJs')
44
+ expect(clientJs).toBeDefined()
45
+ expect(clientJs!.content).toContain("import * as util from './util'")
46
+ })
47
+ })
package/src/analyzer.ts CHANGED
@@ -4237,6 +4237,42 @@ export function listComponentFunctions(
4237
4237
  ts.ScriptKind.TSX
4238
4238
  )
4239
4239
 
4240
+ return listComponentFunctionsFromSourceFile(sourceFile)
4241
+ }
4242
+
4243
+ /**
4244
+ * One parse, two structural facts about a component file — the component
4245
+ * names it exports (today's `listComponentFunctions` result) and the
4246
+ * PascalCase JSX tags it instantiates (`collectJsxComponentTags`'s
4247
+ * out-edges). Used by `@barefootjs/vite`'s `discoverComponents` to build the
4248
+ * component-instantiation graph that decides which SERVER files also need a
4249
+ * client bundle because they transitively own a `'use client'` descendant
4250
+ * (issue #2767) — a property no single-file compile can answer, since it
4251
+ * depends on the whole discovered corpus.
4252
+ */
4253
+ export interface ComponentFileScan {
4254
+ /** Component names this file exports (same result as `listComponentFunctions`). */
4255
+ exports: string[]
4256
+ /** PascalCase JSX tag identifiers this file references. */
4257
+ referencedComponents: string[]
4258
+ }
4259
+
4260
+ export function scanComponentFile(source: string, filePath: string): ComponentFileScan {
4261
+ const sourceFile = ts.createSourceFile(
4262
+ filePath,
4263
+ source,
4264
+ ts.ScriptTarget.Latest,
4265
+ true,
4266
+ ts.ScriptKind.TSX
4267
+ )
4268
+
4269
+ return {
4270
+ exports: listComponentFunctionsFromSourceFile(sourceFile),
4271
+ referencedComponents: [...collectJsxComponentTags(sourceFile)],
4272
+ }
4273
+ }
4274
+
4275
+ function listComponentFunctionsFromSourceFile(sourceFile: ts.SourceFile): string[] {
4240
4276
  const componentNames: string[] = []
4241
4277
 
4242
4278
  // 'use client' directive detection (controls whether multi-return JSX
package/src/compiler.ts CHANGED
@@ -19,7 +19,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
19
19
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
20
20
  import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
21
21
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
22
- import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
+ import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest, renderUsedImportLines, mergeCompiledClientJsImports } from './ir-to-client-js/imports.ts'
23
23
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
24
24
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
25
25
  import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-prefixer.ts'
@@ -43,11 +43,27 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
43
43
  * conflict-free block.
44
44
  *
45
45
  * Named value/type imports from the same source are folded into their first
46
- * occurrence (preserving line order and first-seen symbol order); every
47
- * other import form (side-effect, default, namespace) is kept in place and
48
- * de-duplicated by exact line. This ensures a symbol is never imported
49
- * twice across sibling components a redeclaration that Bun tolerates but
50
- * stricter ESM parsers (the Deno runtime that renders SSR templates) reject.
46
+ * occurrence (preserving line order and first-seen symbol order); a default
47
+ * or namespace import is likewise folded by source rather than deduplicated
48
+ * by exact line every sibling component that shares a module-scope import
49
+ * declaration compiles it independently, so the same default binding can
50
+ * legally arrive as `import cfg from 'x'` from one component and `import
51
+ * cfg, { helper } from 'x'` from another once only the SECOND one also uses
52
+ * a named specifier from the same statement. Folding by source (default
53
+ * name from the first occurrence that has one, named specifiers unioned)
54
+ * collapses both into one `import cfg, { helper } from 'x'` line; the two
55
+ * exact-line-deduplicated strings would otherwise both survive and
56
+ * redeclare `cfg` (#2767 follow-up — this shape was unreachable before a
57
+ * default/namespace-importing server component could become a real client
58
+ * bundle at all). A namespace specifier can't combine with named ones on
59
+ * one line, so it's folded to its own line, keyed by (source, local name)
60
+ * — see `renderUsedImportLines`'s docstring for the same split rendering
61
+ * rule `collectExternalImports` uses.
62
+ *
63
+ * Every other import form (side-effect) is kept in place and de-duplicated
64
+ * by exact line. This ensures a symbol is never imported twice across
65
+ * sibling components — a redeclaration that Bun tolerates but stricter ESM
66
+ * parsers (the Deno runtime that renders SSR templates) reject.
51
67
  *
52
68
  * For a single-component file the output is identical to the input order;
53
69
  * only repeated sibling imports collapse.
@@ -62,38 +78,70 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
62
78
  export function mergeTemplateImports(lines: string[]): string {
63
79
  const result: string[] = []
64
80
  const valueIdx = new Map<string, number>()
81
+ const valueDefault = new Map<string, string>()
65
82
  const valueNames = new Map<string, Set<string>>()
66
83
  const typeIdx = new Map<string, number>()
67
84
  const typeNames = new Map<string, Set<string>>()
68
85
  const seenOther = new Set<string>()
69
86
 
70
- const fold = (
71
- src: string,
72
- rawNames: string,
73
- idx: Map<string, number>,
74
- names: Map<string, Set<string>>,
75
- render: (src: string, names: Set<string>) => string,
76
- ) => {
77
- if (!idx.has(src)) {
78
- idx.set(src, result.length)
79
- names.set(src, new Set())
87
+ const foldType = (src: string, rawNames: string) => {
88
+ if (!typeIdx.has(src)) {
89
+ typeIdx.set(src, result.length)
90
+ typeNames.set(src, new Set())
80
91
  result.push('')
81
92
  }
82
- const set = names.get(src)!
93
+ const set = typeNames.get(src)!
83
94
  for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
84
- result[idx.get(src)!] = render(src, set)
95
+ result[typeIdx.get(src)!] = `import type { ${[...set].join(', ')} } from '${src}'`
96
+ }
97
+
98
+ const foldValue = (src: string, defaultName: string | null, rawNames: string | null) => {
99
+ if (!valueIdx.has(src)) {
100
+ valueIdx.set(src, result.length)
101
+ valueNames.set(src, new Set())
102
+ result.push('')
103
+ }
104
+ if (defaultName && !valueDefault.has(src)) valueDefault.set(src, defaultName)
105
+ if (rawNames) {
106
+ const set = valueNames.get(src)!
107
+ for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
108
+ }
109
+ result[valueIdx.get(src)!] = renderUsedImportLines(
110
+ src,
111
+ valueDefault.get(src) ?? null,
112
+ null,
113
+ [...valueNames.get(src)!],
114
+ ).join('\n')
85
115
  }
86
116
 
87
117
  for (const raw of lines) {
88
118
  const line = raw.trim()
89
119
  if (!line) continue
90
120
  const typeMatch = line.match(/^import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
91
- const valueMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
92
- if (valueMatch) {
93
- fold(valueMatch[2], valueMatch[1], valueIdx, valueNames, (s, n) => `import { ${[...n].join(', ')} } from '${s}'`)
94
- } else if (typeMatch) {
95
- fold(typeMatch[2], typeMatch[1], typeIdx, typeNames, (s, n) => `import type { ${[...n].join(', ')} } from '${s}'`)
121
+ const namedMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
122
+ const defaultNamedMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
123
+ const defaultOnlyMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]\s*;?$/)
124
+
125
+ if (typeMatch) {
126
+ foldType(typeMatch[2], typeMatch[1])
127
+ } else if (namedMatch) {
128
+ foldValue(namedMatch[2], null, namedMatch[1])
129
+ } else if (defaultNamedMatch) {
130
+ foldValue(defaultNamedMatch[3], defaultNamedMatch[1], defaultNamedMatch[2])
131
+ } else if (defaultOnlyMatch) {
132
+ foldValue(defaultOnlyMatch[2], defaultOnlyMatch[1], null)
96
133
  } else if (!seenOther.has(line)) {
134
+ // Covers namespace imports (`import * as X from 'src'`) and
135
+ // side-effect imports alike — deduplicated by exact line, same as
136
+ // before. A namespace import can't combine with named specifiers on
137
+ // one line (see `renderUsedImportLines`), so two components that
138
+ // both import the SAME namespace binding from the SAME source
139
+ // always emit byte-identical lines and collapse here; two DIFFERENT
140
+ // local namespace names for the same source (unusual — would require
141
+ // sibling components to alias the same module-scope `import * as`
142
+ // declaration differently, which isn't possible for one shared
143
+ // declaration) are kept as separate lines rather than silently
144
+ // merged.
97
145
  seenOther.add(line)
98
146
  result.push(line)
99
147
  }
@@ -446,30 +494,9 @@ function compileMultipleComponents(
446
494
  }
447
495
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
448
496
  if (clientJsOutputs.length > 0) {
449
- const importsBySource = new Map<string, Set<string>>()
450
- const otherImports: string[] = []
451
- const allCode: string[] = []
452
- for (const js of clientJsOutputs) {
453
- for (const line of js.split('\n')) {
454
- if (line.startsWith('import ')) {
455
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
456
- if (match) {
457
- const source = match[2]
458
- if (!importsBySource.has(source)) importsBySource.set(source, new Set())
459
- for (const n of match[1].split(',').map(n => n.trim())) importsBySource.get(source)!.add(n)
460
- } else if (!otherImports.includes(line)) {
461
- otherImports.push(line)
462
- }
463
- }
464
- }
465
- allCode.push(js.replace(/^import .+\n/gm, '').trim())
466
- }
467
- const mergedClientImports = [...importsBySource].map(([src, names]) =>
468
- `import { ${[...names].sort().join(', ')} } from '${src}'`
469
- )
470
497
  files.push({
471
498
  path: filePath.replace(/\.tsx?$/, '.client.js'),
472
- content: [...mergedClientImports, ...otherImports, '', ...allCode.filter(Boolean)].join('\n'),
499
+ content: mergeCompiledClientJsImports(clientJsOutputs),
473
500
  type: 'clientJs',
474
501
  })
475
502
  }
@@ -557,63 +584,14 @@ function compileMultipleComponents(
557
584
  })
558
585
  }
559
586
 
560
- // Combine client JS if any
587
+ // Combine client JS if any — see `mergeCompiledClientJsImports`'s
588
+ // docstring for why this AST-based merge (not a text/regex line scan)
589
+ // is required here specifically (#2767 follow-up).
561
590
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
562
591
  if (clientJsOutputs.length > 0) {
563
- // Separate imports from code and merge imports by source
564
- const importsBySource = new Map<string, Set<string>>()
565
- const otherImports: string[] = []
566
- const allCode: string[] = []
567
-
568
- for (const js of clientJsOutputs) {
569
- const lines = js.split('\n')
570
- const codeLines: string[] = []
571
-
572
- for (const line of lines) {
573
- if (line.startsWith('import ')) {
574
- // Parse named imports: import { a, b } from 'source'
575
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
576
- if (match) {
577
- const names = match[1].split(',').map(n => n.trim())
578
- const source = match[2]
579
- if (!importsBySource.has(source)) {
580
- importsBySource.set(source, new Set())
581
- }
582
- const set = importsBySource.get(source)!
583
- for (const name of names) {
584
- set.add(name)
585
- }
586
- } else {
587
- // Other import styles (default, namespace, etc.)
588
- if (!otherImports.includes(line)) {
589
- otherImports.push(line)
590
- }
591
- }
592
- } else {
593
- codeLines.push(line)
594
- }
595
- }
596
-
597
- allCode.push(codeLines.join('\n').trim())
598
- }
599
-
600
- // Generate merged imports
601
- const mergedImports: string[] = []
602
- for (const [source, names] of importsBySource) {
603
- const sortedNames = [...names].sort()
604
- mergedImports.push(`import { ${sortedNames.join(', ')} } from '${source}'`)
605
- }
606
-
607
- const combinedClientJs = [
608
- ...mergedImports,
609
- ...otherImports,
610
- '',
611
- ...allCode.filter(Boolean),
612
- ].join('\n')
613
-
614
592
  files.push({
615
593
  path: filePath.replace(/\.tsx?$/, '.client.js'),
616
- content: combinedClientJs,
594
+ content: mergeCompiledClientJsImports(clientJsOutputs),
617
595
  type: 'clientJs',
618
596
  })
619
597
  }
@@ -735,7 +713,9 @@ export function compileJSX(
735
713
 
736
714
  // Preserve non-runtime user imports whose specifiers are referenced
737
715
  // in the generated body (e.g. an initializer that calls an imported
738
- // helper: `createSignal(defaultValue())`).
716
+ // helper: `createSignal(defaultValue())`). A default- or namespace-
717
+ // imported helper needs its own import syntax, not named braces —
718
+ // see `renderUsedImportLines`'s docstring.
739
719
  const externalImportLines: string[] = []
740
720
  const isUsedAsValue = makeValueUsageTest(body)
741
721
  for (const imp of ctx.imports) {
@@ -745,12 +725,22 @@ export function compileJSX(
745
725
  externalImportLines.push(`import '${imp.source}'`)
746
726
  continue
747
727
  }
748
- const used = imp.specifiers
749
- .filter(s => !s.isDefault && !s.isNamespace && !s.isTypeOnly && isUsedAsValue(s.alias || s.name))
750
- .map(s => s.alias ? `${s.name} as ${s.alias}` : s.name)
751
- if (used.length > 0) {
752
- externalImportLines.push(`import { ${used.join(', ')} } from '${imp.source}'`)
728
+ const usedNamed: string[] = []
729
+ let usedDefault: string | null = null
730
+ let usedNamespace: string | null = null
731
+ for (const s of imp.specifiers) {
732
+ if (s.isTypeOnly) continue
733
+ const localName = s.alias || s.name
734
+ if (!isUsedAsValue(localName)) continue
735
+ if (s.isDefault) {
736
+ usedDefault = localName
737
+ } else if (s.isNamespace) {
738
+ usedNamespace = localName
739
+ } else {
740
+ usedNamed.push(s.alias ? `${s.name} as ${s.alias}` : s.name)
741
+ }
753
742
  }
743
+ externalImportLines.push(...renderUsedImportLines(imp.source, usedDefault, usedNamespace, usedNamed))
754
744
  }
755
745
 
756
746
  const allImports = [runtimeImportLine, ...externalImportLines].filter(Boolean).join('\n')
package/src/index.ts CHANGED
@@ -66,7 +66,7 @@ export type {
66
66
  } from './types.ts'
67
67
 
68
68
  // Analyzer
69
- export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext } from './analyzer.ts'
69
+ export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, scanComponentFile, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext, type ComponentFileScan } from './analyzer.ts'
70
70
  export { createProgramForCorpus, type SharedProgramOptions } from './shared-program.ts'
71
71
 
72
72
  // JSX to IR transformer
@@ -302,6 +302,13 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
302
302
  if (v && attr.value.kind !== 'literal') addExprEdges(ROOT_SOURCE, v, 'template-closure')
303
303
  }
304
304
  for (const ev of el.events) addExprEdges(ROOT_SOURCE, ev.handler, 'init-body')
305
+ // A `ref` callback runs in `initX`'s closure, same context as an event
306
+ // handler — not the template closure. Without this edge, a ref inside
307
+ // a loop nested past the top level (`elem.innerLoops`, never walked by
308
+ // Phase 1's dedicated ref traces) is invisible to reachability, so
309
+ // `computeDeclarationScopes` (compute-scope.ts) drops its declaration
310
+ // as dead code while the emitter still emits the call site (#2750).
311
+ if (el.ref) addExprEdges(ROOT_SOURCE, el.ref, 'init-body')
305
312
  descend()
306
313
  },
307
314
  component: ({ node: c, descend, descendJsxChildren }) => {
@@ -13,6 +13,7 @@ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
15
  import { identifierPattern } from '../identifier-pattern.ts'
16
+ import { classifyDOMProp } from '@barefootjs/shared'
16
17
 
17
18
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
18
19
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -581,11 +582,32 @@ function collectReactiveChildProps(node: IRComponent, ctx: ClientJsContext): voi
581
582
  for (const prop of node.props) {
582
583
  if (prop.name === '...' || prop.name.startsWith('...')) continue
583
584
  if (prop.value.kind === 'jsx-children') continue
584
- const isEventHandler =
585
- prop.name.startsWith('on') &&
586
- prop.name.length > 2 &&
587
- prop.name[2] === prop.name[2].toUpperCase()
588
- if (isEventHandler) continue
585
+ // `classifyDOMProp` is the single source of truth for "how does this prop
586
+ // reach the DOM?" (`packages/shared/src/dom-prop.ts`). `applyRestAttrs`
587
+ // reads it to build its own attribute set — everything whose kind is NOT
588
+ // `ref` / `event` / `skip`. This mirror is the compile-time twin of that
589
+ // loop, so it takes the same three exclusions from the same classifier
590
+ // rather than re-deciding them by hand (#2749: the hand-rolled `on[A-Z]`
591
+ // check covered events but not `ref`, so a `ref` on a child-component
592
+ // call site was mirrored as `setAttribute('ref', String(<fn source>))` —
593
+ // an attribute SSR never emits, failing the SSR-vs-hydrated snapshot and
594
+ // shadowing the real binding `initChild` was already passing correctly).
595
+ //
596
+ // Two small widenings come with reading the shared classifier, both
597
+ // intended. `skip` also covers a `children` prop reaching here as an
598
+ // expression rather than as the `jsx-children` variant handled above —
599
+ // `setAttribute('children', …)` was never a sensible mirror. And the
600
+ // classifier's event test requires an actual A-Z at index 2, where the
601
+ // old `name[2] === name[2].toUpperCase()` also matched non-letters
602
+ // (`on-foo`, `on1x`), which are not DOM events and now mirror normally.
603
+ //
604
+ // Why not also exclude `innerHTML` here: `dangerouslySetInnerHTML` as a
605
+ // reactive CHILD-COMPONENT prop is a separate, unmeasured shape — the
606
+ // element-level path is what the `dangerous-inner-html*` fixtures cover.
607
+ // Narrowing this mirror to the three kinds `applyRestAttrs` excludes keeps
608
+ // the change to the classification `ref` was already missing.
609
+ const domKind = classifyDOMProp(prop.name).kind
610
+ if (domKind === 'ref' || domKind === 'event' || domKind === 'skip') continue
589
611
  // Only `expression` / `template` variants drive reactive prop forwarding.
590
612
  if (prop.value.kind !== 'expression' && prop.value.kind !== 'template') continue
591
613
  const valueExpr = attrValueToString(prop.value)!
@@ -26,9 +26,10 @@ import {
26
26
  isTextOnlyConditional,
27
27
  buildChildRefBindings,
28
28
  } from '../shared.ts'
29
- import { irChildrenToJsExpr } from '../../html-template.ts'
29
+ import { irChildrenToJsExpr, renderPreamble } from '../../html-template.ts'
30
30
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
31
31
  import type { ComponentLoopPlan, NestedComponentInit } from './types.ts'
32
+ import { internalInvariant } from '../../../errors.ts'
32
33
 
33
34
  /** @internal — prefer `buildLoopPlan`. */
34
35
  export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?: string): ComponentLoopPlan {
@@ -37,6 +38,22 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
37
38
  const keyExpr = wrapLoopParamAsAccessor(elem.key || '__idx', elem.param, elem.paramBindings)
38
39
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
39
40
 
41
+ // A component-root loop's preamble is JS-only by construction: Phase 1
42
+ // (jsx-to-ir.ts) already refuses and strips any preamble with a JSX leaf
43
+ // (`builderNames.length > 0`) on this shape, because a lowered HTML-string
44
+ // leaf passed as a prop would diverge from SSR's real JSX elements — see
45
+ // `rowConstruction: 'dom-ops'` below. `renderLeaf` is therefore never
46
+ // reachable here; if it ever fires, a Phase-1 refusal for the new shape
47
+ // that let a JSX-bearing preamble through is missing, not this line.
48
+ const mapPreambleWrapped = elem.preamble
49
+ ? renderPreamble(elem.preamble, {
50
+ transformJs: text => wrapLoopParamAsAccessor(text, elem.param, elem.paramBindings),
51
+ renderLeaf: () => {
52
+ internalInvariant(false, 'component-root loop received a JSX-bearing preamble — Phase 1 should have refused it')
53
+ },
54
+ })
55
+ : ''
56
+
40
57
  // Only init components at loopDepth 0 — inner-loop components are handled by their own loop
41
58
  const outerNestedComps = (elem.nestedComponents ?? []).filter(c => !c.loopDepth)
42
59
  const nestedComps: NestedComponentInit[] = outerNestedComps.map(comp => {
@@ -64,6 +81,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
64
81
  // prop would diverge from SSR (which passes real JSX elements). The plan
65
82
  // dispatcher refuses a JSX-bearing preamble on this variant.
66
83
  rowConstruction: 'dom-ops',
84
+ mapPreambleWrapped,
67
85
  containerVar: `_${varSlotId(elem.slotId)}`,
68
86
  markerId: elem.markerId,
69
87
  arrayExpr: buildChainedArrayExpr(elem),
@@ -77,6 +77,8 @@ interface DynamicLoopCommon extends LoopPlanCommon {
77
77
  paramHead: string
78
78
  /** Statement to unwrap a destructured param at body entry. Empty when not needed. */
79
79
  paramUnwrap: string
80
+ /** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
81
+ mapPreambleWrapped: string
80
82
  }
81
83
 
82
84
  /**
@@ -117,8 +119,6 @@ export interface LoopChildRefBinding {
117
119
  */
118
120
  interface PlainLoopVariant extends DynamicLoopCommon {
119
121
  kind: 'plain'
120
- /** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
121
- mapPreambleWrapped: string
122
122
  /** HTML template string for one item. */
123
123
  template: string
124
124
  /**
@@ -225,8 +225,6 @@ interface ComponentLoopVariant extends DynamicLoopCommon {
225
225
  */
226
226
  interface CompositeLoopVariant extends DynamicLoopCommon {
227
227
  kind: 'composite'
228
- /** Wrapped mapPreamble line, hoisted before the SSR/CSR split. Empty when none. */
229
- mapPreambleWrapped: string
230
228
  /** Inner template HTML for the loop body (single item). */
231
229
  template: string
232
230
  /** Outer-level child components (depth 0), with `insideConditional` ones already filtered out. */