@wular/pnext 0.0.7 → 0.0.9
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 +1 -1
- package/src/cli/analyze.ts +103 -48
- package/src/cli/dev.ts +5 -1
- package/src/client/build.ts +94 -119
- package/src/client/entry.ts +87 -66
- package/src/compat/next/font/runtime.ts +3 -6
- package/src/dev/server.ts +27 -54
- package/src/render/renderer.ts +61 -16
- package/src/render/slots.tsx +6 -12
- package/src/render/static-slots-revive.ts +65 -0
- package/src/render/static-slots.ts +3 -54
- package/src/utils/serialize.ts +59 -2
package/package.json
CHANGED
package/src/cli/analyze.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { listFiles } from '../utils/fs'
|
|
|
9
9
|
import type { BuildManifest, RouteManifestEntry } from '../types'
|
|
10
10
|
|
|
11
11
|
export interface AnalyzeResult {
|
|
12
|
-
mode: 'production' | 'development'
|
|
13
12
|
root: string
|
|
14
13
|
compression: AnalyzeCompression
|
|
15
14
|
files: AnalyzeFile[]
|
|
@@ -43,6 +42,10 @@ export interface AnalyzeBundleFile {
|
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
export interface AnalyzeDynamicBundleFile extends AnalyzeBundleFile {
|
|
45
|
+
/** Island's client-reference id, as emitted in the entry's island table. */
|
|
46
|
+
id: string
|
|
47
|
+
/** Display name: the reference's named export, or its module basename. */
|
|
48
|
+
component: string
|
|
46
49
|
exportName: string
|
|
47
50
|
load: 'render' | 'visible'
|
|
48
51
|
}
|
|
@@ -63,11 +66,10 @@ export async function analyzeProject(
|
|
|
63
66
|
throw new Error(`No PNext output found at ${config.outPath}. Run pnext build first.`)
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// Dev output is unminified, so its sizes say nothing about what ships; analyze is production-only.
|
|
66
70
|
const target = analyzeTarget(config.outPath)
|
|
67
71
|
if (!target) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`No PNext build or dev output found at ${config.outPath}. Run pnext build or pnext dev first.`,
|
|
70
|
-
)
|
|
72
|
+
throw new Error(`No production build found at ${config.outPath}. Run pnext build first.`)
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const compression = options.compression ?? 'gzip'
|
|
@@ -83,13 +85,9 @@ export async function analyzeProject(
|
|
|
83
85
|
}
|
|
84
86
|
}),
|
|
85
87
|
)
|
|
86
|
-
const routeBundles =
|
|
87
|
-
target.mode === 'production'
|
|
88
|
-
? await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
|
|
89
|
-
: []
|
|
88
|
+
const routeBundles = await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
|
|
90
89
|
|
|
91
90
|
return {
|
|
92
|
-
mode: target.mode,
|
|
93
91
|
root: path.relative(config.root, target.root),
|
|
94
92
|
compression,
|
|
95
93
|
files: rows,
|
|
@@ -115,7 +113,9 @@ async function analyzeRouteBundles(
|
|
|
115
113
|
|
|
116
114
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
|
|
117
115
|
const pages = manifest.routes.filter(route => route.kind === 'page')
|
|
118
|
-
const selected = routeFilter
|
|
116
|
+
const selected: AnalyzeRouteSelection[] = routeFilter
|
|
117
|
+
? filterAnalyzeRoutes(pages, routeFilter)
|
|
118
|
+
: pages.map(route => ({ route }))
|
|
119
119
|
if (routeFilter && selected.length === 0) {
|
|
120
120
|
throw new Error(
|
|
121
121
|
`No route matches ${routeFilter}. Routes:\n ${pages
|
|
@@ -135,22 +135,29 @@ async function analyzeRouteBundles(
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
const bundles: AnalyzeRouteBundle[] = []
|
|
138
|
-
for (const route of selected) {
|
|
139
|
-
bundles.push(await analyzeRouteBundle(route, sizes, js))
|
|
138
|
+
for (const { route, pathname } of selected) {
|
|
139
|
+
bundles.push(await analyzeRouteBundle(route, sizes, js, pathname))
|
|
140
140
|
}
|
|
141
141
|
return bundles
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
interface AnalyzeRouteSelection {
|
|
145
|
+
route: RouteManifestEntry
|
|
146
|
+
/** Concrete pathname the filter matched, for a param route's prerendered HTML. */
|
|
147
|
+
pathname?: string
|
|
148
|
+
}
|
|
149
|
+
|
|
144
150
|
// Accepts the route in ':id' or '[id]' template form, or a concrete pathname
|
|
145
|
-
// (e.g. /users/ada) matched through the route patterns.
|
|
146
|
-
|
|
151
|
+
// (e.g. /users/ada) matched through the route patterns. A concrete pathname is
|
|
152
|
+
// carried through so the bundle reports that page's prerendered HTML.
|
|
153
|
+
function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string): AnalyzeRouteSelection[] {
|
|
147
154
|
const normalized = filter === '/' ? filter : filter.replace(/\/+$/, '')
|
|
148
155
|
const direct = pages.filter(
|
|
149
156
|
route => route.route === normalized || publicRoutePath(route.route) === normalized,
|
|
150
157
|
)
|
|
151
|
-
if (direct.length > 0) return direct
|
|
158
|
+
if (direct.length > 0) return direct.map(route => ({ route }))
|
|
152
159
|
const matched = matchRoute(pages, normalized)
|
|
153
|
-
return matched ? [matched.route] : []
|
|
160
|
+
return matched ? [{ route: matched.route, pathname: normalized }] : []
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
function publicRoutePath(route: string) {
|
|
@@ -161,14 +168,30 @@ async function analyzeRouteBundle(
|
|
|
161
168
|
route: RouteManifestEntry,
|
|
162
169
|
sizes: Map<string, AnalyzeFile>,
|
|
163
170
|
js: (path: string) => Promise<string>,
|
|
171
|
+
pathname?: string,
|
|
164
172
|
): Promise<AnalyzeRouteBundle> {
|
|
165
173
|
const initial = new Set<string>()
|
|
166
|
-
addIfExists(initial, sizes, routeHtmlPath(route))
|
|
174
|
+
addIfExists(initial, sizes, routeHtmlPath(route, pathname))
|
|
167
175
|
addIfExists(initial, sizes, 'assets/global.css')
|
|
168
|
-
|
|
176
|
+
// Mirrors the renderer's stylesheet list: compat cssChunking splits a route's
|
|
177
|
+
// CSS into `<id>-<n>.css` and records them in cssAssets; `<id>.css` is emitted
|
|
178
|
+
// only when it is unset.
|
|
179
|
+
for (const asset of route.cssAssets ?? (route.cssImports.length ? [`${route.id}.css`] : []))
|
|
180
|
+
addIfExists(initial, sizes, `assets/${asset}`)
|
|
181
|
+
// Every compat page loads this with a blocking <script>. The sibling
|
|
182
|
+
// `_ssgManifest.js` (router-fetched, never in the document) and the
|
|
183
|
+
// `polyfills-*.js` chunk (noModule: legacy browsers only) are deliberately
|
|
184
|
+
// not initial weight for a modern client.
|
|
185
|
+
addIfExists(initial, sizes, '_next/static/pnext/_buildManifest.js')
|
|
169
186
|
|
|
170
187
|
if (route.clientEntry) {
|
|
171
188
|
addIfExists(initial, sizes, route.clientEntry)
|
|
189
|
+
// The build records the entry's chunk closure from the esbuild metafile and
|
|
190
|
+
// the renderer modulepreloads exactly this set. Chunk folding hoists imports
|
|
191
|
+
// out of the entry module, so walking its source alone under-reports; the
|
|
192
|
+
// walk stays as a union for dev-style single-bundle entries, which never
|
|
193
|
+
// populate clientEntryImports.
|
|
194
|
+
for (const asset of route.clientEntryImports ?? []) addIfExists(initial, sizes, asset)
|
|
172
195
|
for (const dependency of await staticDependencies(route.clientEntry, sizes, js))
|
|
173
196
|
initial.add(dependency)
|
|
174
197
|
}
|
|
@@ -180,17 +203,21 @@ async function analyzeRouteBundle(
|
|
|
180
203
|
|
|
181
204
|
if (route.clientEntry && sizes.has(toPosix(route.clientEntry))) {
|
|
182
205
|
const entrySource = await js(route.clientEntry)
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
for (const island of islandImports(entrySource, route.clientEntry)) {
|
|
207
|
+
const row = sizes.get(island.path)
|
|
208
|
+
if (!row) continue
|
|
209
|
+
// Keyed by island id: every island's loader resolves `module.default`, so
|
|
210
|
+
// matching on the export name collapses them all onto one reference.
|
|
211
|
+
const reference = route.clientReferences.find(item => item.id === island.id)
|
|
186
212
|
const load: AnalyzeDynamicBundleFile['load'] =
|
|
187
213
|
reference?.dynamic?.load === 'visible' ? 'visible' : 'render'
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
dynamicTargets.add(imported.path)
|
|
214
|
+
dynamicTargets.add(island.path)
|
|
215
|
+
const exportName = reference?.exportName ?? 'default'
|
|
191
216
|
const item: AnalyzeDynamicBundleFile = {
|
|
192
|
-
path:
|
|
193
|
-
|
|
217
|
+
path: island.path,
|
|
218
|
+
id: island.id,
|
|
219
|
+
component: islandComponent(exportName, reference?.file, island.path),
|
|
220
|
+
exportName,
|
|
194
221
|
load,
|
|
195
222
|
rawBytes: row.rawBytes,
|
|
196
223
|
compressedBytes: row.compressedBytes,
|
|
@@ -198,12 +225,22 @@ async function analyzeRouteBundle(
|
|
|
198
225
|
if (load === 'visible') visibleDynamic.push(item)
|
|
199
226
|
else dynamic.push(item)
|
|
200
227
|
|
|
201
|
-
|
|
228
|
+
// An island with its own CSS loads `assets/<referenceId>.css` alongside its
|
|
229
|
+
// chunk (loadIslandCss); it is deferred weight, so it belongs here rather
|
|
230
|
+
// than in initial.
|
|
231
|
+
if (reference?.cssImports?.length) addIfExists(lazyShared, sizes, `assets/${island.id}.css`)
|
|
232
|
+
for (const dependency of await staticDependencies(island.path, sizes, js)) {
|
|
202
233
|
if (!initial.has(dependency) && !dynamicTargets.has(dependency)) lazyShared.add(dependency)
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
|
|
206
|
-
|
|
237
|
+
// Same metafile closure as the renderer's low-priority preloads, unioned
|
|
238
|
+
// with the entry-source walk for the same reason as the static side above.
|
|
239
|
+
for (const asset of [
|
|
240
|
+
...(route.clientDynamicImports ?? []),
|
|
241
|
+
...dynamicImportPaths(entrySource, route.clientEntry),
|
|
242
|
+
]) {
|
|
243
|
+
const importedPath = toPosix(asset)
|
|
207
244
|
if (initial.has(importedPath) || dynamicTargets.has(importedPath)) continue
|
|
208
245
|
if (sizes.has(importedPath)) lazyShared.add(importedPath)
|
|
209
246
|
for (const dependency of await staticDependencies(importedPath, sizes, js)) {
|
|
@@ -222,6 +259,12 @@ async function analyzeRouteBundle(
|
|
|
222
259
|
}
|
|
223
260
|
}
|
|
224
261
|
|
|
262
|
+
function islandComponent(exportName: string, file: string | undefined, chunkPath: string) {
|
|
263
|
+
if (exportName !== 'default') return exportName
|
|
264
|
+
const source = file ?? chunkPath.replace(/-[A-Z0-9]{8}\.js$/, '.js')
|
|
265
|
+
return path.posix.basename(toPosix(source)).replace(/\.[^.]+$/, '')
|
|
266
|
+
}
|
|
267
|
+
|
|
225
268
|
async function staticDependencies(
|
|
226
269
|
entry: string,
|
|
227
270
|
sizes: Map<string, AnalyzeFile>,
|
|
@@ -244,17 +287,22 @@ async function staticDependencies(
|
|
|
244
287
|
return dependencies
|
|
245
288
|
}
|
|
246
289
|
|
|
247
|
-
|
|
248
|
-
|
|
290
|
+
// Entries of the entry module's island table, as emitted by client/entry.ts:
|
|
291
|
+
// { id: "c-...", options: {...}, load: () => import("./chunk.js").then(m => m.default) }
|
|
292
|
+
// The CSS variant wraps the import in `Promise.all([...])`. Statically-bundled
|
|
293
|
+
// islands carry `Component:` instead of `load:` and are part of the entry's own
|
|
294
|
+
// closure, so they are not matched here.
|
|
295
|
+
function islandImports(source: string, from: string) {
|
|
296
|
+
const imports: { path: string; id: string }[] = []
|
|
249
297
|
const pattern =
|
|
250
|
-
|
|
298
|
+
/\bid\s*:\s*"([^"]+)"\s*,\s*options\s*:\s*\{[^{}]*\}\s*,\s*load\s*:\s*\(\)\s*=>\s*(?:Promise\.all\(\[\s*)?import\(\s*"([^"]+)"\s*\)/g
|
|
251
299
|
let match: RegExpExecArray | null
|
|
252
300
|
while ((match = pattern.exec(source))) {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
if (!
|
|
301
|
+
const id = match[1]
|
|
302
|
+
const specifier = match[2]
|
|
303
|
+
if (!id || !specifier) continue
|
|
256
304
|
const resolved = resolveBuiltImport(from, specifier)
|
|
257
|
-
if (resolved) imports.push({ path: resolved,
|
|
305
|
+
if (resolved) imports.push({ path: resolved, id })
|
|
258
306
|
}
|
|
259
307
|
return imports
|
|
260
308
|
}
|
|
@@ -306,18 +354,27 @@ function bundleFiles(paths: string[], sizes: Map<string, AnalyzeFile>) {
|
|
|
306
354
|
.sort((a, b) => b.compressedBytes - a.compressedBytes)
|
|
307
355
|
}
|
|
308
356
|
|
|
309
|
-
function routeHtmlPath(route: RouteManifestEntry) {
|
|
310
|
-
|
|
357
|
+
function routeHtmlPath(route: RouteManifestEntry, pathname?: string) {
|
|
358
|
+
const target = pathname ?? prerenderedPathname(route) ?? route.route
|
|
359
|
+
return target === '/' ? 'index.html' : `${target.replace(/^\/+/, '')}/index.html`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A param route prerenders one file per param set, never `posts/:slug/index.html`.
|
|
363
|
+
// Report the first as the representative page instead of dropping HTML entirely.
|
|
364
|
+
function prerenderedPathname(route: RouteManifestEntry) {
|
|
365
|
+
const params = route.prerenderedParams?.[0]
|
|
366
|
+
if (!params) return undefined
|
|
367
|
+
return route.route.replace(/:([a-zA-Z0-9_]+)\*?/g, (_match, name: string) => {
|
|
368
|
+
const value = params[name]
|
|
369
|
+
return Array.isArray(value) ? value.join('/') : (value ?? '')
|
|
370
|
+
})
|
|
311
371
|
}
|
|
312
372
|
|
|
313
373
|
function analyzeTarget(outPath: string) {
|
|
374
|
+
// PPR shells live in `.pnext/ppr`, outside `public`: they are resumed
|
|
375
|
+
// server-side and never downloaded, so they are not shipped weight.
|
|
314
376
|
const publicPath = path.join(outPath, 'public')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const cachePath = path.join(outPath, 'cache')
|
|
318
|
-
if (existsSync(cachePath)) return { mode: 'development' as const, root: cachePath }
|
|
319
|
-
|
|
320
|
-
return null
|
|
377
|
+
return existsSync(publicPath) ? { root: publicPath } : null
|
|
321
378
|
}
|
|
322
379
|
|
|
323
380
|
function compressedSize(bytes: Buffer, compression: AnalyzeCompression) {
|
|
@@ -344,9 +401,7 @@ export function printAnalyzeResult(
|
|
|
344
401
|
result: AnalyzeResult,
|
|
345
402
|
options: { route?: string; files: boolean },
|
|
346
403
|
) {
|
|
347
|
-
console.log(
|
|
348
|
-
`PNext analyze: ${result.mode === 'production' ? 'production build' : 'dev server cache'} at ${dim(result.root)} (${result.compression})`,
|
|
349
|
-
)
|
|
404
|
+
console.log(`PNext analyze: production build at ${dim(result.root)} (${result.compression})`)
|
|
350
405
|
printRouteBundles(result)
|
|
351
406
|
// With a route filter, the remaining files are mostly other routes' bundles —
|
|
352
407
|
// listing them as "other" would misread as unowned weight.
|
|
@@ -385,11 +440,11 @@ function printDynamicBundleGroup(
|
|
|
385
440
|
if (files.length === 0) return
|
|
386
441
|
console.log(`\n ${bold(label)}`)
|
|
387
442
|
const columns = rowColumns(files)
|
|
388
|
-
const componentWidth = Math.max(...files.map(file => file.
|
|
443
|
+
const componentWidth = Math.max(...files.map(file => file.component.length))
|
|
389
444
|
const pathWidth = Math.max(...files.map(file => file.path.length))
|
|
390
445
|
for (const file of files) {
|
|
391
446
|
console.log(
|
|
392
|
-
` ${file.
|
|
447
|
+
` ${file.component.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
|
|
393
448
|
)
|
|
394
449
|
}
|
|
395
450
|
}
|
package/src/cli/dev.ts
CHANGED
|
@@ -86,7 +86,11 @@ function watchServerMemory(server: DevServerHandle) {
|
|
|
86
86
|
if (!Number.isFinite(limitMb) || limitMb <= 0) return
|
|
87
87
|
const limitBytes = limitMb * 1024 * 1024
|
|
88
88
|
const timer = setInterval(() => {
|
|
89
|
-
|
|
89
|
+
// Bun (<=1.3.x) lacks the `process.memoryUsage.rss` fast path Node provides.
|
|
90
|
+
const rss =
|
|
91
|
+
typeof process.memoryUsage.rss === 'function'
|
|
92
|
+
? process.memoryUsage.rss()
|
|
93
|
+
: process.memoryUsage().rss
|
|
90
94
|
if (rss < limitBytes) return
|
|
91
95
|
clearInterval(timer)
|
|
92
96
|
const usedMb = Math.round(rss / (1024 * 1024))
|
package/src/client/build.ts
CHANGED
|
@@ -35,8 +35,6 @@ import {
|
|
|
35
35
|
} from '../resolve/imports'
|
|
36
36
|
import {
|
|
37
37
|
CLIENT_RUNTIME_MODULE,
|
|
38
|
-
DYN_SHARED_GLOBAL,
|
|
39
|
-
dynSharedSpecifiers,
|
|
40
38
|
clientEntrySource,
|
|
41
39
|
clientRuntimeFacts,
|
|
42
40
|
clientRuntimeSource,
|
|
@@ -369,23 +367,60 @@ async function preparePrebuilt(
|
|
|
369
367
|
}
|
|
370
368
|
}
|
|
371
369
|
|
|
372
|
-
/**
|
|
373
|
-
|
|
374
|
-
|
|
370
|
+
/**
|
|
371
|
+
* The served URL of a deferred dynamic reference's on-demand output (dev split).
|
|
372
|
+
* `r` names the route whose build emitted it: two routes reaching the same island
|
|
373
|
+
* each bundle their own, and one route's copy chunk-splits against its own entry.
|
|
374
|
+
*/
|
|
375
|
+
export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>, routeId?: string) {
|
|
376
|
+
const query = routeId ? `?r=${encodeURIComponent(routeId)}` : ''
|
|
377
|
+
return `/__pnext/client-dyn/${reference.id}.js${query}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Where the dev server serves this build's chunks from (see publicPath). */
|
|
381
|
+
export const devClientPublicPath = '/__pnext/client'
|
|
382
|
+
|
|
383
|
+
export interface DeferredDynamicEntry {
|
|
384
|
+
id: string
|
|
385
|
+
file: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Every deferred reference this route's build must emit an output for: the ones
|
|
390
|
+
* the route scan named, plus the ones its own pipeline rewrite registered.
|
|
391
|
+
*/
|
|
392
|
+
function deferredDynamicEntries(route: RouteManifestEntry): DeferredDynamicEntry[] {
|
|
393
|
+
const entries = new Map<string, string>()
|
|
394
|
+
for (const reference of route.clientReferences) {
|
|
395
|
+
if (reference.dynamic && !ssrClientReference(reference)) {
|
|
396
|
+
entries.set(reference.id, reference.file)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const [id, ref] of deferredDynamicRouteRefs.get(route.id) ?? []) entries.set(id, ref.file)
|
|
400
|
+
return [...entries].map(([id, file]) => ({ id, file }))
|
|
375
401
|
}
|
|
376
402
|
|
|
377
403
|
export interface DeferredDynamicRef {
|
|
378
404
|
file: string
|
|
379
405
|
exportName: string
|
|
406
|
+
/** Entry that rewrote this reference: the build its output is emitted by. */
|
|
407
|
+
routeId?: string
|
|
380
408
|
}
|
|
381
409
|
|
|
382
410
|
// Process-level registry the dev chunk endpoint resolves ids through. Entries
|
|
383
411
|
// come from the pipeline rewrite below and from each out-dir's sidecar (a
|
|
384
412
|
// restart serving a cached entry never re-ran the rewrite).
|
|
385
413
|
const deferredDynamicRefs = new Map<string, DeferredDynamicRef>()
|
|
414
|
+
// Indexed by route as well: a route's build needs every reference it reached as
|
|
415
|
+
// an entry point, and a rewrite only names them once the build has run.
|
|
416
|
+
const deferredDynamicRouteRefs = new Map<string, Map<string, DeferredDynamicRef>>()
|
|
386
417
|
|
|
387
418
|
export function registerDeferredDynamicRef(id: string, ref: DeferredDynamicRef) {
|
|
388
419
|
deferredDynamicRefs.set(id, ref)
|
|
420
|
+
if (!ref.routeId) return
|
|
421
|
+
const refs = deferredDynamicRouteRefs.get(ref.routeId) ?? new Map<string, DeferredDynamicRef>()
|
|
422
|
+
deferredDynamicRouteRefs.set(ref.routeId, refs)
|
|
423
|
+
refs.set(id, ref)
|
|
389
424
|
}
|
|
390
425
|
|
|
391
426
|
export function deferredDynamicRefById(id: string) {
|
|
@@ -396,11 +431,11 @@ export function deferredDynamicRefById(id: string) {
|
|
|
396
431
|
export const DEFERRED_DYNAMIC_SIDECAR = 'dyn-refs.json'
|
|
397
432
|
|
|
398
433
|
/** Dev-only: deferred dynamic references load from the on-demand chunk endpoint. */
|
|
399
|
-
function devDeferredDynamicHref(dev: boolean | undefined) {
|
|
434
|
+
function devDeferredDynamicHref(dev: boolean | undefined, routeId?: string) {
|
|
400
435
|
if (!dev || !devDynamicSplitEnabled()) return undefined
|
|
401
436
|
return (reference: ClientReference) =>
|
|
402
437
|
reference.dynamic && !ssrClientReference(reference)
|
|
403
|
-
? deferredDynamicChunkHref(reference)
|
|
438
|
+
? deferredDynamicChunkHref(reference, routeId)
|
|
404
439
|
: undefined
|
|
405
440
|
}
|
|
406
441
|
|
|
@@ -422,7 +457,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
422
457
|
await ensureDir(outDir)
|
|
423
458
|
const suspense = routeSuspenseFree(config, route) === false
|
|
424
459
|
const source = clientEntrySource({
|
|
425
|
-
deferredDynamicHref: devDeferredDynamicHref(dev),
|
|
460
|
+
deferredDynamicHref: devDeferredDynamicHref(dev, route.id),
|
|
426
461
|
pageFile: route.client ? route.file : undefined,
|
|
427
462
|
clientReferences: route.clientReferences,
|
|
428
463
|
nextCompat: nextCompatEnabled(config),
|
|
@@ -439,7 +474,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
439
474
|
})
|
|
440
475
|
const entryName = clientEntryName(route)
|
|
441
476
|
const outfile = path.join(outDir, `${entryName}.js`)
|
|
442
|
-
const pipeline = createClientSourcePipeline(config, dev === true)
|
|
477
|
+
const pipeline = createClientSourcePipeline(config, dev === true, route.id)
|
|
443
478
|
pipeline.warmRoutes([route])
|
|
444
479
|
const runtimeFacts = clientRuntimeFacts(
|
|
445
480
|
[
|
|
@@ -467,38 +502,59 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
467
502
|
),
|
|
468
503
|
)
|
|
469
504
|
|
|
505
|
+
const split = Boolean(devDeferredDynamicHref(dev))
|
|
506
|
+
const runEntryBuild = (dynEntries: DeferredDynamicEntry[]) =>
|
|
507
|
+
build({
|
|
508
|
+
...baseClientBuildOptions(config, dev),
|
|
509
|
+
// Every deferred reference is an entry point of THIS build, so esbuild
|
|
510
|
+
// hoists what it shares with the route entry into a shared chunk. Module
|
|
511
|
+
// identity — contexts, singletons, the preact instance — then holds by
|
|
512
|
+
// construction, and esbuild owns the interop it always did.
|
|
513
|
+
entryPoints: [
|
|
514
|
+
{ in: virtualEntryPath(route.id), out: entryName },
|
|
515
|
+
...dynEntries.map(entry => ({ in: entry.file, out: entry.id })),
|
|
516
|
+
],
|
|
517
|
+
// Chunk imports as absolute URLs: an on-demand output is served from a
|
|
518
|
+
// different directory than the entry, and a relative specifier would make
|
|
519
|
+
// the browser fetch the same chunk under two URLs — two module instances.
|
|
520
|
+
...(split ? { publicPath: devClientPublicPath } : {}),
|
|
521
|
+
outdir: outDir,
|
|
522
|
+
metafile: true,
|
|
523
|
+
plugins: clientBuildPlugins(
|
|
524
|
+
config,
|
|
525
|
+
pipeline,
|
|
526
|
+
[
|
|
527
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
528
|
+
virtualEntryPlugin([{ route, source }]),
|
|
529
|
+
...(split ? [deferredDynamicExternalPlugin()] : []),
|
|
530
|
+
clientRuntimePlugin(runtimeFacts),
|
|
531
|
+
],
|
|
532
|
+
!suspense,
|
|
533
|
+
),
|
|
534
|
+
})
|
|
535
|
+
|
|
470
536
|
let metafile: Metafile | undefined
|
|
537
|
+
let dynEntries = split ? deferredDynamicEntries(route) : []
|
|
471
538
|
try {
|
|
472
539
|
const result = await profileClientBuild(route, dev, () =>
|
|
473
|
-
clientProfile.timeAsync('esbuild', () =>
|
|
474
|
-
build({
|
|
475
|
-
...baseClientBuildOptions(config, dev),
|
|
476
|
-
stdin: {
|
|
477
|
-
contents: source,
|
|
478
|
-
loader: 'ts',
|
|
479
|
-
resolveDir: process.cwd(),
|
|
480
|
-
sourcefile: `${route.id}.ts`,
|
|
481
|
-
},
|
|
482
|
-
outdir: outDir,
|
|
483
|
-
entryNames: entryName,
|
|
484
|
-
metafile: true,
|
|
485
|
-
plugins: clientBuildPlugins(
|
|
486
|
-
config,
|
|
487
|
-
pipeline,
|
|
488
|
-
[
|
|
489
|
-
...(prebuilt ? [prebuilt.plugin] : []),
|
|
490
|
-
...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
|
|
491
|
-
clientRuntimePlugin(runtimeFacts),
|
|
492
|
-
],
|
|
493
|
-
!suspense,
|
|
494
|
-
),
|
|
495
|
-
}),
|
|
496
|
-
),
|
|
540
|
+
clientProfile.timeAsync('esbuild', () => runEntryBuild(dynEntries)),
|
|
497
541
|
)
|
|
498
542
|
metafile = result.metafile
|
|
499
543
|
} catch (error) {
|
|
500
544
|
throw withClientImportTrace(error, config, route, source)
|
|
501
545
|
}
|
|
546
|
+
// A dynamic() inside a 'use client' module is only named by the pipeline's
|
|
547
|
+
// rewrite, which runs mid-build: those references become entry points on the
|
|
548
|
+
// rebuild here, and stay ones from then on (the registry outlives the build).
|
|
549
|
+
if (split) {
|
|
550
|
+
const discovered = deferredDynamicEntries(route)
|
|
551
|
+
if (discovered.length !== dynEntries.length) {
|
|
552
|
+
dynEntries = discovered
|
|
553
|
+
metafile = (
|
|
554
|
+
await clientProfile.timeAsync('esbuildDynEntries', () => runEntryBuild(dynEntries))
|
|
555
|
+
).metafile
|
|
556
|
+
}
|
|
557
|
+
}
|
|
502
558
|
await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve())
|
|
503
559
|
|
|
504
560
|
if (metafile) {
|
|
@@ -522,88 +578,6 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
522
578
|
return outfile
|
|
523
579
|
}
|
|
524
580
|
|
|
525
|
-
/**
|
|
526
|
-
* Dev split: bundle ONE deferred dynamic reference on browser demand. The
|
|
527
|
-
* chunk is its own esbuild build, so single-instance vendors (preact and its
|
|
528
|
-
* facades) resolve to shared shims reading the entry's published namespaces
|
|
529
|
-
* (see dynSharedTableSource) instead of bundling a second copy whose hooks
|
|
530
|
-
* would never see the entry renderer's dispatch.
|
|
531
|
-
*/
|
|
532
|
-
export async function buildClientDynamicChunk({
|
|
533
|
-
config,
|
|
534
|
-
route,
|
|
535
|
-
reference,
|
|
536
|
-
outDir,
|
|
537
|
-
}: {
|
|
538
|
-
config: ResolvedConfig
|
|
539
|
-
route?: RouteManifestEntry
|
|
540
|
-
reference: DeferredDynamicRef & { id: string }
|
|
541
|
-
outDir: string
|
|
542
|
-
}) {
|
|
543
|
-
await ensureDir(outDir)
|
|
544
|
-
const pipeline = createClientSourcePipeline(config, true)
|
|
545
|
-
const prebuilt = await preparePrebuilt(
|
|
546
|
-
config,
|
|
547
|
-
outDir,
|
|
548
|
-
true,
|
|
549
|
-
importer => pipeline.sourceOf(importer) ?? readTextSyncSafe(importer),
|
|
550
|
-
route ? surfaceSignature(config, route, true) : '',
|
|
551
|
-
)
|
|
552
|
-
const source = [
|
|
553
|
-
reference.exportName === 'default'
|
|
554
|
-
? `export { default } from ${JSON.stringify(reference.file)};`
|
|
555
|
-
: '',
|
|
556
|
-
`export * from ${JSON.stringify(reference.file)};`,
|
|
557
|
-
].join('\n')
|
|
558
|
-
const outfile = path.join(outDir, `${reference.id}.js`)
|
|
559
|
-
await clientProfile.timeAsync('dynChunk', () =>
|
|
560
|
-
build({
|
|
561
|
-
...baseClientBuildOptions(config, true),
|
|
562
|
-
stdin: {
|
|
563
|
-
contents: source,
|
|
564
|
-
loader: 'ts',
|
|
565
|
-
resolveDir: process.cwd(),
|
|
566
|
-
sourcefile: `${reference.id}.dyn.ts`,
|
|
567
|
-
},
|
|
568
|
-
outdir: outDir,
|
|
569
|
-
entryNames: reference.id,
|
|
570
|
-
plugins: clientBuildPlugins(config, pipeline, [
|
|
571
|
-
...(prebuilt ? [prebuilt.plugin] : []),
|
|
572
|
-
dynSharedVendorPlugin(nextCompatEnabled(config)),
|
|
573
|
-
deferredDynamicExternalPlugin(),
|
|
574
|
-
]),
|
|
575
|
-
}),
|
|
576
|
-
)
|
|
577
|
-
await prebuilt?.settle()
|
|
578
|
-
clientProfile.report(`client dynamic chunk ${reference.id}`)
|
|
579
|
-
return outfile
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Shared-vendor shims: preact resolves to the entry's published namespace. */
|
|
583
|
-
function dynSharedVendorPlugin(nextCompat: boolean): Plugin {
|
|
584
|
-
const namespace = 'pnext-dyn-shared'
|
|
585
|
-
const filter = new RegExp(`^(?:${dynSharedSpecifiers(nextCompat).map(escapeRegex).join('|')})$`)
|
|
586
|
-
return {
|
|
587
|
-
name: 'pnext-dyn-shared-vendor',
|
|
588
|
-
setup(build) {
|
|
589
|
-
build.onResolve({ filter }, args =>
|
|
590
|
-
args.namespace === namespace ? undefined : { path: args.path, namespace },
|
|
591
|
-
)
|
|
592
|
-
build.onLoad({ filter: /.*/, namespace }, async args => {
|
|
593
|
-
const names = Object.keys((await import(args.path)) as Record<string, unknown>).filter(
|
|
594
|
-
name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default',
|
|
595
|
-
)
|
|
596
|
-
const lines = [
|
|
597
|
-
`const m = window.${DYN_SHARED_GLOBAL}[${JSON.stringify(args.path)}];`,
|
|
598
|
-
'export default (m && m.default);',
|
|
599
|
-
...names.map(name => `export const ${name} = m.${name};`),
|
|
600
|
-
]
|
|
601
|
-
return { contents: lines.join('\n'), loader: 'js' }
|
|
602
|
-
})
|
|
603
|
-
},
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
581
|
/**
|
|
608
582
|
* Bundle every route's client entry in a single esbuild build. With `splitting`
|
|
609
583
|
* enabled, esbuild emits the shared dependency graph (preact runtime, ui kit,
|
|
@@ -1404,7 +1378,7 @@ function coreStaticAssetModule(
|
|
|
1404
1378
|
* App-convention .js/.mjs may contain JSX (jsxImportSource is preact), so those parse with the jsx
|
|
1405
1379
|
* loader; scoped to the source roots, so third-party node_modules .js keeps esbuild's default loader.
|
|
1406
1380
|
*/
|
|
1407
|
-
function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
|
|
1381
|
+
function createClientSourcePipeline(config: ResolvedConfig, dev = false, routeId?: string) {
|
|
1408
1382
|
const sourceRewrite = nextCompatEnabled(config)
|
|
1409
1383
|
const compiler = getCompatModeExtensions().reactCompilerOptions(config)
|
|
1410
1384
|
const asyncPre = hasClientSourceAsyncPreTransforms()
|
|
@@ -1466,9 +1440,10 @@ function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
|
|
|
1466
1440
|
specifier => resolveImport(rootFromFile(resolved), resolved, specifier),
|
|
1467
1441
|
target => {
|
|
1468
1442
|
const id = clientReferenceId(target.file, target.exportName)
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1443
|
+
const ref = { ...target, routeId }
|
|
1444
|
+
registerDeferredDynamicRef(id, ref)
|
|
1445
|
+
deferredRefs.set(id, ref)
|
|
1446
|
+
return deferredDynamicChunkHref({ id }, routeId)
|
|
1472
1447
|
},
|
|
1473
1448
|
)
|
|
1474
1449
|
}
|