@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
@@ -1,4 +1,4 @@
1
- import { rewriteFacts } from '../resolve/scan-facts';
1
+ import { dynamicCallFacts, rewriteFacts, type DynamicCallFact } from '../resolve/scan-facts';
2
2
 
3
3
  export interface DynamicCall {
4
4
  index: number;
@@ -86,19 +86,16 @@ export function dynamicCallsFromSource(source: string, dynamicNames: Set<string>
86
86
  export function rewriteLiteralDynamicCalls(source: string, file?: string) {
87
87
  const dynamicNames = pnextDynamicImportNames(source, file);
88
88
  if (dynamicNames.size === 0) return source;
89
+ const facts = dynamicCallFacts(source, file);
89
90
 
90
91
  const edits: { start: number; end: number; value: string }[] = [];
91
92
  for (const call of dynamicCallsFromSource(source, dynamicNames)) {
92
- const args = source.slice(call.open + 1, call.close);
93
- const literal = /^(\s*)(['"])([^'"]+)\2(?=\s*(?:,|$))/.exec(args);
94
- if (!literal) continue;
95
- const [match, leading = '', quote = "'", specifier] = literal;
96
- if (!specifier) continue;
97
- const start = call.open + 1 + leading.length;
93
+ const fact = factForCall(facts, call);
94
+ if (!fact?.literal) continue;
98
95
  edits.push({
99
- start,
100
- end: call.open + 1 + match.length,
101
- value: `() => import(${quote}${specifier}${quote})`,
96
+ start: fact.specifierStart,
97
+ end: fact.specifierEnd,
98
+ value: `() => import(${source.slice(fact.specifierStart, fact.specifierEnd)})`,
102
99
  });
103
100
  }
104
101
 
@@ -123,18 +120,19 @@ export function rewriteDynamicCallTargets(
123
120
  ) {
124
121
  const dynamicNames = pnextDynamicImportNames(source, file);
125
122
  if (dynamicNames.size === 0) return source;
123
+ const facts = dynamicCallFacts(source, file);
126
124
 
127
125
  const edits: { at: number; value: string }[] = [];
128
126
  for (const call of dynamicCallsFromSource(source, dynamicNames)) {
129
- const target = dynamicImportTarget.exec(call.source);
130
- if (!target?.[2]) continue;
131
- const file = resolve(target[2]);
127
+ const target = loaderImportTargetForCall(call, facts);
128
+ if (!target) continue;
129
+ const file = resolve(target.specifier);
132
130
  if (!file) continue;
133
131
 
134
132
  const args = source.slice(call.open + 1, call.close);
135
133
  const argCount = topLevelArgCount(args);
136
134
  if (argCount < 1 || argCount > 2) continue;
137
- const literal = JSON.stringify({ file, exportName: target[4] ?? 'default' });
135
+ const literal = JSON.stringify({ file, exportName: target.exportName });
138
136
  const separator = /,\s*$/.test(args) ? '' : ', ';
139
137
  edits.push({
140
138
  at: call.close,
@@ -151,8 +149,19 @@ export function rewriteDynamicCallTargets(
151
149
  return next;
152
150
  }
153
151
 
154
- const dynamicImportTarget =
155
- /import\s*\(\s*(['"])([^'"]+)\1\s*\)(?:\s*\.then\s*\(\s*([A-Za-z_$][\w$]*)\s*=>\s*\3\.([A-Za-z_$][\w$]*)\s*\))?/;
152
+ function factForCall(facts: DynamicCallFact[], call: DynamicCall) {
153
+ return facts.find(fact => fact.start === call.index);
154
+ }
155
+
156
+ /**
157
+ * The statically-extractable import behind one dynamic() call — the AST fact
158
+ * alone; a call the scan could not analyze yields no detection. Literal-loader
159
+ * calls (`dynamic('./x')`) answer undefined — they carry no `import()` yet.
160
+ */
161
+ function loaderImportTargetForCall(call: DynamicCall, facts: DynamicCallFact[]) {
162
+ const fact = factForCall(facts, call);
163
+ return fact && !fact.literal ? fact : undefined;
164
+ }
156
165
 
157
166
  /** Dev split of `dynamic(ssr:false)` targets; `PNEXT_DYNAMIC_SPLIT=0` restores eager builds. */
158
167
  export function devDynamicSplitEnabled() {
@@ -175,17 +184,17 @@ export function rewriteDeferredDynamicImports(
175
184
  if (dynamicNames.size === 0) return source;
176
185
  const deferred = deferredDynamicImportSpecifiers(source, file);
177
186
  if (deferred.size === 0) return source;
187
+ const facts = dynamicCallFacts(source, file);
178
188
 
179
189
  const edits: { start: number; end: number; value: string }[] = [];
180
190
  for (const call of dynamicCallsFromSource(source, dynamicNames)) {
181
- const target = dynamicImportTarget.exec(call.source);
182
- if (!target?.[2] || !deferred.has(target[2])) continue;
183
- const resolved = resolve(target[2]);
191
+ const target = loaderImportTargetForCall(call, facts);
192
+ if (!target || !deferred.has(target.specifier)) continue;
193
+ const resolved = resolve(target.specifier);
184
194
  if (!resolved) continue;
185
- const url = href({ file: resolved, exportName: target[4] ?? 'default' });
195
+ const url = href({ file: resolved, exportName: target.exportName });
186
196
  if (!url) continue;
187
- const specifierAt = call.index + target.index + target[0].indexOf(target[2]);
188
- edits.push({ start: specifierAt, end: specifierAt + target[2].length, value: url });
197
+ edits.push({ start: target.specifierStart + 1, end: target.specifierEnd - 1, value: url });
189
198
  }
190
199
  if (edits.length === 0) return source;
191
200
  let next = source;
@@ -212,13 +221,13 @@ export function deferredDynamicImportSpecifiers(source: string, file?: string) {
212
221
  if (constMatch[1] && constMatch[2]) optionConsts.set(constMatch[1], constMatch[2]);
213
222
  }
214
223
 
224
+ const facts = dynamicCallFacts(source, file);
215
225
  for (const call of dynamicCallsFromSource(source, dynamicNames)) {
216
- const target = dynamicImportTarget.exec(call.source);
217
- if (!target?.[2]) continue;
226
+ const target = loaderImportTargetForCall(call, facts);
227
+ if (!target) continue;
218
228
  // Token scan over the call plus any named option consts it references —
219
229
  // tolerant of the compile-appended target argument (rewriteDynamicCallTargets).
220
- const args = source.slice(call.open + 1, call.close);
221
- const tail = args.slice(args.indexOf(target[0]) + target[0].length);
230
+ const tail = source.slice(target.loaderEnd, call.close);
222
231
  let options = tail;
223
232
  for (const identifier of tail.match(/[A-Za-z_$][\w$]*/g) ?? []) {
224
233
  const named = optionConsts.get(identifier);
@@ -227,7 +236,7 @@ export function deferredDynamicImportSpecifiers(source: string, file?: string) {
227
236
  const ssrFalse = /\bssr\s*:\s*false\b/.test(options);
228
237
  const visible =
229
238
  /\bload\s*:\s*['"]visible['"]/.test(options) && !/\bssr\s*:\s*true\b/.test(options);
230
- if (ssrFalse || visible) deferred.add(target[2]);
239
+ if (ssrFalse || visible) deferred.add(target.specifier);
231
240
  }
232
241
  return deferred;
233
242
  }
package/src/ppr.ts CHANGED
@@ -539,14 +539,15 @@ export function hangingPromise<T = never>(api: string): Promise<T> {
539
539
  // candidate and whether request APIs hang.
540
540
  // ---------------------------------------------------------------------------
541
541
 
542
- let cacheComponentsEnabled = false;
542
+ // globalThis-anchored: the prebundled server entry inlines its own copy of this module.
543
+ const CACHE_COMPONENTS = Symbol.for('pnext.cacheComponents');
543
544
 
544
545
  export function setCacheComponents(enabled: boolean): void {
545
- cacheComponentsEnabled = enabled;
546
+ (globalThis as Record<PropertyKey, unknown>)[CACHE_COMPONENTS] = enabled;
546
547
  }
547
548
 
548
549
  export function cacheComponents(): boolean {
549
- return cacheComponentsEnabled;
550
+ return (globalThis as Record<PropertyKey, unknown>)[CACHE_COMPONENTS] === true;
550
551
  }
551
552
 
552
553
  // ---------------------------------------------------------------------------
@@ -583,7 +584,7 @@ function insideUseCacheScope(): boolean {
583
584
  }
584
585
 
585
586
  export async function runWithPrerenderDeterminism<T>(fn: () => Promise<T>): Promise<T> {
586
- if (!cacheComponentsEnabled) return fn();
587
+ if (!cacheComponents()) return fn();
587
588
  const deterministicTime = 1;
588
589
  const realRandom = Math.random;
589
590
  const RealDate = Date;
package/src/proxy.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parseSync } from 'oxc-parser';
3
+ // Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
4
+ const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
5
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
6
+ loadNative(() => require('oxc-parser') as typeof import('oxc-parser')).parseSync(...args);
7
+ import { loadNative } from './utils/native-require';
4
8
  import { registerServerRuntime } from './runtime/server';
5
9
  import { pathToFileHref, type ResolvedConfig } from './config';
6
10
  import { devServerModuleHref } from './dev/imports';
@@ -1,4 +1,16 @@
1
- import { createContext } from 'preact';
1
+ import { createContext, type Context } from 'preact';
2
+
3
+ // Island SSR runs prebundled client chunks — a separate module graph with its own copy of this file, so
4
+ // a plain createContext() would mint two identity-mismatched contexts. Resolve one shared instance.
5
+ function sharedContext<T>(key: string, defaultValue: T): Context<T> {
6
+ const registry = globalThis as typeof globalThis & Record<symbol, Context<unknown>>;
7
+ const symbol = Symbol.for(key);
8
+ const existing = registry[symbol];
9
+ if (existing) return existing as Context<T>;
10
+ const context = createContext<T>(defaultValue);
11
+ registry[symbol] = context as Context<unknown>;
12
+ return context;
13
+ }
2
14
 
3
15
  // ---------------------------------------------------------------------------
4
16
  // Per-island layout-segment context (CORE seam for compat useSelectedLayout-
@@ -28,7 +40,10 @@ export interface LayoutSegmentSnapshot {
28
40
  slots: Record<string, string[]>;
29
41
  }
30
42
 
31
- export const LayoutSegmentContext = createContext<LayoutSegmentSnapshot | null>(null);
43
+ export const LayoutSegmentContext = sharedContext<LayoutSegmentSnapshot | null>(
44
+ 'pnext.layoutSegmentContext',
45
+ null,
46
+ );
32
47
 
33
48
  // Per-island params snapshot (CORE seam for compat useParams).
34
49
  //
@@ -44,4 +59,7 @@ export const LayoutSegmentContext = createContext<LayoutSegmentSnapshot | null>(
44
59
 
45
60
  export type RouteParamsSnapshot = Record<string, string | string[]>;
46
61
 
47
- export const RouteParamsContext = createContext<RouteParamsSnapshot | null>(null);
62
+ export const RouteParamsContext = sharedContext<RouteParamsSnapshot | null>(
63
+ 'pnext.routeParamsContext',
64
+ null,
65
+ );
@@ -103,7 +103,7 @@ import {
103
103
  ISLAND_BOUNDARY_ERROR_ELEMENT,
104
104
  ISLAND_BOUNDARY_ERROR_MESSAGE_ATTRIBUTE,
105
105
  } from '../islands/boundary-error';
106
- import { runWithCacheScope } from '../cache/context';
106
+ import { captureCacheScope, runWithCacheScope } from '../cache/context';
107
107
  import { registerServerRuntime } from '../runtime/server';
108
108
  import { readText } from '../utils/fs';
109
109
  import {
@@ -2424,8 +2424,14 @@ export function pprSubShellPath(outPath: string, routeId: string, key: string) {
2424
2424
  return `${outPath}/ppr/${routeId}.${key}.html`;
2425
2425
  }
2426
2426
 
2427
+ /**
2428
+ * `skipNoindex`: the 404 came out of a server action, where robots the page
2429
+ * declared must stay authoritative — the metadata base stops forcing noindex.
2430
+ * A default noindex is still injected when nothing declares robots at all.
2431
+ */
2427
2432
  export async function renderGlobalNotFoundResponse(
2428
2433
  options: Omit<RenderOptions, 'route' | 'params'>,
2434
+ { skipNoindex = false }: { skipNoindex?: boolean } = {},
2429
2435
  ) {
2430
2436
  const globalFile = ['tsx', 'ts', 'jsx', 'js']
2431
2437
  .map(ext => `${options.config.appPath}/global-not-found.${ext}`)
@@ -2526,7 +2532,9 @@ export async function renderGlobalNotFoundResponse(
2526
2532
  renderScopeRequest(notFoundOptions),
2527
2533
  () =>
2528
2534
  runWithFontScope(() =>
2529
- runWithCacheScope(() => renderNotFoundPage(notFoundOptions, false, { forceDefault })),
2535
+ runWithCacheScope(() =>
2536
+ renderNotFoundPage(notFoundOptions, false, { forceDefault, skipNoindex }),
2537
+ ),
2530
2538
  ),
2531
2539
  {},
2532
2540
  );
@@ -2548,6 +2556,44 @@ export async function renderGlobalNotFoundResponse(
2548
2556
  return response;
2549
2557
  }
2550
2558
 
2559
+ /**
2560
+ * notFound() thrown from a Server Action: the URL DID match a route, so render the not-found
2561
+ * boundary within that route (keeping its layout chain and generateMetadata) instead of anchoring
2562
+ * at the app root like renderGlobalNotFoundResponse.
2563
+ */
2564
+ export async function renderNotFoundForRoute(
2565
+ options: RenderOptions,
2566
+ { skipNoindex = false }: { skipNoindex?: boolean } = {},
2567
+ ): Promise<Response> {
2568
+ const notFoundRequest = options.request
2569
+ ? new Request(options.request.url, { headers: options.request.headers })
2570
+ : undefined;
2571
+ const notFoundOptions: RenderOptions = {
2572
+ ...options,
2573
+ ...(notFoundRequest ? { request: notFoundRequest } : {}),
2574
+ status: 404,
2575
+ };
2576
+ const page = await runWithRequest(
2577
+ renderScopeRequest(notFoundOptions),
2578
+ () =>
2579
+ runWithFontScope(() =>
2580
+ runWithCacheScope(() => renderNotFoundPage(notFoundOptions, false, { skipNoindex })),
2581
+ ),
2582
+ {},
2583
+ );
2584
+ const response = new Response(pageStream(page), {
2585
+ status: 404,
2586
+ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
2587
+ });
2588
+ const html = await response.text();
2589
+ const withRobots = html.includes('name="robots"')
2590
+ ? html
2591
+ : html.replace('</head>', '<meta name="robots" content="noindex"/></head>');
2592
+ const headers = new Headers(response.headers);
2593
+ headers.delete('content-length');
2594
+ return new Response(withRobots, { status: response.status, headers });
2595
+ }
2596
+
2551
2597
  // Everything after this marker is late metadata, not document bytes: the
2552
2598
  // router's streamed read commits the document at the marker and folds the
2553
2599
  // remainder into the live head.
@@ -3351,9 +3397,14 @@ async function renderTree(
3351
3397
  // is body-streamed anyway, resolve it as a deferred stream chunk instead.
3352
3398
  // Only on an RSC navigation render: a hard document load keeps blocking, so a
3353
3399
  // metadata notFound()/redirect() still owns the whole document.
3400
+ // Captured explicitly: the page body already populated the render's cache() scope, and a deferred
3401
+ // generateMetadata must reuse it or its cache()-wrapped values drift from what the body rendered.
3402
+ const runInCacheScope = captureCacheScope();
3354
3403
  const pendingPageMetadata =
3355
3404
  typeof pageMetadata === 'function' && deferrableNavigationMetadata(options, runtimeMetadataInBody, stream)
3356
- ? Promise.resolve().then(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata)))
3405
+ ? Promise.resolve().then(() =>
3406
+ runInCacheScope(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata))),
3407
+ )
3357
3408
  : undefined;
3358
3409
  try {
3359
3410
  resolvedPageMetadata = pendingPageMetadata
@@ -4009,9 +4060,16 @@ export function defaultNotFoundDocument(): string {
4009
4060
  async function renderNotFoundPage(
4010
4061
  options: RenderOptions,
4011
4062
  stream: boolean,
4012
- extra: { status?: number; nextFlightText?: boolean; forceDefault?: boolean } = {},
4063
+ extra: {
4064
+ status?: number;
4065
+ nextFlightText?: boolean;
4066
+ forceDefault?: boolean;
4067
+ skipNoindex?: boolean;
4068
+ } = {},
4013
4069
  ) {
4014
4070
  const status = extra.status ?? 404;
4071
+ // Server-action 404s carry no fallback robots (Next's NonIndex skip).
4072
+ const fallbackRobots = extra.skipNoindex ? {} : { robots: 'noindex' };
4015
4073
  // forceDefault: globalNotFound is enabled but no global-not-found.* exists —
4016
4074
  // skip the app's not-found.* boundary and render the built-in default 404.
4017
4075
  const file = extra.forceDefault ? undefined : nearestConventionFile(options, 'not-found.tsx');
@@ -4021,7 +4079,7 @@ async function renderNotFoundPage(
4021
4079
  notFoundOptions,
4022
4080
  defaultNotFoundTree(),
4023
4081
  stream,
4024
- { title: '404: This page could not be found.', robots: 'noindex' },
4082
+ { title: '404: This page could not be found.', ...fallbackRobots },
4025
4083
  { status },
4026
4084
  );
4027
4085
  return extra.nextFlightText ? withNextFlightText(page) : page;
@@ -4049,7 +4107,7 @@ async function renderNotFoundPage(
4049
4107
  notFoundOptions,
4050
4108
  defaultNotFoundTree(),
4051
4109
  stream,
4052
- { title: '404: This page could not be found.', robots: 'noindex' },
4110
+ { title: '404: This page could not be found.', ...fallbackRobots },
4053
4111
  { status },
4054
4112
  );
4055
4113
  return extra.nextFlightText ? withNextFlightText(page) : page;
@@ -4087,7 +4145,7 @@ async function renderNotFoundPage(
4087
4145
  stream,
4088
4146
  // On the skip path a baked `<title>Not found</title>` would precede the
4089
4147
  // streamed metadata chunk in document order and win `document.title`.
4090
- { robots: 'noindex', ...resolvedMetadata },
4148
+ { ...fallbackRobots, ...resolvedMetadata },
4091
4149
  {
4092
4150
  status,
4093
4151
  ...(resolvedViewport ? { viewport: resolvedViewport } : {}),
@@ -7367,25 +7425,43 @@ function devReloadScript() {
7367
7425
  // unstyled and never loses client state. Anything the server could not prove
7368
7426
  // was CSS-only still arrives as `reload`.
7369
7427
  return `<script data-pnext-dev>
7370
- const events = new EventSource('/__pnext/events');
7371
- events.addEventListener('reload', () => location.reload());
7372
- events.addEventListener('css-update', event => {
7373
- const links = [...document.querySelectorAll('link[rel=stylesheet]')].filter(link => {
7374
- try { return new URL(link.href, location.href).origin === location.origin; } catch { return false; }
7428
+ let events;
7429
+ let generation;
7430
+ // The stream is a long-lived connection counted against the browser's per-origin
7431
+ // socket cap (6 on HTTP/1.1). A page the browser keeps alive after navigating away
7432
+ // (bfcache) holds its own open, so every page load would leak one until the pool is
7433
+ // exhausted and further requests stall for minutes. Close on the way out, reopen on
7434
+ // restore.
7435
+ function connectDevEvents() {
7436
+ events = new EventSource('/__pnext/events');
7437
+ // The server stamps its build generation on connect: a page restored onto a
7438
+ // generation it never saw missed a rebuild while it was disconnected.
7439
+ events.addEventListener('ready', event => {
7440
+ if (generation !== undefined && event.data !== generation) return location.reload();
7441
+ generation = event.data;
7442
+ });
7443
+ events.addEventListener('reload', () => location.reload());
7444
+ events.addEventListener('css-update', event => {
7445
+ const links = [...document.querySelectorAll('link[rel=stylesheet]')].filter(link => {
7446
+ try { return new URL(link.href, location.href).origin === location.origin; } catch { return false; }
7447
+ });
7448
+ if (links.length === 0) { location.reload(); return; }
7449
+ for (const link of links) {
7450
+ const url = new URL(link.href, location.href);
7451
+ url.searchParams.set('__pnext_css', event.data);
7452
+ const next = link.cloneNode();
7453
+ // React tracks precedence-carrying links as its own; the swap is ours.
7454
+ next.removeAttribute('data-precedence');
7455
+ next.href = url.href;
7456
+ const drop = () => link.remove();
7457
+ next.addEventListener('load', drop, { once: true });
7458
+ next.addEventListener('error', drop, { once: true });
7459
+ link.after(next);
7460
+ }
7375
7461
  });
7376
- if (links.length === 0) { location.reload(); return; }
7377
- for (const link of links) {
7378
- const url = new URL(link.href, location.href);
7379
- url.searchParams.set('__pnext_css', event.data);
7380
- const next = link.cloneNode();
7381
- // React tracks precedence-carrying links as its own; the swap is ours.
7382
- next.removeAttribute('data-precedence');
7383
- next.href = url.href;
7384
- const drop = () => link.remove();
7385
- next.addEventListener('load', drop, { once: true });
7386
- next.addEventListener('error', drop, { once: true });
7387
- link.after(next);
7388
- }
7389
- });
7462
+ }
7463
+ connectDevEvents();
7464
+ addEventListener('pagehide', () => events.close());
7465
+ addEventListener('pageshow', event => { if (event.persisted) connectDevEvents(); });
7390
7466
  </script>`;
7391
7467
  }
@@ -1,6 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { ResolverFactory, type NapiResolveOptions } from 'oxc-resolver';
3
+ import type { ResolverFactory, NapiResolveOptions } from 'oxc-resolver';
4
+ import { loadNative } from '../utils/native-require';
5
+
6
+ // Lazy: the native binding costs ~1.3 MB RSS and a prod server may never resolve.
7
+ function createResolverFactory(options: NapiResolveOptions): ResolverFactory {
8
+ const { ResolverFactory: Factory } = loadNative(
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ () => require('oxc-resolver') as typeof import('oxc-resolver'),
11
+ );
12
+ return new Factory(options);
13
+ }
4
14
  import { clearNodeModulesFsCache } from '../utils/fs-cache';
5
15
 
6
16
  const sourceExtensions = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.cts', '.module.css', '.css'];
@@ -200,7 +210,7 @@ function baseOptions(): NapiResolveOptions {
200
210
  }
201
211
 
202
212
  function baseResolver() {
203
- baseFactory ??= new ResolverFactory(baseOptions());
213
+ baseFactory ??= createResolverFactory(baseOptions());
204
214
  return baseFactory;
205
215
  }
206
216