@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.
@@ -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 ? filterAnalyzeRoutes(pages, routeFilter) : pages
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
- function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string) {
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
- addIfExists(initial, sizes, `assets/${route.id}.css`)
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 imports = dynamicImports(entrySource, route.clientEntry)
184
- for (const imported of imports) {
185
- const reference = route.clientReferences.find(item => item.exportName === imported.exportName)
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
- const row = sizes.get(imported.path)
189
- if (!row) continue
190
- dynamicTargets.add(imported.path)
214
+ dynamicTargets.add(island.path)
215
+ const exportName = reference?.exportName ?? 'default'
191
216
  const item: AnalyzeDynamicBundleFile = {
192
- path: imported.path,
193
- exportName: imported.exportName,
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
- for (const dependency of await staticDependencies(imported.path, sizes, js)) {
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
- for (const importedPath of dynamicImportPaths(entrySource, route.clientEntry)) {
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
- function dynamicImports(source: string, from: string) {
248
- const imports: { path: string; exportName: string }[] = []
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
- /import\("([^"]+)"\)\.then\([A-Za-z_$][\w$]*=>[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)\)/g
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 specifier = match[1]
254
- const exportName = match[2]
255
- if (!specifier || !exportName) continue
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, exportName })
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
- return route.route === '/' ? 'index.html' : `${route.route.replace(/^\/+/, '')}/index.html`
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
- if (existsSync(publicPath)) return { mode: 'production' as const, root: publicPath }
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.exportName.length))
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.exportName.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
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/overview.md\n`,
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
 
@@ -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
- const aliases = { ...getImportAliasExtensions().aliases(config, 'client') }
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) {
@@ -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
- return path.join(import.meta.dirname, '../render/static-slots.ts')
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
- function islandProps(raw, root, toChildren) {
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
- const page = cached.settled
4063
- ? await cached.page
4064
- : await Promise.race([cached.page, unsettledPrefetchDeadline()])
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
- const pushOptimisticUrl = () => {
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
- if (options.replace) history.replaceState(shellState, '', url.href)
4533
- else history.pushState(shellState, '', url.href)
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
- const mode = link.getAttribute('data-prefetch')
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.getAttribute('data-prefetch')
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 { PNextConfig } from './types'
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
 
@@ -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