@wular/pnext 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -11,6 +11,8 @@ interface ClientEntryOptions {
11
11
  pageFile?: string;
12
12
  clientReferences: ClientReference[];
13
13
  nextCompat?: boolean;
14
+ /** See ClientRuntimeFacts.suspense; false drops the Suspense island wrapper (and preact/compat). */
15
+ suspense?: boolean;
14
16
  /**
15
17
  * The app reaches a server action anywhere (build's action manifest, or a route whose scan
16
18
  * recorded the `actions`/`form` client-entry reason). The whole action client runtime - RPC wire,
@@ -176,13 +178,20 @@ function softRefreshModulePath() {
176
178
  return path.join(import.meta.dirname, '../compat/client/errors/soft-refresh.ts');
177
179
  }
178
180
 
179
- // The in-tree ClientErrorBoundary is emitted only for a route that has an
180
- // error.*/not-found.* to render at its own position in the tree. Without one it
181
- // would only re-throw: the deferred window last resort (errors/install) already
182
- // drives control-flow errors and mounts global-error for anything uncaught, so
183
- // an app with no boundary conventions ships none of the boundary cluster.
181
+ function bareErrorBoundaryPath() {
182
+ return path.join(import.meta.dirname, '../compat/client/errors/bare-boundary.ts');
183
+ }
184
+
185
+ // The full in-tree ClientErrorBoundary is emitted only for a route that has an
186
+ // error.*/not-found.* to render at its own position in the tree. A route with
187
+ // neither still needs A boundary - a swallowed hydration throw never reaches the
188
+ // window last resort - but only the bare tier, which re-throws and preserves
189
+ // primitive throws for global-error and carries none of the cluster.
184
190
  function boundaryImportSource(nextCompat?: boolean, boundary?: boolean, builtinNotFound?: boolean) {
185
- if (!nextCompat || !boundary) return '';
191
+ if (!nextCompat) return '';
192
+ if (!boundary) {
193
+ return `import { BareErrorBoundary } from ${JSON.stringify(bareErrorBoundaryPath())};`;
194
+ }
186
195
  return `import { ClientErrorBoundary } from ${JSON.stringify(clientErrorBoundaryPath())};
187
196
  import { softRefreshRoute } from ${JSON.stringify(softRefreshModulePath())};${
188
197
  builtinNotFound ? `\n${builtInNotFoundSource()}` : ''
@@ -211,6 +220,7 @@ function __PNextBuiltInNotFound() {
211
220
  type EntryCompatFacts = Pick<
212
221
  ClientEntryOptions,
213
222
  | 'nextCompat'
223
+ | 'suspense'
214
224
  | 'actions'
215
225
  | 'errorFile'
216
226
  | 'globalErrorFile'
@@ -585,14 +595,11 @@ export interface ClientRuntimeFacts {
585
595
  deferredIslands?: boolean;
586
596
  /** Any island loads on visibility (IntersectionObserver tier). */
587
597
  visibleIslands?: boolean;
588
- /** Any route has an `error.*`/`not-found.*` the in-tree boundary renders. */
589
- errorBoundary?: boolean;
590
598
  /**
591
- * Some boundary route has NO `not-found.*` of its own, so a client-side
592
- * notFound() there falls back to the built-in 404 UI (error-ui.ts). False
593
- * keeps that module out of the graph entirely.
599
+ * Compat runtime must support suspension (Suspense island wrapper, preact/compat). False only when
600
+ * the app's client graph provably never suspends (see clientCompatSurface); undefined outside compat.
594
601
  */
595
- builtinNotFound?: boolean;
602
+ suspense?: boolean;
596
603
  }
597
604
 
598
605
  /** Module specifier the generated route stubs import their runtime from. */
@@ -634,19 +641,19 @@ function dynSharedTableSource(
634
641
  }
635
642
 
636
643
  export function clientRuntimeFacts(
637
- routes: { route: RouteFacts; shell?: boolean; boundary?: boolean; notFound?: boolean }[],
644
+ routes: { route: RouteFacts; shell?: boolean }[],
638
645
  nextCompat?: boolean,
646
+ suspense?: boolean,
639
647
  ): ClientRuntimeFacts {
640
648
  const references = routes.flatMap(({ route }) => route.clientReferences);
641
649
  return {
642
650
  nextCompat,
651
+ suspense: nextCompat ? suspense ?? true : undefined,
643
652
  islands: references.length > 0,
644
653
  page: routes.some(({ route }) => route.client),
645
654
  shell: routes.some(entry => entry.shell),
646
655
  deferredIslands: references.some(reference => reference.dynamic?.ssr === false),
647
656
  visibleIslands: references.some(reference => reference.dynamic?.load === 'visible'),
648
- errorBoundary: routes.some(entry => entry.boundary),
649
- builtinNotFound: routes.some(entry => entry.boundary && !entry.notFound),
650
657
  };
651
658
  }
652
659
 
@@ -661,7 +668,8 @@ interface RouteFacts {
661
668
  * Route-specific values (islands, the client page, shell layouts, error components) arrive as config.
662
669
  */
663
670
  export function clientRuntimeSource(facts: ClientRuntimeFacts) {
664
- const { nextCompat, deferredIslands, errorBoundary, builtinNotFound, page, shell } = facts;
671
+ const { nextCompat, deferredIslands, page, shell } = facts;
672
+ const suspense = nextCompat && facts.suspense !== false;
665
673
  // The shell re-renders the whole document through the client root layout, so
666
674
  // it revives island DOM exactly like an island mount does.
667
675
  const revival = Boolean(facts.islands || shell);
@@ -669,8 +677,7 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
669
677
  import { h, hydrate, render } from 'preact';
670
678
  ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
671
679
  ${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
672
- ${nextCompat ? "import { Suspense } from 'preact/compat';" : ''}
673
- ${boundaryImportSource(nextCompat, errorBoundary, builtinNotFound)}
680
+ ${suspense ? "import { Suspense } from 'preact/compat';" : ''}
674
681
  ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
675
682
  ${revival ? propsParserSource(nextCompat) : ''}
676
683
  ${revival ? islandHelpersSource(facts) : ''}
@@ -685,7 +692,7 @@ export function bootstrapRoute(config) {
685
692
  let pnextIslandMounts;
686
693
  let pnextIslandScan;
687
694
  let pnextVisibleMount;
688
- ${clientBoundaryHelperSource(nextCompat, errorBoundary, builtinNotFound)}
695
+ ${clientBoundaryHelperSource(nextCompat, suspense)}
689
696
  ${mountSource()}
690
697
  ${revival ? hydrateIslandsSource(facts) : ''}
691
698
  ${shell ? hydrateClientShellSource(nextCompat) : ''}
@@ -700,6 +707,7 @@ export function clientEntrySource(options: ClientEntryOptions) {
700
707
  options;
701
708
  const facts: EntryCompatFacts = {
702
709
  nextCompat,
710
+ suspense: options.suspense,
703
711
  actions: options.actions,
704
712
  errorFile,
705
713
  globalErrorFile: options.globalErrorFile,
@@ -765,17 +773,12 @@ ${pageFile ? `import Page from ${JSON.stringify(pageFile)};` : ''}
765
773
  ${imports}
766
774
  ${dynSharedTableSource(nextCompat, options.deferredDynamicHref)}
767
775
  ${routerImportSource(facts)}
776
+ ${boundaryImportSource(nextCompat, hasErrorBoundary(facts), !notFoundFile)}
768
777
 
769
778
  const { mountRoute, remountRoute, unmountRoute } = bootstrapRoute({
770
779
  islands: [
771
780
  ${islandManifest}
772
- ],${pageFile ? '\n Page,' : ''}${shellNames ? `\n shell: [${shellNames.join(', ')}],` : ''}${
773
- nextCompat
774
- ? `\n errorComponent: ${errorFile ? '__PNextRouteErrorComponent' : 'undefined'},\n notFoundComponent: ${
775
- notFoundFile ? '__PNextRouteNotFoundComponent' : 'undefined'
776
- },`
777
- : ''
778
- }
781
+ ],${pageFile ? '\n Page,' : ''}${shellNames ? `\n shell: [${shellNames.join(', ')}],` : ''}${boundaryConfigSource(facts)}
779
782
  });
780
783
  export { mountRoute, remountRoute, unmountRoute };
781
784
 
@@ -785,6 +788,23 @@ if (window.__PNEXT_REGISTER_ENTRY__) window.__PNEXT_REGISTER_ENTRY__(import.meta
785
788
  `;
786
789
  }
787
790
 
791
+ /**
792
+ * The `bootstrapRoute` config fields carrying this route's in-tree boundary: the
793
+ * full tier for a route with an `error`/`not-found` file to render, the bare tier
794
+ * for one with neither. Emitting it per route is what keeps the cluster out of
795
+ * the app-wide runtime chunk.
796
+ */
797
+ function boundaryConfigSource(facts: EntryCompatFacts) {
798
+ if (!facts.nextCompat) return '';
799
+ const base = `\n errorComponent: ${facts.errorFile ? '__PNextRouteErrorComponent' : 'undefined'},\n notFoundComponent: ${
800
+ facts.notFoundFile ? '__PNextRouteNotFoundComponent' : 'undefined'
801
+ },`;
802
+ if (!hasErrorBoundary(facts)) return `${base}\n errorBoundary: BareErrorBoundary,`;
803
+ return `${base}\n errorBoundary: ClientErrorBoundary,\n onBoundaryReset: softRefreshRoute,${
804
+ facts.notFoundFile ? '' : '\n builtInNotFound: __PNextBuiltInNotFound,'
805
+ }`;
806
+ }
807
+
788
808
  function shellClientReferences(
789
809
  clientReferences: ClientReference[],
790
810
  errorFile?: string,
@@ -842,10 +862,11 @@ function visibleDynamicIslandEntrySource(
842
862
  }, onReset: softRefreshRoute }, vnode);
843
863
  }
844
864
  `
845
- : // No error.*/not-found.* to render here: the deferred window last resort
846
- // owns every client throw, so the boundary collapses to the vnode.
865
+ : // No error.*/not-found.* to render here: the bare tier only re-throws
866
+ // (and escalates primitive throws), so the window last resort still owns
867
+ // the outcome without this route carrying the boundary cluster.
847
868
  `function pnextClientBoundary(h, vnode) {
848
- return vnode;
869
+ return h(BareErrorBoundary, null, vnode);
849
870
  }
850
871
  `;
851
872
 
@@ -936,16 +957,14 @@ function mountIsland(root, island) {
936
957
  }
937
958
 
938
959
  async function mountIslandTree(root, island) {
939
- const [{ h, hydrate, render }, { Suspense }, Component] = await Promise.all([
940
- import('preact'),
941
- import('preact/compat'),
960
+ const [{ h, hydrate, render }${facts.suspense !== false ? ', { Suspense }' : ''}, Component] = await Promise.all([
961
+ import('preact'),${facts.suspense !== false ? "\n import('preact/compat')," : ''}
942
962
  island.load(),
943
963
  ]);
944
- islandBoundary = Suspense;
945
- const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
964
+ ${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
946
965
  const source = preservedSource(root, render);
947
966
  const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
948
- const wrapped = h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode));
967
+ const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
949
968
  if (source !== root) adoptPreserved(render, root, wrapped);
950
969
  else mount(hydrate, render, root, wrapped);
951
970
  }`
@@ -1248,27 +1267,23 @@ function wrapInBoundary(vnodeExpression: string, nextCompat?: boolean) {
1248
1267
  }
1249
1268
 
1250
1269
  // Emits the per-route boundary helper referenced by wrapInBoundary. The Suspense wrapper is
1251
- // unconditional under compat - dropping it kills the tree. The error boundary inside it is emitted
1252
- // only for an app that has an error.*/not-found.* for it to render: with none it would just re-throw,
1253
- // and the deferred window last resort already owns that path.
1254
- function clientBoundaryHelperSource(
1255
- nextCompat?: boolean,
1256
- boundary?: boolean,
1257
- builtinNotFound?: boolean,
1258
- ) {
1270
+ // unconditional under compat - dropping it kills the tree. The boundary COMPONENT arrives through
1271
+ // config (always set under compat, full tier or bare) rather than being imported here: this module is
1272
+ // shared app-wide, so naming the full boundary would put the whole cluster - and its built-in 404 UI -
1273
+ // on the first paint of every route in an app where any single route has an error file.
1274
+ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
1259
1275
  if (!nextCompat) return '';
1260
- const inner = boundary
1261
- ? `h(ClientErrorBoundary, {
1276
+ // A suspense-free app (no lazy/use/async components anywhere in its client graph) keeps the error
1277
+ // boundary but drops the Suspense wrapper - and preact/compat with it.
1278
+ const boundary = `h(config.errorBoundary, {
1262
1279
  errorComponent: config.errorComponent,
1263
- notFoundComponent: config.notFoundComponent,${
1264
- builtinNotFound ? '\n builtInNotFound: __PNextBuiltInNotFound,' : ''
1265
- }
1266
- onReset: softRefreshRoute,
1267
- }, vnode)`
1268
- : 'vnode';
1280
+ notFoundComponent: config.notFoundComponent,
1281
+ builtInNotFound: config.builtInNotFound,
1282
+ onReset: config.onBoundaryReset,
1283
+ }, vnode)`;
1269
1284
  return `
1270
1285
  function pnextBoundary(vnode) {
1271
- return h(Suspense, { fallback: null }, ${inner});
1286
+ return ${suspense ? `h(Suspense, { fallback: null }, ${boundary})` : boundary};
1272
1287
  }
1273
1288
  `;
1274
1289
  }
@@ -51,6 +51,13 @@ let warmDispatch: ((id: string, args: unknown[]) => Promise<unknown>) | undefine
51
51
 
52
52
  const loadDispatch = () => dispatchRuntime().then(module => (warmDispatch = module.callAction));
53
53
 
54
+ // Warming is speculative: offline (or any failed chunk fetch) must not surface as an unhandled
55
+ // rejection, which the client error last-resort escalates to the global-error document. A real
56
+ // action call still awaits - and rejects through - loadDispatch.
57
+ const warmDispatchChunk = () => {
58
+ void loadDispatch().catch(() => undefined);
59
+ };
60
+
54
61
  function callAction(id: string, args: unknown[]): Promise<unknown> {
55
62
  return warmDispatch ? warmDispatch(id, args) : loadDispatch().then(call => call(id, args));
56
63
  }
@@ -522,7 +529,7 @@ export function installActionRuntime() {
522
529
  // Hydration is also where the dispatch chunk gets warmed (see loadDispatch):
523
530
  // past first paint, before any click, and `import()` dedups the repeats.
524
531
  window.addEventListener('pnext:hydrated', stripProgressiveActionIds);
525
- window.addEventListener('pnext:hydrated', loadDispatch);
532
+ window.addEventListener('pnext:hydrated', warmDispatchChunk);
526
533
  stripProgressiveActionIds();
527
534
  }
528
535
  installVNodeHook();
@@ -555,7 +555,17 @@ async function refresh() {
555
555
 
556
556
  async function swapDocument(response: Response) {
557
557
  const html = await response.text();
558
- await replaceDocument(html);
558
+ // Next renders an action's notFound() in place, so a robots meta the page
559
+ // itself declared survives it. Our swap rewrites the document: carry the live
560
+ // tag over, replacing the server's fallback noindex when one was injected.
561
+ const robots = document.querySelector('meta[name="robots"]')?.outerHTML;
562
+ const pattern = /<meta[^>]+name=["']robots["'][^>]*\/?>/i;
563
+ const withRobots = robots
564
+ ? pattern.test(html)
565
+ ? html.replace(pattern, robots)
566
+ : html.replace('</head>', `${robots}</head>`)
567
+ : html;
568
+ await replaceDocument(withRobots);
559
569
  }
560
570
 
561
571
  /**
@@ -36,6 +36,13 @@ export interface ActionDiscovery {
36
36
  modules: DiscoveredActionModule[];
37
37
  /** Every source file that is a 'use server' module, for the client alias. */
38
38
  actionFiles: Set<string>;
39
+ /**
40
+ * First-party files that import a node_modules action module. The route-fact scan's client-entry-reason
41
+ * walk never descends into node_modules (see collectSourceFiles), so a route reaching an action only
42
+ * through one of these files needs its 'actions' reason added explicitly - the action file itself never
43
+ * lands in route.sourceFiles the way a first-party action module does.
44
+ */
45
+ actionImporters: Set<string>;
39
46
  }
40
47
 
41
48
  /**
@@ -51,7 +58,7 @@ export async function discoverActions(
51
58
  config: ResolvedConfig,
52
59
  options: { modulePathFor?: (file: string) => string } = {},
53
60
  ): Promise<ActionDiscovery> {
54
- const empty: ActionDiscovery = { modules: [], actionFiles: new Set() };
61
+ const empty: ActionDiscovery = { modules: [], actionFiles: new Set(), actionImporters: new Set() };
55
62
  if (!nextCompatEnabled(config)) return empty;
56
63
 
57
64
  const roots = actionScanRoots(config);
@@ -70,7 +77,13 @@ export async function discoverActions(
70
77
  }
71
78
  }
72
79
  const sources = readSources(files);
73
- for (const file of discoverNodeModuleActionFiles(config, files, sources, seen)) {
80
+ const { files: nodeModuleActionFiles, importers: actionImporters } = discoverNodeModuleActionFiles(
81
+ config,
82
+ files,
83
+ sources,
84
+ seen,
85
+ );
86
+ for (const file of nodeModuleActionFiles) {
74
87
  files.push(file);
75
88
  seen.add(file);
76
89
  }
@@ -100,7 +113,7 @@ export async function discoverActions(
100
113
  actionFiles.add(file);
101
114
  }
102
115
 
103
- return { modules, actionFiles };
116
+ return { modules, actionFiles, actionImporters };
104
117
  }
105
118
 
106
119
  /**
@@ -159,8 +172,9 @@ function discoverNodeModuleActionFiles(
159
172
  sourceFiles: string[],
160
173
  sources: Map<string, string>,
161
174
  seen: Set<string>,
162
- ) {
175
+ ): { files: string[]; importers: Set<string> } {
163
176
  const actionFiles = new Set<string>();
177
+ const importers = new Set<string>();
164
178
  // A bare specifier resolves purely from the importer's resolution root, so
165
179
  // both the resolution and the 'use server' verdict are cached app-wide, and
166
180
  // each is derived only for the specifiers a file actually reaches.
@@ -205,10 +219,13 @@ function discoverNodeModuleActionFiles(
205
219
  if (!reachable) continue;
206
220
  for (const specifier of importSpecifiers(source, file)) {
207
221
  const target = actionTarget(resolveRoot, file, specifier);
208
- if (target) actionFiles.add(target);
222
+ if (target) {
223
+ actionFiles.add(target);
224
+ importers.add(file);
225
+ }
209
226
  }
210
227
  }
211
- return [...actionFiles];
228
+ return { files: [...actionFiles], importers };
212
229
  }
213
230
 
214
231
  // Every string literal in an import position: `from 'x'`, `import 'x'`,
@@ -14,7 +14,11 @@
14
14
 
15
15
  import path from 'node:path';
16
16
  import { readFileSync } from 'node:fs';
17
- import { parseSync } from 'oxc-parser';
17
+ // Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
18
+ const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ loadNative(() => require('oxc-parser') as typeof import('oxc-parser')).parseSync(...args);
21
+ import { loadNative } from '../../utils/native-require';
18
22
  import { rewriteFacts, type ImportBinding } from '../../resolve/scan-facts';
19
23
  import { spliceSource } from '../../dev/module-transform';
20
24
  import { resolvePackageSpecifier, resolveVendorPackageSpecifier } from '../../resolve/imports';
@@ -17,7 +17,8 @@ import { createHash } from 'node:crypto';
17
17
  import { existsSync, statSync } from 'node:fs';
18
18
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
19
19
  import path from 'node:path';
20
- import { build, type Plugin } from 'esbuild';
20
+ import type { Plugin } from 'esbuild';
21
+ import { build } from '../../utils/esbuild';
21
22
  import { publicEnvDefines, type ResolvedConfig } from '../../config';
22
23
  import { getAssetExtensions } from '../../extensions';
23
24
 
@@ -0,0 +1,32 @@
1
+ // Bare tier of the client error boundary (see ./error-boundary): a route with no error.*/not-found.*
2
+ // still needs one so hydration throws reach the window last resort (not preact's diff) and primitive
3
+ // throws keep their raw value en route to global-error. Exactly those two behaviors, ~0.5 KB raw.
4
+ import { Component, type ComponentChildren } from 'preact';
5
+ import { normalizeBoundaryError } from './primitive-throw';
6
+
7
+ interface BareBoundaryState {
8
+ error: unknown;
9
+ hasError: boolean;
10
+ }
11
+
12
+ export class BareErrorBoundary extends Component<{ children?: unknown }, BareBoundaryState> {
13
+ state: BareBoundaryState = { error: null, hasError: false };
14
+
15
+ static getDerivedStateFromError(error: unknown): BareBoundaryState {
16
+ return { error: normalizeBoundaryError(error), hasError: true };
17
+ }
18
+
19
+ render(): ComponentChildren {
20
+ if (!this.state.hasError) return this.props.children;
21
+ const { error } = this.state;
22
+ // A primitive would arrive at the window handler as a synthesized Error, so
23
+ // escalate it to global-error directly (deferred tier) with its raw value.
24
+ // Everything else re-throws: unmarked, so the window last resort still sees
25
+ // a genuinely uncaught error and drives it.
26
+ if (error === null || typeof error !== 'object') {
27
+ void import('./global-error').then(module => module.escalateToGlobalError(error));
28
+ return null;
29
+ }
30
+ throw error as Error;
31
+ }
32
+ }
@@ -19,7 +19,7 @@
19
19
  import { Component, h, type ComponentChildren, type ComponentType } from 'preact';
20
20
  import { isControlFlowError, handleControlFlowError } from './control-flow';
21
21
  import { isNotFoundError } from '../../../api/navigation';
22
- import { untagPrimitiveThrow } from './primitive-throw';
22
+ import { normalizeBoundaryError } from './primitive-throw';
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // KNOWN GAP (preact core, not fully fixable from an options hook): a CLIENT
@@ -72,20 +72,6 @@ export function toClientError(value: unknown): Error {
72
72
  return new Error(String(value));
73
73
  }
74
74
 
75
- function normalizeBoundaryError(error: unknown): unknown {
76
- // Preact-core interop tag (see primitive-throw.ts): a `throw undefined`/`null`
77
- // arrives here re-thrown as a tagged Error so it survived preact's own
78
- // `e.then` suspense check without crashing. Unwrap back to the raw value.
79
- const untagged = untagPrimitiveThrow(error);
80
- if (untagged !== error) return untagged;
81
- if (error instanceof Error) {
82
- // Fallback for any crash that slips through un-tagged (defense in depth).
83
- if (error.message === "Cannot read properties of undefined (reading 'then')") return undefined;
84
- if (error.message === "Cannot read properties of null (reading 'then')") return null;
85
- }
86
- return error;
87
- }
88
-
89
75
  // A boundary that catches (or escalates) a real error marks it here so the
90
76
  // window-listener last resort (install.ts) knows the error was already handled
91
77
  // by the nearest-boundary path and must NOT also drive it to global-error.
@@ -34,6 +34,22 @@ export function untagPrimitiveThrow(error: unknown): unknown {
34
34
  return isTaggedPrimitiveThrow(error) ? error[RAW_VALUE] : error;
35
35
  }
36
36
 
37
+ /**
38
+ * What a boundary hands its error UI: the raw value behind a tagged primitive
39
+ * throw, with a message-shaped fallback for a crash that slipped through untagged.
40
+ * Lives here rather than in a boundary so both boundary tiers share it without
41
+ * the bare one pulling in the full cluster.
42
+ */
43
+ export function normalizeBoundaryError(error: unknown): unknown {
44
+ const untagged = untagPrimitiveThrow(error);
45
+ if (untagged !== error) return untagged;
46
+ if (error instanceof Error) {
47
+ if (error.message === "Cannot read properties of undefined (reading 'then')") return undefined;
48
+ if (error.message === "Cannot read properties of null (reading 'then')") return null;
49
+ }
50
+ return error;
51
+ }
52
+
37
53
  /**
38
54
  * A value preact's diff.js catch block would crash on when narrowing for a suspending thenable
39
55
  * (`e.then`): null and undefined - the only two JS values where the property access itself throws.
@@ -1,7 +1,7 @@
1
1
  // Per-link navigation pending state for `useLinkStatus()` (next/link). A single link is "pending" at a
2
2
  // time: the last one whose click started an in-flight soft navigation. It clears on completion, and on
3
3
  // any navigation started elsewhere - all of which broadcast a location change.
4
- import { createContext } from 'preact/compat';
4
+ import { createContext } from 'preact';
5
5
  import { softNavigate } from '../../api/router';
6
6
  import { locationListeners, onNavigationStart } from '../../api/router/events';
7
7
  import { patchHistory } from '../../api/client-navigation';
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { createRequire } from 'node:module';
13
13
  import path from 'node:path';
14
+ import { loadNative } from '../../utils/native-require';
14
15
  import { getNextConfig } from '../next/config-loader';
15
16
 
16
17
  interface LightningModule {
@@ -37,7 +38,7 @@ function loadLightningcss(root: string): LightningModule | null {
37
38
  let loaded: LightningModule | null = null;
38
39
  try {
39
40
  const require = createRequire(path.join(root, 'package.json'));
40
- loaded = require('lightningcss') as LightningModule;
41
+ loaded = loadNative(() => require('lightningcss') as LightningModule);
41
42
  } catch {
42
43
  loaded = null;
43
44
  }
@@ -116,12 +116,13 @@ function cssModuleMapping(file: string, root: string) {
116
116
  export function nextCssSource(file: string, root: string) {
117
117
  const resolved = path.resolve(file);
118
118
  const parts = resolved.split(path.sep);
119
+ // Matched on the cache segments alone: the out dir above them is configurable
120
+ // (distDir) and gains a `dev/` level under a dev server.
119
121
  const buildClientIndex = parts.findIndex(
120
- (part, index) =>
121
- part === '.pnext' && parts.slice(index + 1, index + 4).join('/') === 'cache/server/build-client',
122
+ (_, index) => parts.slice(index, index + 3).join('/') === 'cache/server/build-client',
122
123
  );
123
124
  if (buildClientIndex !== -1) {
124
- const source = path.join(root, ...parts.slice(buildClientIndex + 4));
125
+ const source = path.join(root, ...parts.slice(buildClientIndex + 3));
125
126
  if (existsSync(source)) return source;
126
127
  }
127
128
  const generatedIndex = parts.lastIndexOf('pnext-pages-compat');
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { existsSync, mkdirSync } from 'node:fs';
21
21
  import path from 'node:path';
22
- import { build } from 'esbuild';
22
+ import { build } from '../../utils/esbuild';
23
23
  import type { ResolvedConfig } from '../../config';
24
24
  import { getNextConfig } from '../next/config-loader';
25
25
  import { instrumentationLookupRoots } from './instrumentation';
@@ -16,7 +16,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } fr
16
16
  import path from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { createRequire } from 'node:module';
19
- import { build, type Plugin } from 'esbuild';
19
+ import type { Plugin } from 'esbuild';
20
+ import { build } from '../../utils/esbuild';
20
21
  import type { ResolvedConfig } from '../../config';
21
22
  import { resolveExternalLoadTarget } from '../../resolve/imports';
22
23
  import { compatAliases, reactServerLayerAliases } from '../index';
@@ -136,7 +137,9 @@ async function importInstrumentation(
136
137
  suffix = '',
137
138
  ): Promise<InstrumentationModule> {
138
139
  const root = config.root;
139
- const outDir = path.join(root, '.pnext', 'instrumentation');
140
+ // Under config.outPath, so a dev server's scratch bundle lives in its own
141
+ // subtree and a concurrent build never removes it mid-import.
142
+ const outDir = path.join(config.outPath, 'instrumentation');
140
143
  mkdirSync(outDir, { recursive: true });
141
144
  const outfile = path.join(outDir, `instrumentation.${Date.now()}${suffix}.mjs`);
142
145
  const tsconfig = path.join(root, 'tsconfig.json');
@@ -17,7 +17,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
17
17
  import { createRequire } from 'node:module';
18
18
  import path from 'node:path';
19
19
  import { pathToFileURL } from 'node:url';
20
- import { build } from 'esbuild';
20
+ import { build } from '../../utils/esbuild';
21
21
  import { setConfig } from './config';
22
22
  import { setCacheComponents } from '../../ppr';
23
23
  import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from './constants';
@@ -131,10 +131,24 @@ function findTsconfig(root: string): string | undefined {
131
131
  * The emitted file is written next to the config so Node resolves the app's
132
132
  * node_modules and relative assets from the original location.
133
133
  */
134
- async function importConfigModule(configPath: string, root: string): Promise<NextConfigExport> {
135
- const outDir = path.join(root, '.pnext', 'config');
136
- mkdirSync(outDir, { recursive: true });
134
+ async function importConfigModule(
135
+ configPath: string,
136
+ root: string,
137
+ dev: boolean,
138
+ ): Promise<NextConfigExport> {
137
139
  const cjs = cjsConfigFormat(configPath, root);
140
+ if (process.env.PNEXT_CONFIG_FAST !== '0' && selfContainedConfig(configPath)) {
141
+ // Nothing left for the bundle pass to do, and Bun loads every supported config format natively -
142
+ // so skip it. Worth a special case because this is the common shape and the pass is not cheap:
143
+ // it loads esbuild and spawns its service child on the dev/start boot critical path.
144
+ if (cjs) return createRequire(pathToFileURL(configPath))(configPath) as NextConfigExport;
145
+ const module = (await import(pathToFileURL(configPath).href)) as { default?: NextConfigExport };
146
+ return module.default ?? {};
147
+ }
148
+ // Dev keeps its scratch bundle in its own subtree; a concurrent build wipes
149
+ // only the build-owned `config/`.
150
+ const outDir = path.join(root, '.pnext', ...(dev ? ['dev'] : []), 'config');
151
+ mkdirSync(outDir, { recursive: true });
138
152
  // Content-addressed, not timestamped: a per-boot name leaked one bundle per
139
153
  // dev start and put a path that changes every boot into the module graph,
140
154
  // which renamed every artifact that could reach it.
@@ -193,6 +207,20 @@ async function importConfigModule(configPath: string, root: string): Promise<Nex
193
207
  }
194
208
  }
195
209
 
210
+ /**
211
+ * Whether the config reaches nothing the bundle pass provides: `import`/`require` are its only routes
212
+ * to other modules and `__dirname`/`__filename` the only globals the pass defines. A false positive
213
+ * (token in a comment/string) just runs the bundle pass we would have run anyway.
214
+ */
215
+ function selfContainedConfig(configPath: string): boolean {
216
+ try {
217
+ const source = readFileSync(configPath, 'utf8').replaceAll('import.meta', '');
218
+ return !/\b(?:import|require|__dirname|__filename)\b/.test(source);
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
196
224
  function cjsConfigFormat(configPath: string, root: string): boolean {
197
225
  if (configPath.endsWith('.cjs') || configPath.endsWith('.cts')) return true;
198
226
  if (!configPath.endsWith('.js')) return false;
@@ -538,7 +566,7 @@ export async function loadNextConfig(
538
566
  }
539
567
  let exported: NextConfigExport;
540
568
  try {
541
- exported = await importConfigModule(configPath, root);
569
+ exported = await importConfigModule(configPath, root, Boolean(options.dev));
542
570
  } catch (error) {
543
571
  // A next.config that pulls in an optional wrapper dependency not installed
544
572
  // in this app (e.g. @next/mdx when deps are declared but not installed)
@@ -24,11 +24,15 @@ export default function dynamic<Props extends object = object>(
24
24
  const Lazy = lazy(() => loader().then(module => ({ default: componentFromModule(module) })));
25
25
  const DynamicComponent = ((props: Props) => {
26
26
  const Loading = options.loading;
27
- const content = h(Suspense, { fallback: Loading ? h(Loading, props) : null }, h(Lazy, props));
28
- return options.ssr === false
29
- ? h('pnext-dynamic', { style: { display: 'contents' } },
30
- (!process.browser && typeof window === 'undefined') ? null : content)
31
- : content;
27
+ // ssr:false renders nothing on the server, so the client mount is a *hydration* whose only child
28
+ // suspends. preact/compat nulls a hydrating vnode's type when it unmounts, and Suspense reuses the
29
+ // same props.children vnode on its resolve re-render - the poisoned element then renders as text.
30
+ // The hooks-based core component loads without suspending, so that boundary is never entered.
31
+ if (options.ssr === false) {
32
+ return h('pnext-dynamic', { style: { display: 'contents' } },
33
+ (!process.browser && typeof window === 'undefined') ? null : h(coreDynamic, props));
34
+ }
35
+ return h(Suspense, { fallback: Loading ? h(Loading, props) : null }, h(Lazy, props));
32
36
  }) as DynamicWithReference<Props>;
33
37
 
34
38
  DynamicComponent[dynamicReferenceSymbol] = coreDynamic[dynamicReferenceSymbol];