@barefootjs/vite 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/vite",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
4
4
  "description": "Vite plugin for BarefootJS: Vite/Rollup owns bundling, hashing, chunking, tree-shaking and minification of client assets, BarefootJS keeps only the JSX to (template, client JS) compile",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,17 +38,17 @@
38
38
  "directory": "packages/vite"
39
39
  },
40
40
  "dependencies": {
41
- "@barefootjs/shared": "0.33.2"
41
+ "@barefootjs/shared": "0.33.4"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@barefootjs/jsx": ">=0.2.0",
45
45
  "vite": "^6.0.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@barefootjs/client": "0.33.2",
49
- "@barefootjs/go-template": "0.33.2",
50
- "@barefootjs/hono": "0.33.2",
51
- "@barefootjs/jsx": "0.33.2",
48
+ "@barefootjs/client": "0.33.4",
49
+ "@barefootjs/go-template": "0.33.4",
50
+ "@barefootjs/hono": "0.33.4",
51
+ "@barefootjs/jsx": "0.33.4",
52
52
  "typescript": "^5.0.0",
53
53
  "vite": "^6.0.0"
54
54
  }
@@ -2,7 +2,13 @@ import { describe, test, expect, afterEach } from 'bun:test'
2
2
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { buildChildNameIndex, hasUseClientDirective, discoverComponentFiles, discoverComponents } from '../discover.ts'
5
+ import {
6
+ buildChildNameIndex,
7
+ computeClientEntryPaths,
8
+ hasUseClientDirective,
9
+ discoverComponentFiles,
10
+ discoverComponents,
11
+ } from '../discover.ts'
6
12
 
7
13
  describe('hasUseClientDirective', () => {
8
14
  test('detects a leading double-quoted directive', () => {
@@ -75,13 +81,124 @@ describe('discoverComponents', () => {
75
81
  expect(byName['Client.tsx']).toBe(true)
76
82
  expect(byName['Server.tsx']).toBe(false)
77
83
  })
84
+
85
+ // #2767: a plain server file that renders a 'use client' descendant needs
86
+ // its OWN client bundle too — it's the file whose compiled init actually
87
+ // owns the `initChild(...)` call reaching that descendant. Exercises the
88
+ // analyzer scan (`scanComponentFile`) + `computeClientEntryPaths` closure
89
+ // end to end, on real files, not fabricated rows.
90
+ test('a server parent that renders a client child needs its own client entry', async () => {
91
+ dir = await mkdtemp(join(tmpdir(), 'barefoot-discover-entry-'))
92
+ await writeFile(join(dir, 'Child.tsx'), '\'use client\'\nexport function Child() { return <span/> }')
93
+ await writeFile(
94
+ join(dir, 'Parent.tsx'),
95
+ "import { Child } from './Child'\nexport function Parent() { return <div><Child/></div> }",
96
+ )
97
+ await writeFile(join(dir, 'Leaf.tsx'), 'export function Leaf() { return <span/> }')
98
+
99
+ const found = await discoverComponents([dir], p => Bun.file(p).text())
100
+ const byName = Object.fromEntries(found.map(f => [f.absPath.slice(dir.length + 1), f.needsClientEntry]))
101
+ expect(byName['Child.tsx']).toBe(true)
102
+ expect(byName['Parent.tsx']).toBe(true)
103
+ expect(byName['Leaf.tsx']).toBe(false)
104
+ })
105
+ })
106
+
107
+ describe('computeClientEntryPaths', () => {
108
+ test('a server parent that directly references a client child needs its own entry', () => {
109
+ const rows = [
110
+ { absPath: '/proj/Parent.tsx', isClient: false, exportedComponents: ['Parent'], referencedComponents: ['Child'] },
111
+ { absPath: '/proj/Child.tsx', isClient: true, exportedComponents: ['Child'], referencedComponents: [] },
112
+ ]
113
+ const entries = computeClientEntryPaths(rows)
114
+ expect(entries.has('/proj/Parent.tsx')).toBe(true)
115
+ expect(entries.has('/proj/Child.tsx')).toBe(true)
116
+ })
117
+
118
+ // A parent-of-client-only fix would miss this: every server file on the
119
+ // path from the SSR root down to the client descendant must ship its own
120
+ // bundle, since each one owns the `initChild` call reaching the next.
121
+ test('walks the transitive chain all the way up from a deeply nested client component', () => {
122
+ const rows = [
123
+ { absPath: '/proj/GreatGrand.tsx', isClient: false, exportedComponents: ['GreatGrand'], referencedComponents: ['Grandparent'] },
124
+ { absPath: '/proj/Grandparent.tsx', isClient: false, exportedComponents: ['Grandparent'], referencedComponents: ['Parent'] },
125
+ { absPath: '/proj/Parent.tsx', isClient: false, exportedComponents: ['Parent'], referencedComponents: ['Child'] },
126
+ { absPath: '/proj/Child.tsx', isClient: true, exportedComponents: ['Child'], referencedComponents: [] },
127
+ ]
128
+ const entries = computeClientEntryPaths(rows)
129
+ expect(entries.has('/proj/GreatGrand.tsx')).toBe(true)
130
+ expect(entries.has('/proj/Grandparent.tsx')).toBe(true)
131
+ expect(entries.has('/proj/Parent.tsx')).toBe(true)
132
+ expect(entries.has('/proj/Child.tsx')).toBe(true)
133
+ })
134
+
135
+ // The anti-regression: an all-server tree with zero client descendants
136
+ // anywhere must produce ZERO entries. A `analyzeClientNeeds(ir).needsInit`
137
+ // predicate would get this wrong (it's true for StaticParent purely
138
+ // because it references ServerChild at all) — this is exactly why the
139
+ // closure is seeded from `isClient`, not from the compiler's per-file
140
+ // "needs init" signal.
141
+ test('an all-server tree with no client descendants produces no entries at all', () => {
142
+ const rows = [
143
+ { absPath: '/proj/StaticParent.tsx', isClient: false, exportedComponents: ['StaticParent'], referencedComponents: ['ServerChild'] },
144
+ { absPath: '/proj/ServerChild.tsx', isClient: false, exportedComponents: ['ServerChild'], referencedComponents: [] },
145
+ ]
146
+ const entries = computeClientEntryPaths(rows)
147
+ expect(entries.size).toBe(0)
148
+ })
149
+
150
+ test('a server leaf with no component references is never an entry', () => {
151
+ const rows = [
152
+ { absPath: '/proj/Leaf.tsx', isClient: false, exportedComponents: ['Leaf'], referencedComponents: [] },
153
+ ]
154
+ expect(computeClientEntryPaths(rows).size).toBe(0)
155
+ })
156
+
157
+ // Cycle-safety: a mutual server↔server reference must terminate and stay
158
+ // empty; adding a client component into the cycle must pull in every
159
+ // member reachable from it, not just the one that references it directly.
160
+ test('is cycle-safe and still finds every member of a cycle once one member is a client entry', () => {
161
+ const allServer = [
162
+ { absPath: '/proj/A.tsx', isClient: false, exportedComponents: ['A'], referencedComponents: ['B'] },
163
+ { absPath: '/proj/B.tsx', isClient: false, exportedComponents: ['B'], referencedComponents: ['A'] },
164
+ ]
165
+ expect(computeClientEntryPaths(allServer).size).toBe(0)
166
+
167
+ const withClientMember = [
168
+ { absPath: '/proj/A.tsx', isClient: false, exportedComponents: ['A'], referencedComponents: ['B', 'C'] },
169
+ { absPath: '/proj/B.tsx', isClient: false, exportedComponents: ['B'], referencedComponents: ['A'] },
170
+ { absPath: '/proj/C.tsx', isClient: true, exportedComponents: ['C'], referencedComponents: [] },
171
+ ]
172
+ const entries = computeClientEntryPaths(withClientMember)
173
+ expect(entries.has('/proj/A.tsx')).toBe(true)
174
+ expect(entries.has('/proj/B.tsx')).toBe(true)
175
+ expect(entries.has('/proj/C.tsx')).toBe(true)
176
+ })
177
+
178
+ test('resolves a reference through a multi-export file, same as buildChildNameIndex', () => {
179
+ const rows = [
180
+ { absPath: '/proj/Parent.tsx', isClient: false, exportedComponents: ['Parent'], referencedComponents: ['CopyIcon'] },
181
+ { absPath: '/proj/icon/index.tsx', isClient: true, exportedComponents: ['CopyIcon', 'CheckIcon'], referencedComponents: [] },
182
+ ]
183
+ const entries = computeClientEntryPaths(rows)
184
+ expect(entries.has('/proj/Parent.tsx')).toBe(true)
185
+ expect(entries.has('/proj/icon/index.tsx')).toBe(true)
186
+ })
187
+
188
+ test('an unresolved tag name (e.g. a third-party import) is ignored, not a crash', () => {
189
+ const rows = [
190
+ { absPath: '/proj/Parent.tsx', isClient: false, exportedComponents: ['Parent'], referencedComponents: ['SomeExternalLib'] },
191
+ ]
192
+ expect(() => computeClientEntryPaths(rows)).not.toThrow()
193
+ expect(computeClientEntryPaths(rows).size).toBe(0)
194
+ })
78
195
  })
79
196
 
80
197
  describe('buildChildNameIndex', () => {
81
198
  test('keys \'use client\' files by their exported component names', () => {
82
199
  const index = buildChildNameIndex([
83
- { absPath: '/proj/components/TodoItem.tsx', isClient: true, exportedComponents: ['TodoItem'] },
84
- { absPath: '/proj/blog/LikeButton.tsx', isClient: true, exportedComponents: ['LikeButton'] },
200
+ { absPath: '/proj/components/TodoItem.tsx', needsClientEntry: true, exportedComponents: ['TodoItem'] },
201
+ { absPath: '/proj/blog/LikeButton.tsx', needsClientEntry: true, exportedComponents: ['LikeButton'] },
85
202
  ])
86
203
  expect(index.get('TodoItem')).toBe('/proj/components/TodoItem.tsx')
87
204
  expect(index.get('LikeButton')).toBe('/proj/blog/LikeButton.tsx')
@@ -95,7 +212,7 @@ describe('buildChildNameIndex', () => {
95
212
  const index = buildChildNameIndex([
96
213
  {
97
214
  absPath: '/proj/components/icon/index.tsx',
98
- isClient: true,
215
+ needsClientEntry: true,
99
216
  exportedComponents: ['CopyIcon', 'CheckIcon'],
100
217
  },
101
218
  ])
@@ -109,8 +226,8 @@ describe('buildChildNameIndex', () => {
109
226
  // single-export `ui/button/index.tsx` was unreachable as a marker target.
110
227
  test('single-export colocated index.tsx files resolve by name, and never collide on "index"', () => {
111
228
  const index = buildChildNameIndex([
112
- { absPath: '/proj/ui/button/index.tsx', isClient: true, exportedComponents: ['Button'] },
113
- { absPath: '/proj/ui/toggle/index.tsx', isClient: true, exportedComponents: ['Toggle'] },
229
+ { absPath: '/proj/ui/button/index.tsx', needsClientEntry: true, exportedComponents: ['Button'] },
230
+ { absPath: '/proj/ui/toggle/index.tsx', needsClientEntry: true, exportedComponents: ['Toggle'] },
114
231
  ])
115
232
  expect(index.get('Button')).toBe('/proj/ui/button/index.tsx')
116
233
  expect(index.get('Toggle')).toBe('/proj/ui/toggle/index.tsx')
@@ -119,29 +236,40 @@ describe('buildChildNameIndex', () => {
119
236
 
120
237
  test('first writer wins on a duplicate name, so an earlier components dir shadows a later one', () => {
121
238
  const index = buildChildNameIndex([
122
- { absPath: '/proj/a/Button.tsx', isClient: true, exportedComponents: ['Button'] },
123
- { absPath: '/proj/b/Button.tsx', isClient: true, exportedComponents: ['Button'] },
239
+ { absPath: '/proj/a/Button.tsx', needsClientEntry: true, exportedComponents: ['Button'] },
240
+ { absPath: '/proj/b/Button.tsx', needsClientEntry: true, exportedComponents: ['Button'] },
124
241
  ])
125
242
  expect(index.get('Button')).toBe('/proj/a/Button.tsx')
126
243
  })
127
244
 
128
245
  test('falls back to the basename when no exports were parsed, keeping the old convention working', () => {
129
246
  const index = buildChildNameIndex([
130
- { absPath: '/proj/components/Widget.tsx', isClient: true, exportedComponents: [] },
247
+ { absPath: '/proj/components/Widget.tsx', needsClientEntry: true, exportedComponents: [] },
131
248
  ])
132
249
  expect(index.get('Widget')).toBe('/proj/components/Widget.tsx')
133
250
  })
134
251
 
135
- test('excludes server-only files — a @bf-child marker only ever names an interactive component', () => {
252
+ test('excludes files that need no client entry — a @bf-child marker only ever names a component whose bundle ships', () => {
136
253
  const index = buildChildNameIndex([
137
- { absPath: '/proj/components/ServerOnly.tsx', isClient: false, exportedComponents: [] },
254
+ { absPath: '/proj/components/ServerOnly.tsx', needsClientEntry: false, exportedComponents: [] },
138
255
  ])
139
256
  expect(index.has('ServerOnly')).toBe(false)
140
257
  })
141
258
 
259
+ // #2767: `needsClientEntry`, not `isClient`, decides indexability — a
260
+ // plain server file that owns a 'use client' descendant is a legitimate
261
+ // `@bf-child:` marker target too, because IT is the file whose compiled
262
+ // init contains the `initChild(...)` call reaching that descendant.
263
+ test('indexes a server-only file whose subtree owns a client descendant (needsClientEntry: true)', () => {
264
+ const index = buildChildNameIndex([
265
+ { absPath: '/proj/components/ServerParent.tsx', needsClientEntry: true, exportedComponents: ['ServerParent'] },
266
+ ])
267
+ expect(index.get('ServerParent')).toBe('/proj/components/ServerParent.tsx')
268
+ })
269
+
142
270
  test('accepts a .ts extension too', () => {
143
271
  const index = buildChildNameIndex([
144
- { absPath: '/proj/components/Widget.ts', isClient: true, exportedComponents: ['Widget'] },
272
+ { absPath: '/proj/components/Widget.ts', needsClientEntry: true, exportedComponents: ['Widget'] },
145
273
  ])
146
274
  expect(index.get('Widget')).toBe('/proj/components/Widget.ts')
147
275
  })
@@ -65,10 +65,34 @@ describe('e2e: vite build', () => {
65
65
  expect(keys.some(k => k.endsWith('Counter.tsx'))).toBe(true)
66
66
  expect(keys.some(k => k.endsWith('SharedCounter.tsx'))).toBe(true)
67
67
  expect(keys.some(k => k.endsWith('counterState.tsx'))).toBe(true)
68
- // Greeting.tsx has no 'use client' directive never an entry.
68
+ // Greeting.tsx has no 'use client' directive AND no client descendant
69
+ // anywhere — the anti-regression guard: an all-server file must never
70
+ // become a spurious Rollup entry. See the `needsClientEntry` false case.
69
71
  expect(keys.some(k => k.endsWith('Greeting.tsx'))).toBe(false)
70
72
  })
71
73
 
74
+ // #2767: ServerParent/ServerGrandparent carry NO 'use client' directive at
75
+ // all — they're plain server components whose only reason to become
76
+ // entries is that they transitively render `Counter`. See
77
+ // `e2e-fixture/src/components/ServerParent.tsx` and
78
+ // `ServerGrandparent.tsx`.
79
+ test('a server component that renders a client descendant also becomes a Rollup entry, transitively', async () => {
80
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
81
+ const keys = Object.keys(manifest)
82
+ const serverParentKey = keys.find(k => k.endsWith('ServerParent.tsx'))
83
+ const serverGrandparentKey = keys.find(k => k.endsWith('ServerGrandparent.tsx'))
84
+ expect(serverParentKey).toBeDefined()
85
+ expect(serverGrandparentKey).toBeDefined()
86
+
87
+ const counterKey = keys.find(k => k.endsWith('Counter.tsx') && !k.includes('Shared'))!
88
+
89
+ // Proof `@bf-child:` resolved through the fixed `buildChildNameIndex`
90
+ // to the REAL entry-to-entry import, not the no-op module — mirrors the
91
+ // LoopParent→LoopChild assertion below, one level deeper.
92
+ expect(manifest[serverParentKey!].imports).toContain(counterKey)
93
+ expect(manifest[serverGrandparentKey!].imports).toContain(serverParentKey)
94
+ })
95
+
72
96
  test('the runtime collapses into one shared chunk imported by every client entry', async () => {
73
97
  const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
74
98
  const counterKey = Object.keys(manifest).find(k => k.endsWith('Counter.tsx') && !k.includes('Shared'))!
@@ -147,6 +171,26 @@ describe('e2e: vite build', () => {
147
171
  expect(parentContent).not.toContain('bf-child')
148
172
  })
149
173
 
174
+ // #2767: each server component on the path to Counter owns the
175
+ // `initChild(...)` call reaching the NEXT link in the chain, so each one
176
+ // needs its OWN `Scripts.Register` — not just Counter's.
177
+ test('server components that transitively render a client descendant get their own script registration', async () => {
178
+ const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
179
+ const serverParentKey = Object.keys(manifest).find(k => k.endsWith('ServerParent.tsx'))!
180
+ const serverGrandparentKey = Object.keys(manifest).find(k => k.endsWith('ServerGrandparent.tsx'))!
181
+
182
+ const serverParentTemplate = await readFile(resolve(templatesDir, 'ServerParent.tmpl'), 'utf8')
183
+ const serverGrandparentTemplate = await readFile(resolve(templatesDir, 'ServerGrandparent.tmpl'), 'utf8')
184
+
185
+ expect(serverParentTemplate).toContain(`{{.Scripts.Register "/static/build/${manifest[serverParentKey].file}"}}`)
186
+ expect(serverGrandparentTemplate).toContain(
187
+ `{{.Scripts.Register "/static/build/${manifest[serverGrandparentKey].file}"}}`,
188
+ )
189
+ })
190
+
191
+ // Anti-regression guard, unchanged by #2767: a component with genuinely
192
+ // no client descendant anywhere must stay OUT of the Rollup graph and
193
+ // carry no script registration at all.
150
194
  test('emits a template for the server-only component (never in the Rollup graph) with NO script registration', async () => {
151
195
  const manifest = JSON.parse(await readFile(resolve(outDir, '.vite/manifest.json'), 'utf8'))
152
196
  expect(Object.keys(manifest).some(k => k.endsWith('Greeting.tsx'))).toBe(false)
@@ -135,6 +135,7 @@ describe('e2e: vite dev server', () => {
135
135
  // for its output to actually land on disk before asserting on it.
136
136
  await waitFor(async () => (await readIfExists(join(templatesDir, 'Counter.tmpl'))) !== null)
137
137
  await waitFor(async () => (await readIfExists(join(templatesDir, 'Greeting.tmpl'))) !== null)
138
+ await waitFor(async () => (await readIfExists(join(templatesDir, 'ServerParent.tmpl'))) !== null)
138
139
  }, 30_000)
139
140
 
140
141
  afterAll(async () => {
@@ -176,6 +177,25 @@ describe('e2e: vite dev server', () => {
176
177
  expect(template).toContain('Hello')
177
178
  })
178
179
 
180
+ // #2767: ServerParent has no 'use client' directive but renders Counter —
181
+ // it must get its OWN dev-origin script registration (the `@vite/client`
182
+ // entry plus its own `/@fs/…` module URL), mirroring the client-component
183
+ // assertion above, and that module URL must actually serve compiled JS
184
+ // whose init calls `initChild('Counter', ...)`.
185
+ test('a server component that renders a client descendant also gets dev-origin script registration', async () => {
186
+ const serverParentPath = join(COMPONENTS_DIR, 'ServerParent.tsx')
187
+ const requestPath = devRequestPath({ root: APP_ROOT }, serverParentPath)
188
+ const template = await readFile(join(templatesDir, 'ServerParent.tmpl'), 'utf8')
189
+
190
+ expect(template).toContain(`{{.Scripts.Register "${baseUrl}/@vite/client"}}`)
191
+ expect(template).toContain(`{{.Scripts.Register "${baseUrl}/${requestPath}"}}`)
192
+
193
+ const res = await fetch(`${baseUrl}/${requestPath}`)
194
+ expect(res.status).toBe(200)
195
+ const body = await res.text()
196
+ expect(body).toContain('initChild("Counter"')
197
+ })
198
+
179
199
  test('the templates dir carries the dev-artifact marker while the dev server is running', async () => {
180
200
  const marker = await readIfExists(join(templatesDir, '.barefootjs-dev-build'))
181
201
  expect(marker).not.toBeNull()
@@ -64,10 +64,20 @@ describe('config hook', () => {
64
64
  if (dir) await rm(dir, { recursive: true, force: true })
65
65
  })
66
66
 
67
- test('sets appType custom, forces build.manifest, and keys rollupOptions.input by ONLY "use client" files', async () => {
67
+ test('sets appType custom, forces build.manifest, and keys rollupOptions.input by "use client" files AND the server components that own them (#2767)', async () => {
68
68
  dir = await mkdtemp(join(tmpdir(), 'barefoot-plugin-config-'))
69
69
  await mkdir(join(dir, 'src/components'), { recursive: true })
70
70
  await writeFile(join(dir, 'src/components/Counter.tsx'), '\'use client\'\nexport function Counter() { return <div/> }')
71
+ // Server component (no 'use client') that renders the client Counter —
72
+ // it must ALSO become an entry, since its own compiled init is the only
73
+ // place the `initChild('Counter', ...)` call reaching Counter lives.
74
+ await writeFile(
75
+ join(dir, 'src/components/Parent.tsx'),
76
+ "import { Counter } from './Counter'\nexport function Parent() { return <div><Counter/></div> }",
77
+ )
78
+ // Plain server leaf with no client descendant anywhere — must stay OUT
79
+ // of the entry list. This is the anti-regression: an all-server tree
80
+ // must not start shipping spurious empty bundles.
71
81
  await writeFile(join(dir, 'src/components/Greeting.tsx'), 'export function Greeting() { return <div/> }')
72
82
 
73
83
  const plugin = makePlugin('src/components', 'internal/views')
@@ -76,7 +86,10 @@ describe('config hook', () => {
76
86
  expect(result.appType).toBe('custom')
77
87
  expect(result.build.manifest).toBe(true)
78
88
  const inputPaths = Object.values(result.build.rollupOptions.input) as string[]
79
- expect(inputPaths).toEqual([resolve(dir, 'src/components/Counter.tsx')])
89
+ expect(inputPaths.sort()).toEqual(
90
+ [resolve(dir, 'src/components/Counter.tsx'), resolve(dir, 'src/components/Parent.tsx')].sort(),
91
+ )
92
+ expect(inputPaths).not.toContain(resolve(dir, 'src/components/Greeting.tsx'))
80
93
  })
81
94
 
82
95
  test('produces no entries when nothing under components has "use client"', async () => {
@@ -258,6 +271,13 @@ describe('writeBundle: manifest → scriptAssets resolution', () => {
258
271
  join(dir, 'src/components/Counter.tsx'),
259
272
  '\'use client\'\nimport { createSignal } from \'@barefootjs/client\'\nexport function Counter() {\n const [count, setCount] = createSignal(0)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n',
260
273
  )
274
+ // #2767: a plain server component that merely RENDERS Counter must get
275
+ // its own script registration too — it owns the `initChild('Counter', ...)`
276
+ // call, not Counter itself.
277
+ await writeFile(
278
+ join(dir, 'src/components/Parent.tsx'),
279
+ "import { Counter } from './Counter'\nexport function Parent() { return <div><Counter/></div> }",
280
+ )
261
281
  await writeFile(
262
282
  join(dir, 'src/components/Greeting.tsx'),
263
283
  'export function Greeting() { return <p>Hi</p> }',
@@ -288,17 +308,22 @@ describe('writeBundle: manifest → scriptAssets resolution', () => {
288
308
  join(dir, 'dist/.vite/manifest.json'),
289
309
  JSON.stringify({
290
310
  'src/components/Counter.tsx': { file: 'assets/Counter-abc123.js', isEntry: true },
311
+ 'src/components/Parent.tsx': { file: 'assets/Parent-def456.js', isEntry: true },
291
312
  }),
292
313
  )
293
314
 
294
315
  await plugin.writeBundle()
295
316
 
296
317
  const counterTpl = await readFile(join(templatesDir, `Counter${adapter.extension}`), 'utf8')
318
+ const parentTpl = await readFile(join(templatesDir, `Parent${adapter.extension}`), 'utf8')
297
319
  const greetingTpl = await readFile(join(templatesDir, `Greeting${adapter.extension}`), 'utf8')
298
320
 
299
321
  expect(counterTpl).toContain('{{.Scripts.Register "/static/build/assets/Counter-abc123.js"}}')
300
- // Server-only: never in the manifest scriptAssets resolves to []
301
- // no script registration text at all.
322
+ // The server parent needed a bundle too (#2767) and gets its own
323
+ // registration, from its OWN manifest entry — not Counter's.
324
+ expect(parentTpl).toContain('{{.Scripts.Register "/static/build/assets/Parent-def456.js"}}')
325
+ // Genuinely server-only, no client descendant: never in the manifest →
326
+ // scriptAssets resolves to [] → no script registration text at all.
302
327
  expect(greetingTpl).not.toContain('Scripts.Register')
303
328
  expect(greetingTpl).toContain('Hi')
304
329
  })
package/src/discover.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { readdir } from 'node:fs/promises'
13
13
  import { basename, resolve } from 'node:path'
14
- import { listExportedComponents } from '@barefootjs/jsx'
14
+ import { scanComponentFile } from '@barefootjs/jsx'
15
15
 
16
16
  /** Does `content` start with a `'use client'` / `"use client"` directive
17
17
  * (after skipping leading block/line comments)? */
@@ -95,14 +95,40 @@ export interface DiscoveredComponent {
95
95
  content: string
96
96
  /** Whether the file's content starts with a `'use client'` directive. */
97
97
  isClient: boolean
98
+ /**
99
+ * `isClient`, OR this file transitively instantiates a component that
100
+ * needs one — the property `computeClientEntryPaths` computes over the
101
+ * whole discovered corpus. This, not `isClient`, is the signal for
102
+ * "does this file need its own client bundle and a `<script>` on the
103
+ * page": a plain server component that merely renders a `'use client'`
104
+ * descendant still needs an `initChild(...)` call to run in the
105
+ * browser, and that call lives in ITS OWN compiled init, not the
106
+ * child's (issue #2767 — `hydrateElementScope` in
107
+ * `@barefootjs/client`'s `runtime/hydrate.ts` explicitly skips any
108
+ * element carrying the child marker, deferring to the parent's
109
+ * `initChild`; if the parent's own bundle never ships, that call never
110
+ * happens and the child never hydrates, silently).
111
+ */
112
+ needsClientEntry: boolean
98
113
  /**
99
114
  * Every component this file exports, from `@barefootjs/jsx`'s TS-AST
100
115
  * walk (`listExportedComponents`) — never a regex, and never the
101
116
  * basename standing in for the name. A file exporting more than one
102
117
  * component (`icon/index.tsx` → `CopyIcon` + `CheckIcon`) is why this
103
- * exists; see `buildChildNameIndex`.
118
+ * exists; see `buildChildNameIndex`. Populated for EVERY file, not just
119
+ * client ones — `computeClientEntryPaths` resolves JSX tag references
120
+ * by name across the whole corpus, so a server file must be indexable
121
+ * too (it may itself be someone else's `referencedComponents` target).
104
122
  */
105
123
  exportedComponents: string[]
124
+ /**
125
+ * PascalCase JSX tag identifiers this file's JSX instantiates (its
126
+ * component-instantiation out-edges), from `@barefootjs/jsx`'s
127
+ * `scanComponentFile`. Feeds `computeClientEntryPaths` — never used for
128
+ * anything import-resolution-shaped, so an unresolved or aliased tag
129
+ * name is simply not an edge (see that function's docstring).
130
+ */
131
+ referencedComponents: string[]
106
132
  /**
107
133
  * `CompileOptions.cssLayerPrefix` this file should compile with, carried
108
134
  * over unchanged from whichever `components` entry's `dir` this file was
@@ -151,7 +177,7 @@ export async function discoverComponents(
151
177
  readFile: (absPath: string) => Promise<string>,
152
178
  ): Promise<DiscoveredComponent[]> {
153
179
  const seen = new Set<string>()
154
- const out: DiscoveredComponent[] = []
180
+ const out: Omit<DiscoveredComponent, 'needsClientEntry'>[] = []
155
181
  for (const raw of entries) {
156
182
  const entry: ResolvedComponentDirEntry = typeof raw === 'string' ? { dir: raw } : raw
157
183
  for (const absPath of await discoverComponentFiles(entry.dir, { skipDirs: entry.skipDirs })) {
@@ -159,19 +185,132 @@ export async function discoverComponents(
159
185
  seen.add(absPath)
160
186
  const content = await readFile(absPath)
161
187
  const isClient = hasUseClientDirective(content)
162
- // Only client files can be `@bf-child:` targets, so only they need
163
- // their export list parsed this is a `ts.createSourceFile` per
164
- // file and server-only components are the majority in most trees.
188
+ // One parse gets both the export list AND the JSX tags this file
189
+ // references every file needs both now, not just client ones:
190
+ // `computeClientEntryPaths` walks the instantiation graph across
191
+ // the whole corpus, so a server file must carry its out-edges (and
192
+ // be indexable by name) too.
193
+ const scan = scanComponentFile(content, absPath)
165
194
  out.push({
166
195
  absPath,
167
196
  content,
168
197
  isClient,
169
- exportedComponents: isClient ? listExportedComponents(content, absPath) : [],
198
+ exportedComponents: scan.exports,
199
+ referencedComponents: scan.referencedComponents,
170
200
  cssLayerPrefix: entry.cssLayerPrefix,
171
201
  })
172
202
  }
173
203
  }
174
- return out
204
+ const clientEntryPaths = computeClientEntryPaths(out)
205
+ return out.map(row => ({ ...row, needsClientEntry: clientEntryPaths.has(row.absPath) }))
206
+ }
207
+
208
+ /**
209
+ * The component-name → absolute-path index shared by `computeClientEntryPaths`
210
+ * (resolving JSX-tag out-edges to the file that exports them) and
211
+ * `buildChildNameIndex` (resolving `@bf-child:<Name>` markers) — both need
212
+ * the identical "exported names, falling back to the basename when the AST
213
+ * walk found none; first writer wins on a duplicate name" rule, so it lives
214
+ * in exactly one place. `include` lets each caller restrict which rows are
215
+ * indexable: `computeClientEntryPaths` indexes every row (a server file can
216
+ * be another file's out-edge target), `buildChildNameIndex` only rows that
217
+ * ended up needing a client entry (a marker can only ever resolve to a file
218
+ * that actually ships a bundle).
219
+ */
220
+ function nameIndexOver<T extends Pick<DiscoveredComponent, 'absPath' | 'exportedComponents'>>(
221
+ rows: readonly T[],
222
+ include: (row: T) => boolean,
223
+ ): Map<string, string> {
224
+ const index = new Map<string, string>()
225
+ for (const c of rows) {
226
+ if (!include(c)) continue
227
+ const names = c.exportedComponents.length > 0
228
+ ? c.exportedComponents
229
+ : [basename(c.absPath).replace(/\.tsx?$/, '')]
230
+ for (const name of names) {
231
+ if (!index.has(name)) index.set(name, c.absPath)
232
+ }
233
+ }
234
+ return index
235
+ }
236
+
237
+ /**
238
+ * Which discovered files need their OWN client bundle and `<script>` tag on
239
+ * the page: every `'use client'` file (the seed set), plus every file that
240
+ * transitively instantiates one — a plain server component nested between
241
+ * the SSR root and a `'use client'` descendant still needs its own compiled
242
+ * `init` to run in the browser, because that's the ONLY place the
243
+ * `initChild(...)` call reaching the client descendant is emitted (issue
244
+ * #2767; see `DiscoveredComponent.needsClientEntry`'s docstring). A nested
245
+ * component can't self-hydrate to make up for a missing parent bundle: its
246
+ * SSR root carries the child marker (`bf-h`), which `hydrateElementScope`
247
+ * (`@barefootjs/client`'s `runtime/hydrate.ts`) unconditionally skips,
248
+ * deferring to a parent's `initChild` call that only exists if the parent's
249
+ * own bundle shipped.
250
+ *
251
+ * Deliberately NOT `analyzeClientNeeds(ir).needsInit` (the compiler's
252
+ * per-file "does this file's compiled init do anything nontrivial" signal)
253
+ * — that's true for almost any server component with dynamic content at
254
+ * all (a prop interpolation, a conditional, a `.map()`, a plain
255
+ * `onClick`...), not just ones that own a client descendant. Gating Vite
256
+ * entries on it would bundle huge swaths of purely-server trees that have
257
+ * nothing to hydrate. The property this function computes — "is there a
258
+ * `'use client'` file reachable via component-instantiation edges" — is
259
+ * inherently cross-file, so it can only be answered here, with the whole
260
+ * discovered corpus in hand, not by any single-file compiler analysis.
261
+ *
262
+ * Pure structural closure over JSX tag references — no compile. Resolves
263
+ * each file's `referencedComponents` (JSX tag names) to the file that
264
+ * exports that name via the shared `nameIndexOver` index, then walks the
265
+ * REVERSE edges breadth-first from the `isClient` seed set. Cycle-safe (a
266
+ * visited-set) and runs in O(files + edges); on this repo's real
267
+ * `ui`/`site` component corpus (~260 files) the whole discovery pass
268
+ * (parse + this closure) costs low-single-digit milliseconds.
269
+ *
270
+ * The closure only ever needs to walk upward from a `'use client'` seed:
271
+ * a client file cannot legally import a server component in the first
272
+ * place (`analyzer.ts`'s `validateClientImports` raises BF003, a hard
273
+ * compile error), so there is no "server child of a client parent needing
274
+ * its own entry" shape to account for.
275
+ */
276
+ export function computeClientEntryPaths(
277
+ rows: readonly Pick<DiscoveredComponent, 'absPath' | 'isClient' | 'exportedComponents' | 'referencedComponents'>[],
278
+ ): Set<string> {
279
+ const nameToPath = nameIndexOver(rows, () => true)
280
+
281
+ // Reverse edges: referencers.get(g) = every file that references a name
282
+ // resolving to g.
283
+ const referencers = new Map<string, Set<string>>()
284
+ for (const row of rows) {
285
+ for (const name of row.referencedComponents) {
286
+ const target = nameToPath.get(name)
287
+ if (!target || target === row.absPath) continue
288
+ let set = referencers.get(target)
289
+ if (!set) {
290
+ set = new Set()
291
+ referencers.set(target, set)
292
+ }
293
+ set.add(row.absPath)
294
+ }
295
+ }
296
+
297
+ const visited = new Set<string>()
298
+ const queue: string[] = []
299
+ for (const row of rows) {
300
+ if (row.isClient && !visited.has(row.absPath)) {
301
+ visited.add(row.absPath)
302
+ queue.push(row.absPath)
303
+ }
304
+ }
305
+ while (queue.length > 0) {
306
+ const current = queue.shift() as string
307
+ for (const referencer of referencers.get(current) ?? []) {
308
+ if (visited.has(referencer)) continue
309
+ visited.add(referencer)
310
+ queue.push(referencer)
311
+ }
312
+ }
313
+ return visited
175
314
  }
176
315
 
177
316
  /**
@@ -182,10 +321,16 @@ export async function discoverComponents(
182
321
  * `resolveId` needs a name→file lookup built from a full discovery pass.
183
322
  *
184
323
  * Keyed by each exported component NAME, which is what the marker
185
- * carries. Server-only files are excluded: a `@bf-child:` marker only
186
- * ever names another component this one instantiates at runtime
187
- * (`initChild`/`createComponent`), which requires an `init` function only
188
- * a `'use client'` file has.
324
+ * carries. Files that don't need their own client entry are excluded: a
325
+ * `@bf-child:` marker only ever names another component this one
326
+ * instantiates at runtime (`initChild`/`createComponent`), which requires
327
+ * a REAL `init` — and a file with `needsClientEntry: false` compiles to a
328
+ * no-op template-only mount (`generateTemplateOnlyMount` in
329
+ * `@barefootjs/jsx`'s `ir-to-client-js`), nothing to jump to. This is
330
+ * `needsClientEntry`, not `isClient` — a plain server file that owns a
331
+ * `'use client'` descendant is a legitimate marker target too (issue
332
+ * #2767: it's the file whose compiled init actually contains the
333
+ * `initChild(...)` call reaching that descendant).
189
334
  *
190
335
  * This used to key on the file's basename, which worked only because the
191
336
  * one-component-per-file convention makes the two coincide
@@ -211,20 +356,7 @@ export function buildChildNameIndex(
211
356
  // Only the fields the index actually reads — callers with a full
212
357
  // `DiscoveredComponent[]` pass it as-is, and tests can construct rows
213
358
  // without dragging in `content`.
214
- discovered: readonly Pick<DiscoveredComponent, 'absPath' | 'isClient' | 'exportedComponents'>[],
359
+ discovered: readonly Pick<DiscoveredComponent, 'absPath' | 'needsClientEntry' | 'exportedComponents'>[],
215
360
  ): Map<string, string> {
216
- const index = new Map<string, string>()
217
- for (const c of discovered) {
218
- if (!c.isClient) continue
219
- // Fall back to the basename when the AST walk found no exports: a
220
- // file can still be a marker target through the old convention, and
221
- // losing that would be a regression rather than a fix.
222
- const names = c.exportedComponents.length > 0
223
- ? c.exportedComponents
224
- : [basename(c.absPath).replace(/\.tsx?$/, '')]
225
- for (const name of names) {
226
- if (!index.has(name)) index.set(name, c.absPath)
227
- }
228
- }
229
- return index
361
+ return nameIndexOver(discovered, c => c.needsClientEntry)
230
362
  }