@thomas.f/prnext 0.1.20-alpha → 0.1.21-alpha

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 (47) hide show
  1. package/build/app.mjs +4 -4
  2. package/build/build-timings.mjs +38 -0
  3. package/build/bundle-runtime.mjs +8 -8
  4. package/build/compiler.mjs +2 -1
  5. package/build/flight-summary.mjs +64 -0
  6. package/build/index.mjs +32 -28
  7. package/build/inspect.mjs +3 -0
  8. package/build/inspection-summary.mjs +7 -2
  9. package/build/native-discovery.mjs +18 -6
  10. package/build/npm-packages.mjs +2 -2
  11. package/build/source-analysis.mjs +54 -1
  12. package/build/standalone-copy.mjs +48 -0
  13. package/build/standalone.mjs +2 -22
  14. package/build/transform-cache.mjs +17 -3
  15. package/cli.mjs +13 -0
  16. package/compat/diagnostics.cjs +5 -4
  17. package/compat/runtime-sample.cjs +56 -0
  18. package/compat/use-cache.cjs +5 -2
  19. package/docs/ai/compatibility.md +3 -3
  20. package/docs/ai/deployment.md +8 -5
  21. package/docs/ai/troubleshooting.md +22 -0
  22. package/package.json +20 -20
  23. package/runtime/app-client.mjs +106 -38
  24. package/runtime/app-render.mjs +31 -6
  25. package/runtime/app-stream-channel.mjs +17 -4
  26. package/runtime/app-stream-html.mjs +30 -11
  27. package/runtime/cache-components-rsc.mjs +26 -6
  28. package/runtime/cache-result.mjs +58 -0
  29. package/runtime/dev-client.mjs +26 -5
  30. package/runtime/dev-compiler-broker.mjs +20 -23
  31. package/runtime/dev-gateway.mjs +22 -6
  32. package/runtime/development-flight.mjs +59 -0
  33. package/runtime/development-queue.mjs +33 -0
  34. package/runtime/development.mjs +11 -9
  35. package/runtime/flight-diagnostics.mjs +48 -0
  36. package/runtime/flight-tap.mjs +10 -5
  37. package/runtime/html-envelope.mjs +34 -0
  38. package/runtime/loopback-rpc.mjs +92 -0
  39. package/runtime/native-ppr.mjs +57 -4
  40. package/runtime/persistent/client.mjs +1 -1
  41. package/runtime/persistent/common.mjs +7 -18
  42. package/runtime/persistent/daemon.mjs +1 -1
  43. package/runtime/persistent/native-proxy.mjs +1 -1
  44. package/runtime/persistent/watchdog.mjs +12 -27
  45. package/runtime/prewarm.mjs +35 -0
  46. package/runtime/rsc-worker.mjs +58 -8
  47. package/runtime/worker.mjs +29 -14
@@ -1,6 +1,6 @@
1
1
  import { deploymentHeaders, assertDeploymentResponse } from './deployment-client.mjs';
2
2
  import React, { startTransition, useLayoutEffect } from 'react';
3
- import { preload } from 'react-dom';
3
+ import { preload, flushSync } from 'react-dom';
4
4
  import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_CACHE_BYTES } from './app-prefetch-constants.mjs';
5
5
  import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
6
6
  import { createRoot, hydrateRoot } from 'react-dom/client';
@@ -21,6 +21,7 @@ import {recoverNavigation} from './navigation-recovery.mjs';
21
21
  import { createActivityCache } from '../compat/activity-cache.cjs';
22
22
  import { createFlightTransport, TYPE as FLIGHT_DELTA_TYPE } from '../compat/flight-delta.cjs';
23
23
  import {decodeBase64} from '../compat/base64.cjs';
24
+ import { supersedableDevelopmentFlight, developmentFlightRoot } from './development-flight.mjs';
24
25
 
25
26
  const PREFETCH_LIMIT = 8;
26
27
  const PREFETCH_LIFETIME = 30_000;
@@ -258,6 +259,10 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
258
259
  let historyRouting = currentRouting;
259
260
  let currentModel = initialModel;
260
261
  let committedView = { model: initialModel, routing: currentRouting, url: currentUrl.href, retentionEpoch: 0, authorizationEpoch: 0 };
262
+ // Development navigation can display a newer component generation before its
263
+ // last Flight row arrives. Keep that visible tree separately from the last
264
+ // genuinely complete response; superseding it must not restore older types.
265
+ let developmentView;
261
266
  let pendingView, pendingNavigation;
262
267
  const historyFocus = new Map();
263
268
  let currentKey = window.history.state?.__prnext?.key || 0;
@@ -290,46 +295,57 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
290
295
  // A committed loading shell can still contain rows owned by a live
291
296
  // fetch. Keep the last fully received view available before aborting it.
292
297
  // Authorized PPR previews likewise never become the canonical model.
293
- if (canonical) committedView = { model, routing: model.routing, url, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
298
+ if (canonical) {
299
+ committedView = { model, routing: model.routing, url, element, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
300
+ developmentView = undefined;
301
+ }
294
302
  };
295
303
  if (flight) flight.committed = promote;
296
- startTransition(() => root.render(appView({ model, controller, url, globalErrorComponent, globalErrorCss,
304
+ const element = appView({ model, controller, url, globalErrorComponent, globalErrorCss,
297
305
  committed: () => {
298
306
  if (token !== navigation || pendingView !== view) return;
299
307
  view.committed = true;
308
+ if (canonical && flight?.development && kind !== 'action') developmentView = { model, routing: model.routing, url, element, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
300
309
  promote();
301
310
  committed?.();
302
311
  },
303
- })));
312
+ });
313
+ view.element = element;
314
+ startTransition(() => root.render(element));
304
315
  }
305
316
 
306
317
  function abandonPendingView() {
307
318
  if (!pendingView) return true;
308
319
  const flight = navigationStreams.get(pendingView.model);
309
320
  const strictAuthorization = controller._navigationCache?.authCookies !== undefined;
321
+ const retained = developmentView || committedView;
310
322
  if (flight) flight.committed = undefined;
311
323
  if (pendingView.kind === 'action' && committedView.authorizationEpoch === controller._authorizationEpoch &&
312
324
  (!strictAuthorization || committedView.model.authorizationKey === currentModel.authorizationKey)) {
313
325
  // An action's received result can invoke a returned action before React
314
326
  // commits its UI. This response is never owned/aborted by activeRequest;
315
327
  // adopt its model instead of rolling back the prior mutation's epoch.
328
+ committedView = { model: currentModel, routing: currentRouting, url: currentUrl.href, element: pendingView.element, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
316
329
  pendingView = undefined;
317
- committedView = { model: currentModel, routing: currentRouting, url: currentUrl.href, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
330
+ developmentView = undefined;
318
331
  return true;
319
332
  }
320
333
  // A new authorized shell or explicit invalidation can make the last full
321
334
  // model private to an obsolete session. Reauthorize through a document
322
335
  // request instead of briefly restoring that model while C is pending.
323
- if ((strictAuthorization && committedView.model.authorizationKey !== currentModel.authorizationKey) ||
324
- committedView.retentionEpoch !== controller._retentionEpoch ||
325
- committedView.authorizationEpoch !== controller._authorizationEpoch) return false;
336
+ if ((strictAuthorization && retained.model.authorizationKey !== currentModel.authorizationKey) ||
337
+ retained.retentionEpoch !== controller._retentionEpoch ||
338
+ retained.authorizationEpoch !== controller._authorizationEpoch ||
339
+ (retained.model.rootLayout && currentModel.rootLayout && retained.model.rootLayout !== currentModel.rootLayout)) return false;
326
340
  pendingView = undefined;
327
- currentModel = committedView.model;
328
- currentRouting = committedView.routing;
341
+ currentModel = retained.model;
342
+ currentRouting = retained.routing;
329
343
  // React can already be reading a lazy row after createFromFetch resolves
330
344
  // the root model. Replace that suspended render before abort rejects its
331
345
  // remaining rows; it must not poison the retained layout or page cache.
332
- root.render(appView({ model: committedView.model, controller, url: committedView.url, globalErrorComponent, globalErrorCss }));
346
+ // Reuse the exact submitted element. Before a replacement refresh commits,
347
+ // rebuilding the older Flight lazies after Refresh can itself remount them.
348
+ root.render(retained.element || appView({ model: retained.model, controller, url: retained.url, globalErrorComponent, globalErrorCss }));
333
349
  return true;
334
350
  }
335
351
 
@@ -371,7 +387,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
371
387
  const actionReloadError = () => new Error('The session changed while this page was loading. Reload before repeating the action.');
372
388
 
373
389
  const flightTransport = createFlightTransport();
374
- async function requestFlight(url, signal, routing = currentRouting, onPartial, {skipEnsure=false,fullFlight=false}={}) {
390
+ async function requestFlight(url, signal, routing = currentRouting, onPartial, {skipEnsure=false,fullFlight=false,developmentSupersession}={}) {
375
391
  if(process.env.NODE_ENV==='development'&&!routing?.refresh&&!skipEnsure)await globalThis.__PRNEXT_DEV__?.ensureRoute?.(url.href);
376
392
  const exported = window.__PRNEXT_STATIC_EXPORT__ === true;
377
393
  const base = !exported && !fullFlight ? flightTransport.snapshot() : undefined;
@@ -392,12 +408,17 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
392
408
  }
393
409
  await onPartial?.(response, destination);
394
410
  let model;
395
- const flight = { complete: false };
396
- try { model = assertModel(await createFromFetch(Promise.resolve(await flightTransport.response(response,base,{onEnd(){flight.complete = true;flight.committed?.();}})), flightOptions)); }
411
+ const flight = { complete: false, development: process.env.NODE_ENV === 'development' && !!developmentSupersession };
412
+ try {
413
+ const received = await flightTransport.response(response,base,{onEnd(){flight.complete = true;flight.committed?.();}});
414
+ if (process.env.NODE_ENV === 'development' && developmentSupersession) {
415
+ model = assertModel(await developmentFlightRoot(createFromReadableStream(supersedableDevelopmentFlight(received.body, developmentSupersession), { ...flightOptions, unstable_allowPartialStream: true }), signal));
416
+ } else model = assertModel(await createFromFetch(Promise.resolve(received), flightOptions));
417
+ }
397
418
  catch(error) {
398
419
  if(!delta||fullFlight||signal?.aborted)throw error;
399
420
  flightTransport.clear();
400
- return requestFlight(url,signal,routing,onPartial,{skipEnsure:true,fullFlight:true});
421
+ return requestFlight(url,signal,routing,onPartial,{skipEnsure:true,fullFlight:true,developmentSupersession});
401
422
  }
402
423
  navigationStreams.set(model, flight);
403
424
  model.router.basePath = basePath;
@@ -406,7 +427,11 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
406
427
  return { model, url: destination };
407
428
  }
408
429
 
409
- const navigateFlight=(url,signal,...args)=>recoverNavigation(recoverySignal=>requestFlight(url,recoverySignal,...args),{signal});
430
+ // Only development navigation GETs may retain a DOM-committed partial tree.
431
+ // Direct prefetch, production navigation and Action POST decoders stay strict.
432
+ const navigateFlight=(url,signal,routing,onPartial,options={})=>recoverNavigation(recoverySignal=>requestFlight(url,recoverySignal,routing,onPartial,{
433
+ ...options, ...(process.env.NODE_ENV === 'development' ? { developmentSupersession: signal } : {}),
434
+ }),{signal});
410
435
 
411
436
  const requestPartial = (typeof __PRNEXT_CACHE_COMPONENTS__ === 'undefined' ? cacheComponents : __PRNEXT_CACHE_COMPONENTS__) ? async function requestPartial(url, signal, full = false) {
412
437
  const response = await fetch(url.href, { credentials: 'same-origin', signal,
@@ -507,10 +532,10 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
507
532
 
508
533
  async function waitForDevelopmentUpdate() {
509
534
  // A generation updates the Flight loader before React Refresh registers
510
- // the replacement component families. An already-running action may finish
511
- // in that interval; rendering its model immediately remounts those client
512
- // components and loses layout state. Wait only on development bookkeeping,
513
- // never replay the mutation or cancel a newer user navigation.
535
+ // the replacement component families. An already-running Flight request
536
+ // may finish in that interval; rendering its model immediately can remount
537
+ // those client components and lose layout state. Wait only on development
538
+ // bookkeeping, never replay a request or cancel a newer user navigation.
514
539
  let development = globalThis.__PRNEXT_DEV__;
515
540
  while (development?.applying && development.applied) {
516
541
  await development.applied;
@@ -596,14 +621,19 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
596
621
  return outcome();
597
622
  }
598
623
 
599
- async function navigate(href, { replace = false, scroll = true, pop = false, savedScroll, savedRouting, refresh = false } = {}) {
600
- if (pageHidden || documentFallback) return;
624
+ async function navigate(href, { replace = false, scroll = true, pop = false, savedScroll, savedRouting, refresh = false, developmentSignal } = {}) {
625
+ if (developmentSignal?.aborted) return false;
626
+ if (pageHidden || documentFallback) return developmentSignal ? false : undefined;
601
627
  const target = navigationUrl(href);
602
628
  if (!isApplicationURL(target, window.location.origin, basePath) || isHtmlDocumentRoute(htmlOnlyRoutes, removeBasePath(target.pathname, basePath))) {
603
629
  fallbackDocument(target.href, replace);
604
- return;
630
+ return developmentSignal ? false : undefined;
605
631
  }
606
632
  const token = ++navigation;
633
+ // The compiler generation may remain valid when an ordinary navigation or
634
+ // Action replaces only its pending UI. This is distinct from a DOM commit,
635
+ // an owner cancellation, or a genuine failed request.
636
+ const superseded = () => developmentSignal && token !== navigation && !developmentSignal.aborted && !pageHidden && !documentFallback ? 'superseded' : false;
607
637
  const intent = { token, url: target.href, replace: replace || pop || refresh };
608
638
  pendingNavigation = intent;
609
639
  finishCommit?.();
@@ -615,7 +645,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
615
645
  activeRequest = undefined;
616
646
  if (!restored) {
617
647
  fallbackDocument(target.href, replace || pop || refresh);
618
- return;
648
+ return developmentSignal ? false : undefined;
619
649
  }
620
650
 
621
651
  function updateHistory(destination) {
@@ -639,7 +669,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
639
669
  updateHistory(target);
640
670
  if (scroll) scrollToTarget(target, savedScroll);
641
671
  if (pendingNavigation === intent) pendingNavigation = undefined;
642
- return;
672
+ return developmentSignal ? false : undefined;
643
673
  }
644
674
 
645
675
  const cacheKey = `${target.origin}${target.pathname}${target.search}`;
@@ -669,6 +699,8 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
669
699
  const abort = new AbortController();
670
700
  activeRequest = abort;
671
701
  result = await navigateFlight(target, abort.signal, currentRouting, async (response, destination) => {
702
+ if (process.env.NODE_ENV === 'development') await waitForDevelopmentUpdate();
703
+ if (token !== navigation || abort.signal.aborted) return;
672
704
  // A cached shell (including any cached authorization key) cannot
673
705
  // establish this request's live session identity. Strict account
674
706
  // isolation waits for the freshly authorized Flight model instead.
@@ -680,6 +712,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
680
712
  if (destination.pathname !== preview.url.pathname || destination.search !== preview.url.search) return;
681
713
  prepareLayoutCache(preview.model, currentModel);
682
714
  await appStyleManager(document).prepare(preview.model.css || [], abort.signal);
715
+ if (process.env.NODE_ENV === 'development') await waitForDevelopmentUpdate();
683
716
  if (token !== navigation || abort.signal.aborted) return;
684
717
  updateHistory(destination); historyUpdated = true;
685
718
  appStyleManager(document).handoff(preview.model.css || []);
@@ -692,19 +725,26 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
692
725
  // A refresh is already inside the compiler's apply transaction. Calling
693
726
  // ensureRoute from here can wait for that same transaction forever when
694
727
  // this tree has no routing state (the ordinary single-route case).
695
- result = await navigateFlight(target, abort.signal, pop && savedRouting ? { ...savedRouting, restore: true } : refresh && currentRouting ? { ...currentRouting, refresh: true } : currentRouting, undefined, {skipEnsure:refresh});
728
+ const signal = developmentSignal ? AbortSignal.any([abort.signal, developmentSignal]) : abort.signal;
729
+ result = await navigateFlight(target, signal, pop && savedRouting ? { ...savedRouting, restore: true } : refresh && currentRouting ? { ...currentRouting, refresh: true } : currentRouting, undefined, {skipEnsure:refresh,developmentSupersession:developmentSignal ? signal : undefined});
696
730
  }
697
- if (token !== navigation) return;
731
+ // Internal refreshes execute inside applyUpdate and must never wait on
732
+ // their own transaction. Ordinary navigations can already be in flight
733
+ // when an on-demand route publishes another client-module generation.
734
+ if (process.env.NODE_ENV === 'development' && !refresh) await waitForDevelopmentUpdate();
735
+ if (token !== navigation || activeRequest?.signal.aborted || developmentSignal?.aborted) return superseded();
698
736
  if (!isApplicationURL(result.url, window.location.origin, basePath)) throw new Error('The navigation destination requires a document request');
699
737
  if (result.model.rootLayout && currentModel.rootLayout && result.model.rootLayout !== currentModel.rootLayout) throw new Error('A different root layout requires a document request');
700
738
  if (requiresAuthorizedDocument(result.model)) {
701
739
  activeRequest?.abort();
702
740
  fallbackDocument(result.url.href, replace || pop || refresh);
703
- return;
741
+ return developmentSignal ? false : undefined;
704
742
  }
705
743
  prepareLayoutCache(result.model, currentModel);
706
- await appStyleManager(document).prepare(result.model.globalError ? [] : result.model.css || [], activeRequest?.signal);
707
- if (token !== navigation) return;
744
+ const styleSignal = developmentSignal && activeRequest ? AbortSignal.any([activeRequest.signal, developmentSignal]) : activeRequest?.signal;
745
+ await appStyleManager(document).prepare(result.model.globalError ? [] : result.model.css || [], styleSignal);
746
+ if (process.env.NODE_ENV === 'development' && !refresh) await waitForDevelopmentUpdate();
747
+ if (token !== navigation || activeRequest?.signal.aborted || developmentSignal?.aborted) return superseded();
708
748
  currentRouting = result.model.routing;
709
749
  currentModel = result.model;
710
750
  historyRouting = currentRouting;
@@ -718,21 +758,32 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
718
758
  }
719
759
  };
720
760
  appStyleManager(document).handoff(result.model.globalError ? [] : result.model.css || []);
761
+ let didCommit = false;
721
762
  if(process.env.NODE_ENV==='development'&&refresh)await new Promise(resolve=>{
722
- const finish=()=>{if(finishCommit===finish)finishCommit=undefined;resolve();};
763
+ const finish=()=>{developmentSignal?.removeEventListener('abort', finish);if(finishCommit===finish)finishCommit=undefined;resolve();};
723
764
  finishCommit=finish;
724
- renderView(result.model, result.url.href, { token, committed: () => { committed(); finish(); } });
765
+ developmentSignal?.addEventListener('abort', finish, { once: true });
766
+ if (developmentSignal?.aborted) { finish(); return; }
767
+ renderView(result.model, result.url.href, { token, kind: developmentSignal ? 'development-refresh' : 'navigation', committed: () => { didCommit = true; committed(); finish(); } });
725
768
  });
726
769
  else renderView(result.model, result.url.href, { token, committed });
770
+ if (!developmentSignal) return;
771
+ if (developmentSignal.aborted || pageHidden || documentFallback) return false;
772
+ return didCommit || superseded();
727
773
  } catch (error) {
728
- if (token !== navigation || error?.name === 'AbortError') return;
774
+ if (developmentSignal?.aborted) return false;
729
775
  if(error?.code==='PRNEXT_NAVIGATION_NETWORK'){
776
+ // A deadline failure is never evidence that only the UI was replaced.
777
+ if (token !== navigation) return false;
730
778
  window.dispatchEvent(new CustomEvent('prnext:navigation-error',{detail:{message:error.message}}));
731
- return;
779
+ return developmentSignal ? false : undefined;
732
780
  }
781
+ if (token !== navigation) return error?.name === 'AbortError' ? superseded() : false;
782
+ if (error?.name === 'AbortError') return false;
733
783
  // Pages Router routes, public files, and unavailable Flight endpoints use
734
784
  // a normal document navigation, including the browser's redirect handling.
735
785
  fallbackDocument(target.href, replace || pop || refresh);
786
+ return developmentSignal ? false : undefined;
736
787
  } finally {
737
788
  if (pendingNavigation === intent) pendingNavigation = undefined;
738
789
  if (finishNavigation === releaseNavigation) finishNavigation = undefined;
@@ -740,7 +791,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
740
791
  }
741
792
  }
742
793
 
743
- function purgeRetainedPages() {
794
+ function purgeRetainedPages({ synchronous = false } = {}) {
744
795
  if (pageHidden || documentFallback) return false;
745
796
  saveScroll();
746
797
  if (!abandonPendingView()) {
@@ -751,9 +802,14 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
751
802
  controller._retentionEpoch++;
752
803
  historyFocus.clear();
753
804
  if (committedView.model === currentModel && committedView.authorizationEpoch === controller._authorizationEpoch) committedView = { ...committedView, retentionEpoch: controller._retentionEpoch };
805
+ if (developmentView?.model === currentModel && developmentView.authorizationEpoch === controller._authorizationEpoch) developmentView = { ...developmentView, retentionEpoch: controller._retentionEpoch };
754
806
  // This only updates retention for the already committed model. It must
755
807
  // not become a new pending Flight view or a development commit barrier.
756
- startTransition(() => root.render(appView({ model: currentModel, controller, url: committedView.url, globalErrorComponent, globalErrorCss })));
808
+ const element = appView({ model: currentModel, controller, url: (developmentView || committedView).url, globalErrorComponent, globalErrorCss });
809
+ if (committedView.model === currentModel) committedView = { ...committedView, element };
810
+ if (developmentView?.model === currentModel) developmentView = { ...developmentView, element };
811
+ if (synchronous) flushSync(() => root.render(element));
812
+ else startTransition(() => root.render(element));
757
813
  return true;
758
814
  }
759
815
 
@@ -813,6 +869,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
813
869
  let resolveDevReady;
814
870
  const devReady=process.env.NODE_ENV==='development'?new Promise(resolve=>{resolveDevReady=resolve;}):undefined;
815
871
  const initialView = appView({ model: initialModel, controller, globalErrorComponent, globalErrorCss, ...(devReady?{committed:resolveDevReady}:{}) });
872
+ committedView.element = initialView;
816
873
  const rootOptions = {
817
874
  formState: initialModel.formState ?? null,
818
875
  onCaughtError(error) {
@@ -871,7 +928,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
871
928
  return { root, router: controller, ...(process.env.NODE_ENV === 'development' ? {
872
929
  devReady,
873
930
  devStyles: routes => developmentStyles(currentModel, routes).css || [],
874
- devPrepareStyles: routes => appStyleManager(document).prepare(developmentStyles(currentModel, routes).css || []),
931
+ devPrepareStyles: (routes, signal) => appStyleManager(document).prepare(developmentStyles(currentModel, routes).css || [], signal),
875
932
  devStylesApplied: (routes, { commit = true } = {}) => {
876
933
  for (const model of new Set([currentModel, committedView.model, pendingView?.model].filter(Boolean))) {
877
934
  const next = developmentStyles(model, routes);
@@ -880,7 +937,18 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
880
937
  if (commit) appStyleManager(document).commit(currentModel.globalError ? [] : currentModel.css || []);
881
938
  },
882
939
  devPrepare: options => moduleLoader.updateModules(options.clientModules),
883
- devCommit: () => {invalidatePrefetches();purgeRetainedPages();return navigate(new URL(window.location.href),{refresh:true});},
940
+ devBeforeRefresh: ({ navigation: routeOpening } = {}) => {
941
+ if (!routeOpening) { invalidatePrefetches(); return purgeRetainedPages({ synchronous: true }); }
942
+ // A cold route's module publication must not invalidate retained pages.
943
+ // Quiesce the old visible tree under its old families before Refresh,
944
+ // without changing epochs, cancelling the click, or committing its URL.
945
+ saveScroll();
946
+ if (!abandonPendingView()) { activeRequest?.abort(); fallbackDocument(currentUrl.href); return false; }
947
+ const retained = developmentView || committedView;
948
+ flushSync(() => root.render(retained.element));
949
+ return true;
950
+ },
951
+ devCommit: (_options, { signal } = {}) => navigate(new URL(window.location.href), { refresh: true, developmentSignal: signal }),
884
952
  } : {}) };
885
953
  }
886
954
 
@@ -15,7 +15,7 @@ import { workerMetadata, freezeClientModules, isImmutableClientModules } from '.
15
15
  import { workerArtifacts } from './worker-artifacts.mjs';
16
16
  import path from 'node:path';
17
17
  import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES } from './app-prefetch.mjs';
18
- import { pathToFileURL } from 'node:url';
18
+ import { pathToFileURL, fileURLToPath } from 'node:url';
19
19
  import { createRequire } from 'node:module';
20
20
  import { AppRouterProvider } from '../compat/app-context.cjs';
21
21
  import { appContent } from './app-content.mjs';
@@ -243,19 +243,44 @@ function getWorker(production) {
243
243
  return current;
244
244
  }
245
245
  export async function prewarmAppRenderer(options){
246
+ if(!Number.isFinite(options?.timeoutMs)||options.timeoutMs<=0||options.timeoutMs>30000||!Number.isFinite(options?.maxRssMb)||options.maxRssMb<=0||options.maxRssMb>2048)throw new TypeError('Invalid renderer prewarm budget');
247
+ const deadline=performance.now()+options.timeoutMs;
248
+ const remaining=()=>{
249
+ options.signal?.throwIfAborted();
250
+ if(performance.now()>=deadline||process.memoryUsage().rss>=options.maxRssMb*1024*1024)throw new Error('Renderer prewarm budget exhausted');
251
+ return Math.max(1,deadline-performance.now());
252
+ };
253
+ remaining();
246
254
  flightDecoder(true);
247
- await preloadAppRoutes([],options);
255
+ // The progressive HTML renderer is otherwise imported by the first document
256
+ // request. This prepares framework code only, never an application route.
257
+ htmlRuntime ??= await import('./app-stream-html.mjs');
258
+ const flight=await preloadAppRoutes([],{maxRssMb:options.maxRssMb,timeoutMs:remaining(),signal:options.signal,frameworkWarmup:true});
259
+ remaining();
260
+ if(!(flight instanceof ArrayBuffer)||flight.byteLength>65536)throw new Error('Invalid renderer prewarm Flight');
261
+ const body=new ReadableStream({start(controller){controller.enqueue(new Uint8Array(flight));controller.close();}});
262
+ let response,htmlBytes=0;
263
+ try{
264
+ response=await htmlRuntime.renderProgressiveAppHtml({result:{body,status:200},request:{production:true,method:'GET',clientModules:{},distDir:path.dirname(fileURLToPath(import.meta.url)),strictMode:true},route:{},responseHeaders:{},decodeFlight,signal:options.signal,timeoutMs:remaining()});
265
+ for await(const chunk of response.body){remaining();htmlBytes+=chunk.byteLength;if(htmlBytes>65536)throw new Error('Renderer prewarm HTML exceeds its byte budget');}
266
+ remaining();
267
+ return{flightBytes:flight.byteLength,htmlBytes,synthetic:true};
268
+ }finally{await response?.cancel();}
248
269
  }
249
270
  export async function preloadAppRoutes(files,options){
271
+ const {signal,...wireOptions}=options;
272
+ signal?.throwIfAborted();
250
273
  const worker=getWorker(true),id=`preload-${++nextId}`;
251
274
  worker.ref();
252
- try{await new Promise((resolve,reject)=>{
253
- const clean=()=>{clearTimeout(timer);worker.off('message',message);worker.off('error',error);worker.off('exit',exit);};
254
- const message=value=>{if(value.type==='preloaded'&&value.id===id){clean();value.error?reject(new Error(value.error)):resolve();}};
275
+ try{return await new Promise((resolve,reject)=>{
276
+ const clean=()=>{clearTimeout(timer);worker.off('message',message);worker.off('error',error);worker.off('exit',exit);signal?.removeEventListener('abort',cancel);};
277
+ const message=value=>{if(value.type==='preloaded'&&value.id===id){clean();value.error?reject(new Error(value.error)):resolve(value.frameworkFlight);}};
255
278
  const error=value=>{clean();reject(value);};const exit=()=>error(new Error('RSC worker exited while preloading'));
279
+ const cancel=()=>{try{worker.postMessage({type:'preload-cancel',id});}catch{}finally{error(signal.reason);}};
256
280
  const timer=setTimeout(()=>{clean();void lifecycles.get(worker)?.terminate(new Error('RSC preload timed out'));reject(new Error('RSC preload timed out'));},options.timeoutMs);
257
281
  worker.on('message',message);worker.once('error',error);worker.once('exit',exit);
258
- worker.postMessage({type:'preload',id,value:{files,options}});
282
+ signal?.addEventListener('abort',cancel,{once:true});
283
+ try{worker.postMessage({type:'preload',id,value:{files,options:wireOptions}});}catch(failure){error(failure);}
259
284
  });}finally{if(!hasPending(worker)&&!lifecycles.get(worker)?.retiring)worker.unref();}
260
285
  }
261
286
 
@@ -12,6 +12,8 @@ export function workerStreamChannel(port, id) {
12
12
  let ended = false;
13
13
  let previousSignal;
14
14
  let combinedSignal;
15
+ let chunkMessage;
16
+ let transfer;
15
17
  const wait = async signal => {
16
18
  while (!credit) {
17
19
  signal.throwIfAborted();
@@ -43,11 +45,22 @@ export function workerStreamChannel(port, id) {
43
45
  if (!Buffer.isBuffer(bytes) && offset === 0 && length === bytes.byteLength && bytes.buffer.byteLength <= APP_STREAM_CHUNK_BYTES) chunk = bytes;
44
46
  else { chunk = new Uint8Array(length); chunk.set(bytes.subarray(offset, offset + length)); }
45
47
  const last = offset + length === bytes.byteLength;
46
- const message = { id, type: metadata ? 'start' : 'chunk', ...metadata,
47
- chunk: chunk.buffer, byteOffset: chunk.byteOffset, byteLength: chunk.byteLength, done: done && last };
48
+ // MessagePort serializes synchronously. Reuse this request's ordinary
49
+ // chunk envelope and transfer list after the first frame rather than
50
+ // allocating both for every chunk. The one-frame case keeps its old
51
+ // allocation count; no envelope or payload is shared across requests.
52
+ const message = metadata ? { id, type: 'start', ...metadata }
53
+ : chunkMessage ??= { id, type: 'chunk' };
54
+ const complete = done && last;
55
+ message.chunk = chunk.buffer;
56
+ message.byteOffset = chunk.byteOffset;
57
+ message.byteLength = chunk.byteLength;
58
+ message.done = complete;
48
59
  metadata = undefined;
49
- port.postMessage(message, [chunk.buffer]);
50
- if (message.done) ended = true;
60
+ (transfer ??= [undefined])[0] = chunk.buffer;
61
+ try { port.postMessage(message, transfer); }
62
+ finally { transfer[0] = message.chunk = undefined; }
63
+ if (complete) ended = true;
51
64
  }
52
65
  },
53
66
  end() {
@@ -1,5 +1,5 @@
1
1
  import {reportRequestError,acknowledgeRequestError} from './instrumentation.mjs';
2
- import { htmlEnvelope } from './html-envelope.mjs';
2
+ import { htmlEnvelope, flightBootstrap } from './html-envelope.mjs';
3
3
  import React from 'react';
4
4
  import { fontPreloads } from './font-preload.mjs';
5
5
  import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
@@ -16,11 +16,14 @@ import { streamSelector } from './stream-select.mjs';
16
16
  import {nodeHtmlReader,useNodeHtml} from './node-html-stream.mjs';
17
17
 
18
18
  import {flightTap} from './flight-tap.mjs';
19
+ import {createFlightMeasurement} from './flight-diagnostics.mjs';
19
20
 
20
21
  export async function renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal, timeoutMs, partial }) {
21
22
  timeoutMs ??= route?.cacheConfig?.maxDuration ? route.cacheConfig.maxDuration * 1000 : 25_000;
22
23
  const nonceAttribute = request.nonce ? ` nonce="${escapeHtml(request.nonce)}"` : '';
23
- const tap = flightTap(result.body);
24
+ const measurement = createFlightMeasurement(request, 'ssr');
25
+ const tap = flightTap(result.body, measurement);
26
+ let status = result.status;
24
27
  const abort = new AbortController();
25
28
  const timeout = setTimeout(() => abort.abort(appTimeoutError('HTML stream', timeoutMs)), timeoutMs);
26
29
  const combinedSignal = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
@@ -29,7 +32,11 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
29
32
  let committed = Boolean(partial?.shellSent);
30
33
  let control;
31
34
  let model;
35
+ let bootstrapFlight;
36
+ const failed = error => measurement?.finish(error?.statusCode === 504 || combinedSignal.reason?.statusCode === 504
37
+ ? 'timedOut' : combinedSignal.aborted ? 'cancelled' : 'failed', status);
32
38
  const cancel = async reason => {
39
+ measurement?.finish(reason?.statusCode === 504 ? 'timedOut' : 'cancelled', status);
33
40
  clearTimeout(timeout);
34
41
  combinedSignal.removeEventListener('abort', onAbort);
35
42
  if (!abort.signal.aborted) abort.abort(reason || new Error('App Router response was cancelled'));
@@ -39,6 +46,7 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
39
46
  combinedSignal.addEventListener('abort', onAbort, { once: true });
40
47
  try {
41
48
  model = await decodeFlight(tap.body, request.clientModules, request.distDir, { production: request.production });
49
+ measurement?.mark('modelReady');
42
50
  const renderOptions = {
43
51
  signal: combinedSignal,
44
52
  nonce: request.nonce,
@@ -81,15 +89,17 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
81
89
  }
82
90
  const styles = fontPreloads(route.fonts, request.nonce) + (model.css ?? route.css ?? []).map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
83
91
  if (styles && !partial) prefix = prefix.replace(/<\/head>/i, `${styles}</head>`);
92
+ measurement?.mark('shellReady');
84
93
  tap.startOutput();
85
94
  committed = true;
86
95
  const body = (async function* () {
87
96
  let total = 0;
88
97
  const envelope = htmlEnvelope({ resumed: Boolean(partial) });
89
98
  const encode = value => {
90
- const bytes = Buffer.from(value);
99
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
91
100
  total += bytes.byteLength;
92
101
  if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
102
+ if (measurement) measurement.responseBytes = total;
93
103
  return bytes;
94
104
  };
95
105
  let flightDone = false;
@@ -116,9 +126,8 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
116
126
  } else {
117
127
  if (next.done) { flightDone = true; }
118
128
  else {
119
- const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
120
129
  selection.watch('flight', tap.next());
121
- yield encode(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
130
+ yield encode((bootstrapFlight ??= flightBootstrap(nonceAttribute))(next.value));
122
131
  }
123
132
  }
124
133
  if (control) {
@@ -128,6 +137,10 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
128
137
  }
129
138
  }
130
139
  yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + envelope.finish());
140
+ measurement?.finish('completed', status);
141
+ } catch (error) {
142
+ failed(error);
143
+ throw error;
131
144
  } finally {
132
145
  selection.close();
133
146
  combinedSignal.removeEventListener('abort', onAbort);
@@ -136,7 +149,7 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
136
149
  })();
137
150
  // An error encoded in Flight may be contained by a valid Suspense shell.
138
151
  // Ordinary GET errors select HTTP 500 only when that shell also fails.
139
- const status = result.rscError && result.status === 500 && ['GET', 'HEAD'].includes(request.method) ? 200 : result.status;
152
+ status = result.rscError && result.status === 500 && ['GET', 'HEAD'].includes(request.method) ? 200 : result.status;
140
153
  return { status, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
141
154
  } catch (error) {
142
155
  if (!committed && !combinedSignal.aborted && error?.statusCode !== 504 &&
@@ -151,13 +164,16 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
151
164
  (route.client ? `<script type="module" async${nonceAttribute} src="${escapeHtml(route.client)}"></script>` : '');
152
165
  const prefix = shell.slice(0, end) + bootstrap;
153
166
  const closing = shell.slice(end);
167
+ status = route.parallel && navigationResponse(error)?.status === 404 ? 404 : 500;
168
+ measurement?.mark('shellReady');
154
169
  tap.startOutput();
155
170
  const body = (async function* () {
156
171
  let total = 0;
157
172
  const encode = value => {
158
- const bytes = Buffer.from(value);
173
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
159
174
  total += bytes.byteLength;
160
175
  if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
176
+ if (measurement) measurement.responseBytes = total;
161
177
  return bytes;
162
178
  };
163
179
  try {
@@ -166,18 +182,21 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
166
182
  combinedSignal.throwIfAborted();
167
183
  const next = await tap.next();
168
184
  if (next.done) break;
169
- const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
170
- yield encode(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
185
+ yield encode((bootstrapFlight ??= flightBootstrap(nonceAttribute))(next.value));
171
186
  }
172
187
  yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + closing);
173
- } finally { await cancel(); }
188
+ measurement?.finish('completed', status);
189
+ } catch (failure) { failed(failure); throw failure; }
190
+ finally { await cancel(); }
174
191
  })();
175
- return { status: route.parallel && navigationResponse(error)?.status === 404 ? 404 : 500, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
192
+ return { status, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
176
193
  } catch (recoveryError) {
194
+ failed(recoveryError);
177
195
  await cancel(recoveryError);
178
196
  throw combinedSignal.reason?.statusCode === 504 ? combinedSignal.reason : recoveryError;
179
197
  }
180
198
  }
199
+ failed(error);
181
200
  combinedSignal.removeEventListener('abort', onAbort);
182
201
  await cancel(error);
183
202
  if (combinedSignal.reason?.statusCode === 504) throw combinedSignal.reason;
@@ -4,6 +4,7 @@ import { actionManifest, registerCacheClientModule } from './action-server.mjs';
4
4
  import { codecSymbol } from '../compat/use-cache.cjs';
5
5
  import { encodeCacheArguments } from './cache-arguments.mjs';
6
6
  import { flightBundler } from './worker-metadata.mjs';
7
+ import {primitiveCacheResult,flightCacheResult,isCacheResultPacket,readCacheResult,CACHE_RESULT_KEY_VERSION,CACHE_RESULT_LIMIT} from './cache-result.mjs';
7
8
  const consumers = new WeakMap();
8
9
  const emptyModules = Object.freeze({});
9
10
 
@@ -32,6 +33,7 @@ export function flightConsumer(context) {
32
33
  }
33
34
 
34
35
  globalThis[codecSymbol] = {
36
+ cacheKeyVersion:CACHE_RESULT_KEY_VERSION,
35
37
  async arguments(values, context) {
36
38
  const temporaryReferences = clientReferences();
37
39
  const body = await encodeReply(values, { temporaryReferences, signal: context.signal });
@@ -41,27 +43,45 @@ globalThis[codecSymbol] = {
41
43
  return { key, values: decoded, temporaryReferences, serverTemporaryReferences };
42
44
  },
43
45
  async render(callback, encoded, context) {
46
+ context.signal?.throwIfAborted();
44
47
  const bundler = flightBundler(context.clientModules, false);
45
48
  let failure;
46
49
  // React calls then inside its rendering context, preserving React.cache isolation.
47
50
  // React can subscribe more than once while unwrapping a thenable. Start
48
51
  // lazily in its rendering context, but never execute the producer twice.
49
- let execution;
50
- const result = { then(resolve, reject) { (execution ||= Promise.resolve().then(callback)).then(resolve, reject); } };
52
+ let execution,checked=false,primitive;
53
+ const result = { then(resolve, reject) { (execution ||= Promise.resolve().then(callback)).then(value=>{
54
+ try{
55
+ context.signal?.throwIfAborted();
56
+ if(!checked){primitive=primitiveCacheResult(value);checked=true;}
57
+ // The producer still runs inside the official renderer exactly once.
58
+ // Do not return the original object through another Promise: that would
59
+ // inspect a user-defined then getter again before React receives it.
60
+ resolve(primitive?null:value);
61
+ }catch(error){reject(error);}
62
+ },reject); } };
51
63
  const stream = renderToReadableStream(result, bundler, { signal: context.signal, temporaryReferences: encoded.serverTemporaryReferences, onError(error) { failure ||= error; return 'PRNextCacheRenderError'; } });
52
64
  const chunks = [];
53
65
  let size = 0;
54
66
  for await (const value of stream) {
55
67
  size += value.byteLength;
56
68
  if (size > 16 * 1024 * 1024) throw new Error('Cached React result exceeds the 16 MiB rendering limit');
57
- chunks.push(Buffer.from(value));
69
+ // React exposes an official type:'bytes' Web stream: each delivered
70
+ // Uint8Array owns its storage. Keep it until the sole packet aggregate;
71
+ // copy Buffers/other representations defensively instead.
72
+ chunks.push(value instanceof Uint8Array&&!Buffer.isBuffer(value)?value:Buffer.from(value));
58
73
  }
59
74
  if (failure) throw failure;
60
- // Each chunk above is already an owned copy. A single chunk needs no
61
- // second allocation; never expose the producer's mutable stream storage.
62
- return chunks.length===1?chunks[0]:Buffer.concat(chunks, size);
75
+ context.signal?.throwIfAborted();
76
+ // The packet always owns a final aggregate; no producer storage escapes.
77
+ return primitive||flightCacheResult(chunks,size);
63
78
  },
64
79
  async decode(bytes, encoded, context) {
80
+ if(isCacheResultPacket(bytes)){
81
+ const result=readCacheResult(bytes);
82
+ if(!result.flight)return result.value;
83
+ bytes=result.flight;
84
+ }else if(bytes.length>CACHE_RESULT_LIMIT)throw new Error('Invalid RSC cache result envelope');
65
85
  return createFromReadableStream(new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }), {
66
86
  serverConsumerManifest: flightConsumer(context), temporaryReferences: encoded.temporaryReferences, replayConsoleLogs: false,
67
87
  });