@wular/pnext 0.0.6 → 0.0.8
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/README.md +12 -6
- package/bin/pnext +5 -0
- package/package.json +1 -1
- package/reference/compat.md +86 -21
- package/reference/config.md +40 -93
- package/reference/css.md +40 -35
- package/reference/dev.md +25 -32
- package/reference/env.md +18 -20
- package/reference/getting-started.md +132 -0
- package/reference/metadata.md +26 -41
- package/reference/navigation.md +58 -105
- package/reference/performance.md +21 -142
- package/reference/rendering.md +73 -70
- package/reference/routing.md +87 -97
- package/reference/typegen.md +21 -27
- package/src/api/client-navigation.ts +11 -8
- package/src/api/link.tsx +5 -3
- package/src/cli/analyze.ts +103 -48
- package/src/cli/create.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/client/build.ts +10 -2
- package/src/client/entry.ts +13 -9
- package/src/client/router/events.ts +15 -0
- package/src/client/router/runtime.ts +40 -9
- package/src/client/router/types.ts +5 -0
- package/src/config.ts +18 -1
- package/src/dev/server.ts +2 -2
- package/src/render/renderer.ts +11 -0
- package/src/render/static-slots-revive.ts +65 -0
- package/src/render/static-slots.ts +3 -54
- package/src/routing/href.ts +19 -1
- package/src/runtime/loader.ts +3 -1
- package/src/runtime/vendor.ts +5 -4
- package/src/types.ts +5 -1
- package/reference/overview.md +0 -35
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/create.ts
CHANGED
|
@@ -149,6 +149,6 @@ function scaffoldFiles(name: string): Record<string, string> {
|
|
|
149
149
|
'app/counter.tsx': `'use client';\n\nimport { useState } from 'preact/hooks';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;\n}\n`,
|
|
150
150
|
'app/globals.css': `body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n`,
|
|
151
151
|
'.gitignore': `node_modules\n.pnext\n`,
|
|
152
|
-
'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/
|
|
152
|
+
'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/getting-started.md\n`,
|
|
153
153
|
}
|
|
154
154
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ import path from 'node:path'
|
|
|
4
4
|
import { markBoot } from './boot/trace'
|
|
5
5
|
import { commandBinaryName, nameEsbuildProcess, namedBunBinary } from './boot/named-bin'
|
|
6
6
|
|
|
7
|
+
// Reached only if something bypassed bin/pnext (which guards the same way).
|
|
8
|
+
if (typeof Bun === 'undefined') {
|
|
9
|
+
console.error('pnext requires Bun. Install it from https://bun.sh/get')
|
|
10
|
+
process.exit(1)
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
const [, , command, ...args] = process.argv
|
|
8
14
|
markBoot('cli:entry')
|
|
9
15
|
|
package/src/client/build.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { foldInitialChunks } from './chunk-fold'
|
|
17
17
|
import { clientEntryName } from './chunk-name'
|
|
18
18
|
import { clientProfile } from './profile'
|
|
19
|
-
import { publicEnvDefines, type ResolvedConfig } from '../config'
|
|
19
|
+
import { frameworkRuntimeAliasEntries, publicEnvDefines, type ResolvedConfig } from '../config'
|
|
20
20
|
import { cssModuleClientPlugin } from '../css/build'
|
|
21
21
|
import { withAssetPrefix } from '../css/build'
|
|
22
22
|
import {
|
|
@@ -1220,7 +1220,15 @@ function clientBuildPlugins(
|
|
|
1220
1220
|
}
|
|
1221
1221
|
|
|
1222
1222
|
function importAliasPlugin(config: ResolvedConfig, reactLite = false): Plugin {
|
|
1223
|
-
|
|
1223
|
+
// preact core/hooks/jsx-runtime are single-instance, compat or not: an app with its
|
|
1224
|
+
// own `preact` in node_modules otherwise bundles a second physical copy next to the
|
|
1225
|
+
// framework's, and hooks called from framework components (Link) read a null current
|
|
1226
|
+
// component off options the app's copy never installed. The server runtime pins the
|
|
1227
|
+
// same set unconditionally (loader's coreAliases); the client build must match.
|
|
1228
|
+
const aliases: Record<string, string> = {
|
|
1229
|
+
...frameworkRuntimeAliasEntries(),
|
|
1230
|
+
...getImportAliasExtensions().aliases(config, 'client'),
|
|
1231
|
+
}
|
|
1224
1232
|
// Suspense-free tier: the app's `react` imports resolve to the compat-free lite shim, so the
|
|
1225
1233
|
// bundle ships preact core + hooks without preact/compat (see clientSuspenseFree).
|
|
1226
1234
|
if (reactLite && aliases.react) {
|
package/src/client/entry.ts
CHANGED
|
@@ -91,7 +91,9 @@ function islandContextModulePath() {
|
|
|
91
91
|
// Wire-marker revival shared with the server encoder (utils/serialize.ts):
|
|
92
92
|
// island props carrying a CYCLE travel as `$$pnext_ref` back-references.
|
|
93
93
|
function staticSlotsModulePath() {
|
|
94
|
-
|
|
94
|
+
// The preact-free half: entries import it eagerly, so it must not drag preact into a
|
|
95
|
+
// visible-dynamic entry's static graph.
|
|
96
|
+
return path.join(import.meta.dirname, '../render/static-slots-revive.ts')
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
function serializeModulePath() {
|
|
@@ -296,10 +298,12 @@ import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots
|
|
|
296
298
|
// Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
|
|
297
299
|
// inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
|
|
298
300
|
// with no element props skip the walk on a substring test of the raw attribute.
|
|
299
|
-
|
|
301
|
+
// \`h\` is threaded in rather than imported by the slot reviver: that would put preact in the static
|
|
302
|
+
// graph of entries (visible-dynamic) that otherwise only lazy-import it.
|
|
303
|
+
function islandProps(raw, root, toChildren, h) {
|
|
300
304
|
const props = parseIslandProps(raw);
|
|
301
305
|
if (!__pnextHasIslandSlots(raw)) return props;
|
|
302
|
-
return __pnextReviveIslandSlots(props, root, toChildren);
|
|
306
|
+
return __pnextReviveIslandSlots(props, root, toChildren, h);
|
|
303
307
|
}
|
|
304
308
|
function parseIslandProps(raw) {
|
|
305
309
|
const props = JSON.parse(raw || '{}');${
|
|
@@ -978,7 +982,7 @@ async function mountIslandTree(root, island) {
|
|
|
978
982
|
]);
|
|
979
983
|
${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
980
984
|
const source = preservedSource(root, render);
|
|
981
|
-
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
|
|
985
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
|
|
982
986
|
const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
|
|
983
987
|
if (source !== root) adoptPreserved(render, root, wrapped);
|
|
984
988
|
else mount(hydrate, render, root, wrapped);
|
|
@@ -991,7 +995,7 @@ async function mountIslandTree(root, island) {
|
|
|
991
995
|
const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), island.load()]);
|
|
992
996
|
const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
993
997
|
const source = preservedSource(root, render);
|
|
994
|
-
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
|
|
998
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
|
|
995
999
|
if (source !== root) adoptPreserved(render, root, vnode);
|
|
996
1000
|
else mount(hydrate, render, root, vnode);
|
|
997
1001
|
}`
|
|
@@ -1077,7 +1081,7 @@ async function domNode(h, node) {
|
|
|
1077
1081
|
if (!island) return h(element.localName, domProps(element), await domChildren(h, element));
|
|
1078
1082
|
const Component = island.Component ?? await island.load();
|
|
1079
1083
|
const rawProps = element.getAttribute('data-pnext-props') ?? '{}';
|
|
1080
|
-
const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node)), await staticChildren(h, element, island.id));
|
|
1084
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node), h), await staticChildren(h, element, island.id));
|
|
1081
1085
|
return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
|
|
1082
1086
|
}
|
|
1083
1087
|
|
|
@@ -1364,7 +1368,7 @@ async function mountIslandTree(root, island) {
|
|
|
1364
1368
|
// Preserved across a soft navigation: re-render in place with the incoming
|
|
1365
1369
|
// document's props/children so component state survives while the routed
|
|
1366
1370
|
// content under the island updates.
|
|
1367
|
-
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
|
|
1371
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
|
|
1368
1372
|
render(${wrapInBoundary('vnode', nextCompat)}, root);
|
|
1369
1373
|
pnextMountedRoots.add(root);
|
|
1370
1374
|
return;
|
|
@@ -1375,7 +1379,7 @@ async function mountIslandTree(root, island) {
|
|
|
1375
1379
|
root.replaceChildren(...incoming.childNodes);
|
|
1376
1380
|
root.__pnextLive = undefined;
|
|
1377
1381
|
}
|
|
1378
|
-
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
|
|
1382
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
|
|
1379
1383
|
mount(root, ${wrapInBoundary('vnode', nextCompat)});
|
|
1380
1384
|
}
|
|
1381
1385
|
|
|
@@ -1448,7 +1452,7 @@ async function domNode(node) {
|
|
|
1448
1452
|
: ''
|
|
1449
1453
|
}
|
|
1450
1454
|
const Component = island.Component ?? await island.load();
|
|
1451
|
-
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren)', 'children', 'element', nextCompat), nextCompat)};
|
|
1455
|
+
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element', nextCompat), nextCompat)};
|
|
1452
1456
|
}
|
|
1453
1457
|
|
|
1454
1458
|
if (Page && element.id === 'pnext-page') {
|
|
@@ -22,9 +22,24 @@ export function locationListeners() {
|
|
|
22
22
|
|
|
23
23
|
export function emitLocationChange() {
|
|
24
24
|
routerState.observedLocationKey = locationKey()
|
|
25
|
+
if (silentLocationDepth > 0) return
|
|
25
26
|
for (const listener of [...locationListeners()]) listener()
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
let silentLocationDepth = 0
|
|
30
|
+
|
|
31
|
+
// Moves the address bar without waking usePathname/useParams subscribers: the pre-commit
|
|
32
|
+
// optimistic push's URL is ahead of the tree, so broadcasting would desync URL and params.
|
|
33
|
+
// observedLocationKey still advances, so a traversal off the pushed entry is a real move.
|
|
34
|
+
export function withSilentLocationChange(move: () => void) {
|
|
35
|
+
silentLocationDepth++
|
|
36
|
+
try {
|
|
37
|
+
move()
|
|
38
|
+
} finally {
|
|
39
|
+
silentLocationDepth--
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
// Fires at the start of every soft navigation (link click, router.push,
|
|
29
44
|
// refresh). Compat's link-status uses it to end a link's pending state when a
|
|
30
45
|
// different navigation supersedes it.
|
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
emitNavigationCommit,
|
|
32
32
|
emitNavigationStart,
|
|
33
33
|
scheduleNavigationScroll,
|
|
34
|
+
withSilentLocationChange,
|
|
34
35
|
} from './events'
|
|
35
36
|
import type {
|
|
36
37
|
ClientPageRoot,
|
|
37
38
|
DocumentNavState,
|
|
38
39
|
EntryModule,
|
|
40
|
+
LinkPrefetchMode,
|
|
39
41
|
LoadingShellPrediction,
|
|
40
42
|
PrefetchedPage,
|
|
41
43
|
PrefetchOptions,
|
|
@@ -4059,9 +4061,13 @@ async function pageForNavigation(
|
|
|
4059
4061
|
// Reuse within the staleTime window (the entry is kept warm, not one-shot). A failed
|
|
4060
4062
|
// entry is dropped. A shell-only (partial prefetch) entry never commits as a document -
|
|
4061
4063
|
// its loading shell was painted above; fall through to the real fetch.
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4064
|
+
// A full (`prefetch={true}`) prefetch is a complete document for this navigation:
|
|
4065
|
+
// attach to it on its own settle signal (cancel/error resolve null), never a wall
|
|
4066
|
+
// clock - a slow machine must not make the router duplicate the request.
|
|
4067
|
+
const page =
|
|
4068
|
+
cached.settled || cached.full
|
|
4069
|
+
? await cached.page
|
|
4070
|
+
: await Promise.race([cached.page, unsettledPrefetchDeadline()])
|
|
4065
4071
|
if (page && !page.shellOnly) return page
|
|
4066
4072
|
// Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
|
|
4067
4073
|
// the navigation issued no duplicate fetch, so paint the static stage it landed and let
|
|
@@ -4523,14 +4529,24 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
4523
4529
|
// shallow same-entry move in onPopState and get dropped, stranding the UI on the
|
|
4524
4530
|
// half-committed target. The commit below reuses the id.
|
|
4525
4531
|
let optimisticEntryId: string | undefined
|
|
4526
|
-
|
|
4532
|
+
// `silent` moves the address bar without broadcasting: used before the tree is painted, where a
|
|
4533
|
+
// location broadcast would render the destination URL against the departing route's params.
|
|
4534
|
+
const pushOptimisticUrl = (silent = false) => {
|
|
4527
4535
|
if (optimisticallyPushed || options.pop || refreshLike) return
|
|
4528
4536
|
if (url.pathname === location.pathname && url.search === location.search) return
|
|
4529
4537
|
optimisticallyPushed = true
|
|
4530
4538
|
optimisticEntryId = routerState.renderedEntryId = newEntryId()
|
|
4531
4539
|
const shellState = { ...historyState(), __pnextEntry: optimisticEntryId }
|
|
4532
|
-
|
|
4533
|
-
|
|
4540
|
+
const move = () => {
|
|
4541
|
+
if (options.replace) history.replaceState(shellState, '', url.href)
|
|
4542
|
+
else history.pushState(shellState, '', url.href)
|
|
4543
|
+
}
|
|
4544
|
+
if (silent) withSilentLocationChange(move)
|
|
4545
|
+
else move()
|
|
4546
|
+
// Record the observed URL without emitting; a stale key would make a back() off this
|
|
4547
|
+
// entry compare equal to the departing URL and read as a shallow move, so onPopState
|
|
4548
|
+
// would leave the DOM alone and this navigation would paint over the popped entry.
|
|
4549
|
+
routerState.observedLocationKey = locationKey()
|
|
4534
4550
|
}
|
|
4535
4551
|
// A forward navigation to a route with a loading boundary streams shell-first, so paint
|
|
4536
4552
|
// that fallback into the current page container as soon as the shell chunk arrives. Skipped
|
|
@@ -4696,6 +4712,12 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
4696
4712
|
return
|
|
4697
4713
|
}
|
|
4698
4714
|
|
|
4715
|
+
// All hard-navigate bailouts are behind us: open the history entry NOW, before the asset
|
|
4716
|
+
// warm-up awaits the network - a back() in that window would otherwise escape the app
|
|
4717
|
+
// (no entry pushed yet). The final commit replaces this entry, correcting redirects. Silent:
|
|
4718
|
+
// nothing has painted yet, so subscribers must keep seeing the departing route until commit.
|
|
4719
|
+
pushOptimisticUrl(true)
|
|
4720
|
+
|
|
4699
4721
|
// Warm the new document's assets before touching the current one: the swap
|
|
4700
4722
|
// then paints styled content immediately instead of flashing unstyled HTML.
|
|
4701
4723
|
const entrySrc = entryScriptSrc(doc)
|
|
@@ -5005,6 +5027,16 @@ function isFullPrefetchLink(link: Element): boolean {
|
|
|
5005
5027
|
return link.getAttribute('data-prefetch-full') === 'true'
|
|
5006
5028
|
}
|
|
5007
5029
|
|
|
5030
|
+
// The mode a link prefetches in. Its own `data-prefetch` always wins; a plain
|
|
5031
|
+
// `data-pnext-link` anchor without one takes the app-wide config default
|
|
5032
|
+
// (`window.__PNEXT_PREFETCH__`, injected by the server), else 'visible'.
|
|
5033
|
+
export function linkPrefetchMode(link: Element): LinkPrefetchMode {
|
|
5034
|
+
const attribute = link.getAttribute('data-prefetch')
|
|
5035
|
+
if (attribute === null)
|
|
5036
|
+
return (typeof window === 'undefined' ? undefined : window.__PNEXT_PREFETCH__) ?? 'visible'
|
|
5037
|
+
return attribute === 'false' ? false : (attribute as LinkPrefetchMode)
|
|
5038
|
+
}
|
|
5039
|
+
|
|
5008
5040
|
// Pointer-intent state. True once the pointer has moved since the last pointerdown -
|
|
5009
5041
|
// distinguishes a real hover from content swapping in under a stationary cursor. Starts true
|
|
5010
5042
|
// so a hover before any click counts. Because boundary events precede the pointermove of the
|
|
@@ -5029,8 +5061,7 @@ function onLinkIntent(event: Event) {
|
|
|
5029
5061
|
}
|
|
5030
5062
|
const link = linkFromEvent(event)
|
|
5031
5063
|
if (!link) return
|
|
5032
|
-
|
|
5033
|
-
if (mode === 'false') return
|
|
5064
|
+
if (linkPrefetchMode(link) === false) return
|
|
5034
5065
|
const href = link.getAttribute('href')
|
|
5035
5066
|
// unstable_dynamicOnHover: hover intent upgrades the partial (viewport)
|
|
5036
5067
|
// prefetch to a full one carrying the dynamic data — served as a resume-only
|
|
@@ -5128,7 +5159,7 @@ function scanEagerPrefetchLinks(root: Element) {
|
|
|
5128
5159
|
const links = [...root.querySelectorAll<HTMLAnchorElement>('a[data-pnext-link]')]
|
|
5129
5160
|
if (root.matches('a[data-pnext-link]')) links.push(root as HTMLAnchorElement)
|
|
5130
5161
|
for (const link of links) {
|
|
5131
|
-
const mode = link
|
|
5162
|
+
const mode = linkPrefetchMode(link)
|
|
5132
5163
|
if (mode !== 'load' && mode !== 'visible') continue
|
|
5133
5164
|
if (eagerLinks.has(link)) continue
|
|
5134
5165
|
eagerLinks.add(link)
|
|
@@ -152,8 +152,13 @@ export interface EntryModule {
|
|
|
152
152
|
mountRoute?: () => Promise<unknown> | void
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Mirror of core's PrefetchMode; the router keeps no imports outside its chunk. */
|
|
156
|
+
export type LinkPrefetchMode = false | 'intent' | 'visible' | 'load'
|
|
157
|
+
|
|
155
158
|
declare global {
|
|
156
159
|
interface Window {
|
|
160
|
+
/** App-wide default prefetch mode (config `prefetch`), injected by the server. */
|
|
161
|
+
__PNEXT_PREFETCH__?: LinkPrefetchMode
|
|
157
162
|
__PNEXT_ROUTER_INSTALLED__?: boolean
|
|
158
163
|
__PNEXT_ROUTER_IMPORTS__?: number
|
|
159
164
|
__PNEXT_ACTIVE_ENTRY__?: ActiveEntry
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { loadEnv } from './env'
|
|
5
5
|
import { findWorkspaceRoot } from './resolve/imports'
|
|
6
|
-
import type
|
|
6
|
+
import { PREFETCH_MODES, type PNextConfig } from './types'
|
|
7
7
|
import { setCacheComponents } from './render/ppr'
|
|
8
8
|
|
|
9
9
|
export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>> &
|
|
@@ -86,6 +86,7 @@ export async function loadConfig(
|
|
|
86
86
|
? { compat: { ...(loadedConfig.default?.compat ?? {}), next: true } }
|
|
87
87
|
: {}),
|
|
88
88
|
}
|
|
89
|
+
validateConfig(config)
|
|
89
90
|
// cacheComponents is a per-app flag on a process-global cell: clear it before
|
|
90
91
|
// the next.config source re-derives it, so a pure-core (or flag-off) app
|
|
91
92
|
// loaded after a cacheComponents app never inherits the previous build's flag.
|
|
@@ -128,6 +129,22 @@ export async function loadConfig(
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// One row per enum-valued config field; validateConfig checks them all.
|
|
133
|
+
const enumFields: readonly [keyof PNextConfig, readonly unknown[]][] = [
|
|
134
|
+
['prefetch', PREFETCH_MODES],
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
function validateConfig(config: PNextConfig) {
|
|
138
|
+
for (const [field, allowed] of enumFields) {
|
|
139
|
+
const value = config[field]
|
|
140
|
+
if (value === undefined || allowed.includes(value)) continue
|
|
141
|
+
const list = allowed.map(v => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ')
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Invalid pnext config: '${field}' must be one of ${list} (received ${JSON.stringify(value)}).`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
/** Dev's private subtree under the out root. Build never touches it. */
|
|
132
149
|
export const devOutSegment = 'dev'
|
|
133
150
|
|
package/src/dev/server.ts
CHANGED
|
@@ -964,10 +964,10 @@ async function handleRoute(
|
|
|
964
964
|
}
|
|
965
965
|
|
|
966
966
|
function routeClientCacheKey(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
967
|
-
const existing = routeCacheKeys.get(route.id)
|
|
967
|
+
const existing = routeCacheKeys.get(appKey(config.outPath, route.id))
|
|
968
968
|
if (existing) return existing
|
|
969
969
|
const key = devClientCacheKey(config, route, Boolean(config.compat?.next))
|
|
970
|
-
routeCacheKeys.set(route.id, key)
|
|
970
|
+
routeCacheKeys.set(appKey(config.outPath, route.id), key)
|
|
971
971
|
return key
|
|
972
972
|
}
|
|
973
973
|
|
package/src/render/renderer.ts
CHANGED
|
@@ -3655,6 +3655,7 @@ async function renderTree(
|
|
|
3655
3655
|
renderCollectedHeadScripts(),
|
|
3656
3656
|
bootstrapScripts,
|
|
3657
3657
|
compatBuildManifestScript,
|
|
3658
|
+
prefetchModeScript(options.config),
|
|
3658
3659
|
]
|
|
3659
3660
|
.filter(Boolean)
|
|
3660
3661
|
.join('\n'),
|
|
@@ -3791,6 +3792,16 @@ function shouldRenderRuntimeMetadataInBody(options: RenderOptions, runtimeMetada
|
|
|
3791
3792
|
)
|
|
3792
3793
|
}
|
|
3793
3794
|
|
|
3795
|
+
/**
|
|
3796
|
+
* Inline script exposing the configured default prefetch mode to the client router, which applies it
|
|
3797
|
+
* to links carrying no `data-prefetch` of their own. Emitted only when `prefetch` is configured - an
|
|
3798
|
+
* app that leaves it unset keeps the global undefined and the built-in 'visible' default.
|
|
3799
|
+
*/
|
|
3800
|
+
function prefetchModeScript(config: ResolvedConfig): string {
|
|
3801
|
+
if (config.prefetch === undefined) return ''
|
|
3802
|
+
return `<script>window.__PNEXT_PREFETCH__=${JSON.stringify(config.prefetch)};</script>`
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3794
3805
|
/**
|
|
3795
3806
|
* Embeds the render's navigation state (children source path + each slot's
|
|
3796
3807
|
* resolved source path). The client echoes it on soft-navigation fetches so
|