@barefootjs/vite 0.33.2 → 0.33.3
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/dist/discover.d.ts +78 -6
- package/dist/discover.d.ts.map +1 -1
- package/dist/index.js +742 -384
- package/package.json +6 -6
- package/src/__tests__/discover.test.ts +140 -12
- package/src/__tests__/e2e-vite-build.test.ts +45 -1
- package/src/__tests__/e2e-vite-dev.test.ts +20 -0
- package/src/__tests__/plugin.test.ts +29 -4
- package/src/discover.ts +159 -27
- package/src/plugin.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/vite",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.3",
|
|
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.
|
|
41
|
+
"@barefootjs/shared": "0.33.3"
|
|
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.
|
|
49
|
-
"@barefootjs/go-template": "0.33.
|
|
50
|
-
"@barefootjs/hono": "0.33.
|
|
51
|
-
"@barefootjs/jsx": "0.33.
|
|
48
|
+
"@barefootjs/client": "0.33.3",
|
|
49
|
+
"@barefootjs/go-template": "0.33.3",
|
|
50
|
+
"@barefootjs/hono": "0.33.3",
|
|
51
|
+
"@barefootjs/jsx": "0.33.3",
|
|
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 {
|
|
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',
|
|
84
|
-
{ absPath: '/proj/blog/LikeButton.tsx',
|
|
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
|
-
|
|
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',
|
|
113
|
-
{ absPath: '/proj/ui/toggle/index.tsx',
|
|
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',
|
|
123
|
-
{ absPath: '/proj/b/Button.tsx',
|
|
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',
|
|
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
|
|
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',
|
|
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',
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
301
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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:
|
|
198
|
+
exportedComponents: scan.exports,
|
|
199
|
+
referencedComponents: scan.referencedComponents,
|
|
170
200
|
cssLayerPrefix: entry.cssLayerPrefix,
|
|
171
201
|
})
|
|
172
202
|
}
|
|
173
203
|
}
|
|
174
|
-
|
|
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.
|
|
186
|
-
* ever names another component this one
|
|
187
|
-
* (`initChild`/`createComponent`), which requires
|
|
188
|
-
* a
|
|
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' | '
|
|
359
|
+
discovered: readonly Pick<DiscoveredComponent, 'absPath' | 'needsClientEntry' | 'exportedComponents'>[],
|
|
215
360
|
): Map<string, string> {
|
|
216
|
-
|
|
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
|
}
|