@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.
- package/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +60 -21
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/register/segment.ts +16 -6
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/dynamic/source.ts +36 -27
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +239 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +26 -41
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
package/src/client/entry.ts
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
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
|
|
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
|
-
*
|
|
592
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
${
|
|
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,
|
|
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
|
|
846
|
-
//
|
|
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
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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 }, ${
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
(
|
|
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 +
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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];
|