@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
@@ -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
- return withRewriteHeaders(
953
- withDeploymentId(
954
- treePrefetchResponse(payload, { format: url.searchParams.has('_rsc') ? 'flight' : 'json' }),
955
- ),
956
- rewriteHeaders,
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
- outPath: path.resolve(root, merged.outDir),
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 { build, type Plugin } from 'esbuild';
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
- type CssWorker = Worker & { ref(): void; unref(): void };
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,
@@ -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 { build, type OnResolveResult, type Plugin } from 'esbuild';
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
- stubClientImports: reactCompatEnabled(config),
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
- async function devRouteBundlePath(
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.outPath),
2388
+ path.basename(config.outRootPath),
2360
2389
  ]).has(name);
2361
2390
  }
2362
2391
 
@@ -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 { transformSync, type TransformOptions } from 'oxc-transform';
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
- assetBuilds.clear();
160
- routeCacheKeys.clear();
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`, () => buildRouteCss(config, route, { dev: true }))
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`, () => buildRouteCss(config, route, { dev: true })),
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(key: string, build: () => Promise<unknown>) {
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.id)) return;
1422
- compiledRoutes.add(route.id);
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 onChange(path.resolve(root, event.filename), event.eventType === 'rename');
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 live reload.
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('event: ready\ndata: ok\n\n'));
1755
+ controller.enqueue(new TextEncoder().encode(`event: ready\ndata: ${generation}\n\n`));
1687
1756
  },
1688
1757
  cancel() {
1689
1758
  if (client) clients.delete(client);