@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
|
@@ -918,6 +918,11 @@ const segmentPrefetchInterceptor: RequestInterceptor = async (request, ctx) => {
|
|
|
918
918
|
'x-nextjs-postponed': '2',
|
|
919
919
|
'x-nextjs-stale-time': String(staleTime),
|
|
920
920
|
...(isStatic ? { 'x-nextjs-prerender': '1' } : {}),
|
|
921
|
+
// Announce head outlining on the tree HEADERS too, so the client
|
|
922
|
+
// can keep head-before-body order without reading the tree body.
|
|
923
|
+
...(selection?.route.pprMetadata === true
|
|
924
|
+
? { 'x-pnext-head-outlined': headFetchedFirst(selection) ? 'first' : '1' }
|
|
925
|
+
: {}),
|
|
921
926
|
'x-nextjs-deployment-id': deploymentId(),
|
|
922
927
|
// A static route's baked tree is CDN-cacheable like Next's
|
|
923
928
|
// prerendered payloads; the per-variant `_rsc` cache-buster keys
|
|
@@ -949,12 +954,17 @@ const segmentPrefetchInterceptor: RequestInterceptor = async (request, ctx) => {
|
|
|
949
954
|
? { headOutlined: true, ...(headFetchedFirst(selection) ? { headFirst: true } : {}) }
|
|
950
955
|
: {}),
|
|
951
956
|
});
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
957
|
+
const treeResponse = treePrefetchResponse(payload, {
|
|
958
|
+
format: url.searchParams.has('_rsc') ? 'flight' : 'json',
|
|
959
|
+
});
|
|
960
|
+
// Mirror the payload's head-outlining on the HEADERS (see baked branch).
|
|
961
|
+
if (selection?.route.pprMetadata === true) {
|
|
962
|
+
treeResponse.headers.set(
|
|
963
|
+
'x-pnext-head-outlined',
|
|
964
|
+
headFetchedFirst(selection) ? 'first' : '1',
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
return withRewriteHeaders(withDeploymentId(treeResponse), rewriteHeaders);
|
|
958
968
|
};
|
|
959
969
|
|
|
960
970
|
/**
|
package/src/config.ts
CHANGED
|
@@ -14,7 +14,12 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
|
14
14
|
publicDir: 'public';
|
|
15
15
|
appPath: string;
|
|
16
16
|
publicPath: string;
|
|
17
|
+
/** Where this process writes and serves from: the out root, or `<outRoot>/dev` in dev. */
|
|
17
18
|
outPath: string;
|
|
19
|
+
/** The out root itself (`.pnext`), identical in dev and build. */
|
|
20
|
+
outRootPath: string;
|
|
21
|
+
/** Generated types — always under the out root, because tsconfig points at it. */
|
|
22
|
+
typesPath: string;
|
|
18
23
|
};
|
|
19
24
|
|
|
20
25
|
const defaultConfig = {
|
|
@@ -88,6 +93,7 @@ export async function loadConfig(
|
|
|
88
93
|
const sourceOverrides = stripUndefined(await resolveConfigSource(config, root, options));
|
|
89
94
|
const merged = { ...defaultConfig, ...config, ...sourceOverrides };
|
|
90
95
|
const appPath = await resolveAppPath(root, config);
|
|
96
|
+
const outRootPath = path.resolve(root, merged.outDir);
|
|
91
97
|
const workspaceRoot = canonicalRoot(
|
|
92
98
|
config.workspaceRoot
|
|
93
99
|
? path.resolve(root, config.workspaceRoot)
|
|
@@ -113,10 +119,18 @@ export async function loadConfig(
|
|
|
113
119
|
workspaceRoot,
|
|
114
120
|
appPath,
|
|
115
121
|
publicPath: path.resolve(root, 'public'),
|
|
116
|
-
|
|
122
|
+
// Dev owns `<outRoot>/dev` exclusively so a concurrent `pnext build` — which
|
|
123
|
+
// wipes its own outputs under the out root — can never pull a running dev
|
|
124
|
+
// server's cache, manifest or assets out from under it.
|
|
125
|
+
outPath: options.dev ? path.join(outRootPath, devOutSegment) : outRootPath,
|
|
126
|
+
outRootPath,
|
|
127
|
+
typesPath: path.join(outRootPath, 'types'),
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
/** Dev's private subtree under the out root. Build never touches it. */
|
|
132
|
+
export const devOutSegment = 'dev';
|
|
133
|
+
|
|
120
134
|
// Relative-import resolution realpaths the importing file (so a materialized shim resolves `./sibling`
|
|
121
135
|
// against real source), so every containment check against root/workspaceRoot compares realpaths.
|
|
122
136
|
// Canonicalize the roots to match - otherwise an app served through a symlinked directory (a macOS
|
package/src/css/build.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import type { Plugin } from 'esbuild';
|
|
5
|
+
import { build } from '../utils/esbuild';
|
|
5
6
|
import { ensureDir, readText } from '../utils/fs';
|
|
6
7
|
import { postcssConfigFile, runPostcss } from './postcss';
|
|
7
8
|
import { extraPageExtensions, getCssExtensions } from '../extensions';
|
|
@@ -19,7 +20,8 @@ interface Pending {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// Bun's Worker carries node's ref/unref; the DOM lib typing does not.
|
|
22
|
-
|
|
23
|
+
// Bun's terminate() resolves once the thread is gone (lib.dom types it void).
|
|
24
|
+
type CssWorker = Worker & { ref(): void; unref(): void; terminate(): Promise<void> };
|
|
23
25
|
|
|
24
26
|
let worker: CssWorker | undefined;
|
|
25
27
|
let workerFailed = false;
|
|
@@ -76,6 +78,15 @@ function send(request: (id: number) => CssWorkerRequest) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/** Run the app's postcss pipeline over a built stylesheet, off the event loop. */
|
|
81
|
+
// A live worker thread at process.exit races Bun's teardown (seen flipping the
|
|
82
|
+
// exit code to 1 on Linux after a fully successful build); one-shot commands
|
|
83
|
+
// terminate it explicitly before exiting.
|
|
84
|
+
export async function stopCssWorker(): Promise<void> {
|
|
85
|
+
const active = worker;
|
|
86
|
+
worker = undefined;
|
|
87
|
+
if (active) await Promise.resolve(active.terminate());
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
export async function runPostcssOffThread(
|
|
80
91
|
config: Pick<ResolvedConfig, 'root' | 'outPath'>,
|
|
81
92
|
cssFile: string,
|
package/src/dev/imports.ts
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import { existsSync, readFileSync, type Dirent } from 'node:fs';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { copyFile, mkdir, readdir, symlink, writeFile } from 'node:fs/promises';
|
|
7
|
-
import {
|
|
7
|
+
import type { OnResolveResult, Plugin } from 'esbuild';
|
|
8
|
+
import { build } from '../utils/esbuild';
|
|
8
9
|
import { drainPreplanBuilds, vendorTraceEnabled, vendorTraceRow } from '../runtime/vendor';
|
|
9
10
|
import {
|
|
10
11
|
clientReferenceExportNames,
|
|
@@ -756,7 +757,10 @@ function devRouteBundlePlugin(config: ResolvedConfig, route: RouteManifestEntry)
|
|
|
756
757
|
conditionTarget,
|
|
757
758
|
reactServerLayer: true,
|
|
758
759
|
externalLoadTarget: externalLoadTargetForConditionTarget(conditionTarget),
|
|
759
|
-
|
|
760
|
+
// Always stub 'use client' files, compat or not: a client module reached transitively through a
|
|
761
|
+
// server module would otherwise inline as an untagged bundle-local copy - markClientReferences then
|
|
762
|
+
// tags a different instance and the component never gets its hydration island.
|
|
763
|
+
stubClientImports: true,
|
|
760
764
|
rewriteExternalServerImports: true,
|
|
761
765
|
bundleExternalPackages: reactCompatEnabled(config),
|
|
762
766
|
};
|
|
@@ -2174,15 +2178,40 @@ function devServerCacheKey(config: ResolvedConfig, file: string) {
|
|
|
2174
2178
|
return path.join('external', `${hash}${path.extname(file) || '.js'}`);
|
|
2175
2179
|
}
|
|
2176
2180
|
|
|
2181
|
+
// Bundle names memoized per (route, entries) so a warm request re-derives
|
|
2182
|
+
// nothing; any save clears the whole map (see clearDevRouteBundleKeys).
|
|
2183
|
+
const routeBundleKeys = new Map<string, Promise<string>>();
|
|
2184
|
+
|
|
2185
|
+
/** A save may rename any route bundle: drop every memoized name. */
|
|
2186
|
+
export function clearDevRouteBundleKeys() {
|
|
2187
|
+
routeBundleKeys.clear();
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2177
2190
|
// The route bundle inlines the route file and its layouts, so its name carries
|
|
2178
2191
|
// every source graph it bundles.
|
|
2179
|
-
|
|
2192
|
+
function devRouteBundlePath(
|
|
2180
2193
|
config: ResolvedConfig,
|
|
2181
2194
|
route: RouteManifestEntry,
|
|
2182
2195
|
layoutFiles: string[],
|
|
2183
2196
|
) {
|
|
2184
|
-
const graph = devModuleGraph(config);
|
|
2185
2197
|
const entries = uniqueFiles([route.file, ...layoutFiles]);
|
|
2198
|
+
const memoKey = `${route.id}\0${entries.join('\0')}`;
|
|
2199
|
+
const memoized = routeBundleKeys.get(memoKey);
|
|
2200
|
+
if (memoized) return memoized;
|
|
2201
|
+
const next = computeDevRouteBundlePath(config, route, entries).catch(error => {
|
|
2202
|
+
routeBundleKeys.delete(memoKey);
|
|
2203
|
+
throw error;
|
|
2204
|
+
});
|
|
2205
|
+
routeBundleKeys.set(memoKey, next);
|
|
2206
|
+
return next;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
async function computeDevRouteBundlePath(
|
|
2210
|
+
config: ResolvedConfig,
|
|
2211
|
+
route: RouteManifestEntry,
|
|
2212
|
+
entries: string[],
|
|
2213
|
+
) {
|
|
2214
|
+
const graph = devModuleGraph(config);
|
|
2186
2215
|
const cached = await cachedRouteBundlePath(config, route.id, graph.graphKey, entries);
|
|
2187
2216
|
if (cached) return cached;
|
|
2188
2217
|
const hashes = await Promise.all(entries.map(file => graph.graphHash(file)));
|
|
@@ -2356,7 +2385,7 @@ function assetContextEntry(
|
|
|
2356
2385
|
'pages',
|
|
2357
2386
|
'public',
|
|
2358
2387
|
'node_modules',
|
|
2359
|
-
path.basename(config.
|
|
2388
|
+
path.basename(config.outRootPath),
|
|
2360
2389
|
]).has(name);
|
|
2361
2390
|
}
|
|
2362
2391
|
|
package/src/dev/module-cache.ts
CHANGED
|
@@ -25,6 +25,24 @@ export function devHeadTrimEnabled(): boolean {
|
|
|
25
25
|
return process.env.PNEXT_HEAD_TRIM !== '0';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// While a recursive fs watcher is reporting every save, the memoized graph is
|
|
29
|
+
// authoritative and a warm request needs no per-module re-stat; the dev server
|
|
30
|
+
// marks its outPath once its watch roots are up and clears it if any root
|
|
31
|
+
// fails. Per cache root, so other processes' caches (tests, one-off renders)
|
|
32
|
+
// keep the stat-based freshness they rely on.
|
|
33
|
+
// Bisect seam: PNEXT_DEV_WATCH_FRESHNESS=0 restores the per-request re-stat.
|
|
34
|
+
const watcherFreshRoots = new Set<string>();
|
|
35
|
+
|
|
36
|
+
export function setDevWatcherFreshness(outPath: string, trusted: boolean) {
|
|
37
|
+
if (trusted) watcherFreshRoots.add(cacheRoot(outPath));
|
|
38
|
+
else watcherFreshRoots.delete(cacheRoot(outPath));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function watcherFreshnessTrusted(root: string) {
|
|
42
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
43
|
+
return watcherFreshRoots.has(root) && process.env.PNEXT_DEV_WATCH_FRESHNESS !== '0';
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/** Files whose bytes are not source: hashed from stat, never read or scanned. */
|
|
29
47
|
const BINARY_SOURCE = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|otf|eot|mp[34]|webm|pdf)$/i;
|
|
30
48
|
|
|
@@ -289,6 +307,7 @@ function createDevModuleCache(
|
|
|
289
307
|
* everywhere), and the disk is the only authority on what a module contains.
|
|
290
308
|
*/
|
|
291
309
|
async function refresh(file: string) {
|
|
310
|
+
if (watcherFreshnessTrusted(root)) return;
|
|
292
311
|
const reachable = closures.get(file);
|
|
293
312
|
if (!reachable) return;
|
|
294
313
|
if (passes > 0 && refreshedThisPass.has(file)) return;
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
//
|
|
5
5
|
// esbuild's bundler is still load-bearing for a handful of shapes (below); `transformBailReason` sniffs
|
|
6
6
|
// those out per file and the caller falls back to the esbuild build for them. Same pipeline in dev and prod.
|
|
7
|
-
import {
|
|
7
|
+
import type { TransformOptions } from 'oxc-transform';
|
|
8
|
+
|
|
9
|
+
// Lazy: the native binding costs ~3.6 MB RSS and a prod server never transforms.
|
|
10
|
+
const transformSync: typeof import('oxc-transform').transformSync = (...args) =>
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
loadNative(() => require('oxc-transform') as typeof import('oxc-transform')).transformSync(...args);
|
|
13
|
+
import { loadNative } from '../utils/native-require';
|
|
8
14
|
import { isCommonJsModuleSource } from '../resolve/imports';
|
|
9
15
|
|
|
10
16
|
/** Why a source cannot take the transform path — one per esbuild-only shape. */
|
package/src/dev/server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { readFile, readdir, rm, stat, watch } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -83,6 +83,7 @@ import { clearResolverFsCache } from '../resolve/engine';
|
|
|
83
83
|
import { clearAppTreeResolutions, workspacePackageRoots } from '../resolve/imports';
|
|
84
84
|
import type { RouteManifestEntry, RouteParamValue } from '../types';
|
|
85
85
|
import {
|
|
86
|
+
clearDevRouteBundleKeys,
|
|
86
87
|
devClientModuleHref,
|
|
87
88
|
devModuleGraph,
|
|
88
89
|
devRouteModuleLoaders,
|
|
@@ -90,7 +91,7 @@ import {
|
|
|
90
91
|
warmDevModulePipeline,
|
|
91
92
|
} from './imports';
|
|
92
93
|
import { ssrClientReference } from '../client/reference';
|
|
93
|
-
import { cacheRoot } from './module-cache';
|
|
94
|
+
import { cacheRoot, setDevWatcherFreshness } from './module-cache';
|
|
94
95
|
import { markBoot } from '../cli/boot-trace';
|
|
95
96
|
|
|
96
97
|
interface DevServerOptions {
|
|
@@ -126,8 +127,15 @@ const indexedClientDirs = new Set<string>();
|
|
|
126
127
|
// Layout chains per route file: findLayouts + an existsSync per level, which a
|
|
127
128
|
// warm request would otherwise redo every time.
|
|
128
129
|
const routeLayoutFiles = new Map<string, string[]>();
|
|
130
|
+
// Keyed by app (outPath), not by asset path or route id alone: several dev
|
|
131
|
+
// servers for different apps share one process in tests and monorepo tooling,
|
|
132
|
+
// and a bare `/assets/global.css` or route id collides between them.
|
|
129
133
|
const assetBuilds = new Map<string, Promise<unknown>>();
|
|
130
134
|
const routeCacheKeys = new Map<string, Promise<string>>();
|
|
135
|
+
const appKey = (outPath: string, key: string) => `${outPath}\0${key}`;
|
|
136
|
+
function clearAppKeyed(map: Map<string, unknown>, outPath: string) {
|
|
137
|
+
for (const key of map.keys()) if (key.startsWith(`${outPath}\0`)) map.delete(key);
|
|
138
|
+
}
|
|
131
139
|
// Routes whose bundles this process has already compiled, so the "Compiling"
|
|
132
140
|
// banner prints once per cold route (like Next.js) and not on warm hits.
|
|
133
141
|
// Cleared on reload.
|
|
@@ -156,8 +164,9 @@ function invalidateDevCaches(config: ResolvedConfig, changed: string[], structur
|
|
|
156
164
|
// Route CSS is derived from the whole source tree (Tailwind scans it), and
|
|
157
165
|
// the client cache key is a content hash whose inputs just moved.
|
|
158
166
|
clearGlobalCssSourceCache();
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
clearDevRouteBundleKeys();
|
|
168
|
+
clearAppKeyed(assetBuilds, config.outPath);
|
|
169
|
+
clearAppKeyed(routeCacheKeys, config.outPath);
|
|
161
170
|
indexedClientDirs.clear();
|
|
162
171
|
compiledRoutes.clear();
|
|
163
172
|
// Long sessions accumulate one generation per save; sweep past the keep
|
|
@@ -213,6 +222,11 @@ async function removeLeakedStaleCaches(outPath: string) {
|
|
|
213
222
|
|
|
214
223
|
export async function startDevServer(options: DevServerOptions) {
|
|
215
224
|
const { config, port, hostname } = options;
|
|
225
|
+
// FSEvents replays writes that landed just before the watch started, so a server
|
|
226
|
+
// booted right after a checkout or scaffold reloads itself once for files it has
|
|
227
|
+
// already read. Nothing is compiled before the watcher, so anything this old is
|
|
228
|
+
// already in hand.
|
|
229
|
+
const bootTime = Date.now();
|
|
216
230
|
// Spawn the CSS worker first: Tailwind's cold boot then runs off the event
|
|
217
231
|
// loop, alongside everything below, instead of on the first page's stylesheet.
|
|
218
232
|
warmCssPipeline(config, { dev: true });
|
|
@@ -260,6 +274,9 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
260
274
|
// registry + client-stub set that startup/reload armed inline stay current.
|
|
261
275
|
publishRuntime();
|
|
262
276
|
const clients = new Set<DevEventClient>();
|
|
277
|
+
// Bumped on every rebuild broadcast and stamped on each stream's `ready`, so a
|
|
278
|
+
// client that reconnects (bfcache restore) can tell it missed one and reload.
|
|
279
|
+
let generation = 0;
|
|
263
280
|
let watcher: DevWatcher | undefined;
|
|
264
281
|
/** Requests currently being served — background work defers to them. */
|
|
265
282
|
let inFlight = 0;
|
|
@@ -306,6 +323,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
306
323
|
// table, the proxy or the client bundles - only the built CSS assets. The page swaps its <link>s
|
|
307
324
|
// in place instead of navigating.
|
|
308
325
|
if (isCssOnlyChange(change.files)) {
|
|
326
|
+
generation++;
|
|
309
327
|
broadcast(clients, 'css-update');
|
|
310
328
|
return;
|
|
311
329
|
}
|
|
@@ -322,9 +340,20 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
322
340
|
void typegenInBackground();
|
|
323
341
|
watchedFactsVersion = -1;
|
|
324
342
|
syncWatchRoots();
|
|
343
|
+
generation++;
|
|
325
344
|
broadcast(clients, 'reload');
|
|
326
345
|
}
|
|
327
346
|
|
|
347
|
+
// A save's visibility must not wait out the coalescing window: the moment the
|
|
348
|
+
// event lands, forget the changed file's graph and the memoized names derived
|
|
349
|
+
// from it, so a request racing the debounced reload already compiles fresh.
|
|
350
|
+
// The heavy reload work (proxy, typegen, route scan, CSS) stays debounced.
|
|
351
|
+
function eagerInvalidate(file: string) {
|
|
352
|
+
devModuleGraph(config).invalidate([file]);
|
|
353
|
+
clearDevRouteBundleKeys();
|
|
354
|
+
clearAppKeyed(routeCacheKeys, config.outPath);
|
|
355
|
+
}
|
|
356
|
+
|
|
328
357
|
// Watch roots outside app/ come from route sourceFiles, which only exist once
|
|
329
358
|
// a route has resolved its deferred facts — so they are re-derived whenever
|
|
330
359
|
// another route materializes (its first compile), not once at boot.
|
|
@@ -332,7 +361,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
332
361
|
function syncWatchRoots() {
|
|
333
362
|
if (watchedFactsVersion === routeFactsVersion()) return;
|
|
334
363
|
watchedFactsVersion = routeFactsVersion();
|
|
335
|
-
watcher = refreshWatcher(config, routes, watcher, reload);
|
|
364
|
+
watcher = refreshWatcher(config, routes, watcher, reload, bootTime, eagerInvalidate);
|
|
336
365
|
}
|
|
337
366
|
|
|
338
367
|
syncWatchRoots();
|
|
@@ -430,7 +459,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
430
459
|
|
|
431
460
|
if (url.pathname === '/__pnext/events') {
|
|
432
461
|
server.timeout(request, 0);
|
|
433
|
-
return finish(eventStream(clients));
|
|
462
|
+
return finish(eventStream(clients, generation));
|
|
434
463
|
}
|
|
435
464
|
|
|
436
465
|
const assetResponse = await profileDevStep(profile, 'built asset lookup', () =>
|
|
@@ -766,7 +795,7 @@ async function warmDevRoutes(config: ResolvedConfig, routes: RouteManifestEntry[
|
|
|
766
795
|
// it twice). The stand-down covers the stylesheet too, not just the route loop: a page request
|
|
767
796
|
// that arrived first is blocked on its own import, and global.css is a build it does not consume.
|
|
768
797
|
if (deferAssetPreload() && pageRequestsSeen > 0) return;
|
|
769
|
-
await buildDevAsset('/assets/global.css', () => buildGlobalCss(config, { dev: true }));
|
|
798
|
+
await buildDevAsset(config, '/assets/global.css', () => buildGlobalCss(config, { dev: true }));
|
|
770
799
|
for (const route of wanted) {
|
|
771
800
|
if (pageRequestsSeen > 0) return;
|
|
772
801
|
await warmDevRoute(config, route).catch(() => undefined);
|
|
@@ -780,7 +809,9 @@ async function warmDevRoute(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
|
780
809
|
const hasClient = route.client || route.clientReferences.length > 0;
|
|
781
810
|
await Promise.allSettled([
|
|
782
811
|
route.cssImports.length > 0
|
|
783
|
-
? buildDevAsset(`/assets/${route.id}.css`, () =>
|
|
812
|
+
? buildDevAsset(config, `/assets/${route.id}.css`, () =>
|
|
813
|
+
buildRouteCss(config, route, { dev: true }),
|
|
814
|
+
)
|
|
784
815
|
: Promise.resolve(),
|
|
785
816
|
devRouteModuleLoaders(config, route, layoutFiles),
|
|
786
817
|
hasClient ? buildDevClient(config, route) : Promise.resolve(),
|
|
@@ -1017,12 +1048,14 @@ function preloadDevPageAssets(
|
|
|
1017
1048
|
profile: DevRequestProfile | undefined,
|
|
1018
1049
|
) {
|
|
1019
1050
|
void profileDevStep(profile, 'asset preload /assets/global.css', () =>
|
|
1020
|
-
buildDevAsset('/assets/global.css', () => buildGlobalCss(config, { dev: true })),
|
|
1051
|
+
buildDevAsset(config, '/assets/global.css', () => buildGlobalCss(config, { dev: true })),
|
|
1021
1052
|
).catch(error => logDevPreloadError('global css build', error));
|
|
1022
1053
|
|
|
1023
1054
|
if (route.cssImports.length === 0) return;
|
|
1024
1055
|
void profileDevStep(profile, `asset preload /assets/${route.id}.css`, () =>
|
|
1025
|
-
buildDevAsset(`/assets/${route.id}.css`, () =>
|
|
1056
|
+
buildDevAsset(config, `/assets/${route.id}.css`, () =>
|
|
1057
|
+
buildRouteCss(config, route, { dev: true }),
|
|
1058
|
+
),
|
|
1026
1059
|
).catch(error => logDevPreloadError(`route css build for ${route.route}`, error));
|
|
1027
1060
|
}
|
|
1028
1061
|
|
|
@@ -1270,7 +1303,7 @@ async function maybeBuiltAsset(
|
|
|
1270
1303
|
) {
|
|
1271
1304
|
if (!pathname.startsWith('/assets/')) return null;
|
|
1272
1305
|
if (pathname === '/assets/global.css') {
|
|
1273
|
-
await buildDevAsset(pathname, () => buildGlobalCss(config, { dev: true }));
|
|
1306
|
+
await buildDevAsset(config, pathname, () => buildGlobalCss(config, { dev: true }));
|
|
1274
1307
|
} else {
|
|
1275
1308
|
const cssMatch = /^\/assets\/(.+)\.css$/.exec(pathname);
|
|
1276
1309
|
const route = cssMatch?.[1] ? routes.find(item => item.id === cssMatch[1]) : undefined;
|
|
@@ -1283,9 +1316,9 @@ async function maybeBuiltAsset(
|
|
|
1283
1316
|
findClientReferenceCss(routes, cssMatch[1])
|
|
1284
1317
|
: undefined;
|
|
1285
1318
|
if (route) {
|
|
1286
|
-
await buildDevAsset(pathname, () => buildRouteCss(config, route, { dev: true }));
|
|
1319
|
+
await buildDevAsset(config, pathname, () => buildRouteCss(config, route, { dev: true }));
|
|
1287
1320
|
} else if (reference) {
|
|
1288
|
-
await buildDevAsset(pathname, () =>
|
|
1321
|
+
await buildDevAsset(config, pathname, () =>
|
|
1289
1322
|
buildClientReferenceCss(config, reference, { dev: true }),
|
|
1290
1323
|
);
|
|
1291
1324
|
}
|
|
@@ -1301,7 +1334,8 @@ async function maybeBuiltAsset(
|
|
|
1301
1334
|
});
|
|
1302
1335
|
}
|
|
1303
1336
|
|
|
1304
|
-
function buildDevAsset(
|
|
1337
|
+
function buildDevAsset(config: ResolvedConfig, pathname: string, build: () => Promise<unknown>) {
|
|
1338
|
+
const key = appKey(config.outPath, pathname);
|
|
1305
1339
|
const existing = assetBuilds.get(key);
|
|
1306
1340
|
if (existing) return existing;
|
|
1307
1341
|
// Build each asset once per dev version (reload() clears it); drop on failure to allow retry.
|
|
@@ -1418,8 +1452,8 @@ function logDevPageAbort(pending: PendingDevPageLoadLog | undefined) {
|
|
|
1418
1452
|
// so the several-second cold esbuild + Tailwind build isn't a silent stall. The
|
|
1419
1453
|
// timed `page GET ... in Xs` line follows once the response resolves.
|
|
1420
1454
|
function noteDevCompileStart(route: RouteManifestEntry) {
|
|
1421
|
-
if (compiledRoutes.has(route.
|
|
1422
|
-
compiledRoutes.add(route.
|
|
1455
|
+
if (compiledRoutes.has(route.file)) return;
|
|
1456
|
+
compiledRoutes.add(route.file);
|
|
1423
1457
|
console.log(`${cyan('○')} ${dim('Compiling')} ${cyan(route.route)} ${dim('...')}`);
|
|
1424
1458
|
}
|
|
1425
1459
|
|
|
@@ -1534,12 +1568,14 @@ function refreshWatcher(
|
|
|
1534
1568
|
routes: RouteManifestEntry[],
|
|
1535
1569
|
current: DevWatcher | undefined,
|
|
1536
1570
|
onChange: (change: DevChange) => Promise<void>,
|
|
1571
|
+
bootTime: number,
|
|
1572
|
+
onEvent: (file: string) => void,
|
|
1537
1573
|
) {
|
|
1538
1574
|
const roots = devWatchRoots(config, routes);
|
|
1539
1575
|
const rootsKey = roots.join('\0');
|
|
1540
1576
|
if (current?.rootsKey === rootsKey) return current;
|
|
1541
1577
|
current?.stop();
|
|
1542
|
-
return watchRoots(roots, rootsKey, onChange);
|
|
1578
|
+
return watchRoots(roots, rootsKey, config.outPath, onChange, bootTime, onEvent);
|
|
1543
1579
|
}
|
|
1544
1580
|
|
|
1545
1581
|
function devWatchRoots(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
@@ -1606,10 +1642,17 @@ interface DevChange {
|
|
|
1606
1642
|
renamed: boolean;
|
|
1607
1643
|
}
|
|
1608
1644
|
|
|
1645
|
+
// Once any watch root fails (recursive watch unsupported), the graph goes back
|
|
1646
|
+
// to re-stating on every request for the life of the process.
|
|
1647
|
+
let recursiveWatchBroken = false;
|
|
1648
|
+
|
|
1609
1649
|
function watchRoots(
|
|
1610
1650
|
roots: string[],
|
|
1611
1651
|
rootsKey: string,
|
|
1652
|
+
outPath: string,
|
|
1612
1653
|
onChange: (change: DevChange) => Promise<void>,
|
|
1654
|
+
bootTime: number,
|
|
1655
|
+
onEvent: (file: string) => void,
|
|
1613
1656
|
): DevWatcher {
|
|
1614
1657
|
const controller = new AbortController();
|
|
1615
1658
|
let pending: Timer | undefined;
|
|
@@ -1630,6 +1673,8 @@ function watchRoots(
|
|
|
1630
1673
|
}
|
|
1631
1674
|
|
|
1632
1675
|
function schedule(file: string, renamed: boolean) {
|
|
1676
|
+
if (predatesBoot(file, bootTime)) return;
|
|
1677
|
+
onEvent(file);
|
|
1633
1678
|
batch.files.push(file);
|
|
1634
1679
|
batch.renamed ||= renamed;
|
|
1635
1680
|
if (pending) clearTimeout(pending);
|
|
@@ -1646,8 +1691,12 @@ function watchRoots(
|
|
|
1646
1691
|
}
|
|
1647
1692
|
|
|
1648
1693
|
for (const root of roots) {
|
|
1649
|
-
void watchRoot(root, controller.signal, schedule)
|
|
1694
|
+
void watchRoot(root, controller.signal, schedule, () => {
|
|
1695
|
+
recursiveWatchBroken = true;
|
|
1696
|
+
setDevWatcherFreshness(outPath, false);
|
|
1697
|
+
});
|
|
1650
1698
|
}
|
|
1699
|
+
if (!recursiveWatchBroken) setDevWatcherFreshness(outPath, true);
|
|
1651
1700
|
|
|
1652
1701
|
return {
|
|
1653
1702
|
rootsKey,
|
|
@@ -1658,10 +1707,27 @@ function watchRoots(
|
|
|
1658
1707
|
};
|
|
1659
1708
|
}
|
|
1660
1709
|
|
|
1710
|
+
/** A replayed pre-boot event: still on disk, unchanged since before the server read it. */
|
|
1711
|
+
function predatesBoot(file: string, bootTime: number) {
|
|
1712
|
+
const info = statSync(file, { throwIfNoEntry: false });
|
|
1713
|
+
return Boolean(info?.isFile() && info.mtimeMs <= bootTime);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Output and dependency churn is not a save: .pnext (the server's own cache
|
|
1717
|
+
// persists + typegen land there and used to reload the world on every warm
|
|
1718
|
+
// request), other dot dirs, and node_modules.
|
|
1719
|
+
const ignoredWatchSegment = /(?:^|[\\/])(?:node_modules|\.[^\\/]+)(?:[\\/]|$)/;
|
|
1720
|
+
|
|
1721
|
+
/** @internal Test-only: is this root-relative watch event output/dependency churn? */
|
|
1722
|
+
export function isIgnoredWatchPath(relativeFile: string) {
|
|
1723
|
+
return ignoredWatchSegment.test(relativeFile);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1661
1726
|
async function watchRoot(
|
|
1662
1727
|
root: string,
|
|
1663
1728
|
signal: AbortSignal,
|
|
1664
1729
|
onChange: (file: string, structural: boolean) => void,
|
|
1730
|
+
onBroken: () => void,
|
|
1665
1731
|
) {
|
|
1666
1732
|
try {
|
|
1667
1733
|
const watcher = watch(root, { recursive: true, signal });
|
|
@@ -1669,21 +1735,24 @@ async function watchRoot(
|
|
|
1669
1735
|
// No filename (rare, platform-dependent) means we cannot scope the
|
|
1670
1736
|
// invalidation: treat it as structural so everything is re-derived.
|
|
1671
1737
|
if (!event.filename) onChange(root, true);
|
|
1672
|
-
else
|
|
1738
|
+
else if (!ignoredWatchSegment.test(event.filename))
|
|
1739
|
+
onChange(path.resolve(root, event.filename), event.eventType === 'rename');
|
|
1673
1740
|
}
|
|
1674
1741
|
} catch (error) {
|
|
1675
1742
|
if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) return;
|
|
1676
|
-
// Some platforms do not support recursive watch. Dev still works without
|
|
1743
|
+
// Some platforms do not support recursive watch. Dev still works without
|
|
1744
|
+
// live reload, but the graph must then re-stat on every request.
|
|
1745
|
+
onBroken();
|
|
1677
1746
|
}
|
|
1678
1747
|
}
|
|
1679
1748
|
|
|
1680
|
-
function eventStream(clients: Set<DevEventClient
|
|
1749
|
+
function eventStream(clients: Set<DevEventClient>, generation: number) {
|
|
1681
1750
|
let client: DevEventClient | undefined;
|
|
1682
1751
|
const stream = new ReadableStream<Uint8Array>({
|
|
1683
1752
|
start(controller) {
|
|
1684
1753
|
client = { controller };
|
|
1685
1754
|
clients.add(client);
|
|
1686
|
-
controller.enqueue(new TextEncoder().encode(
|
|
1755
|
+
controller.enqueue(new TextEncoder().encode(`event: ready\ndata: ${generation}\n\n`));
|
|
1687
1756
|
},
|
|
1688
1757
|
cancel() {
|
|
1689
1758
|
if (client) clients.delete(client);
|