@rsc-kit/core 0.20.6 → 0.20.8

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 (65) hide show
  1. package/dist/cache.d.ts +11 -0
  2. package/dist/cache.js +13 -0
  3. package/dist/cache.js.map +1 -1
  4. package/dist/clientEntries.js +4 -1
  5. package/dist/clientEntries.js.map +1 -1
  6. package/dist/files.d.ts +38 -0
  7. package/dist/files.js +109 -0
  8. package/dist/files.js.map +1 -1
  9. package/dist/host.d.ts +11 -2
  10. package/dist/host.js +37 -6
  11. package/dist/host.js.map +1 -1
  12. package/dist/js/ActivityRouter.d.ts +11 -0
  13. package/dist/js/ActivityRouter.js +24 -4
  14. package/dist/js/ActivityRouter.js.map +1 -1
  15. package/dist/js/Form.js +24 -2
  16. package/dist/js/Form.js.map +1 -1
  17. package/dist/js/Link.d.ts +1 -1
  18. package/dist/js/Link.js +25 -9
  19. package/dist/js/Link.js.map +1 -1
  20. package/dist/js/PageTransition.d.ts +27 -0
  21. package/dist/js/PageTransition.js +57 -0
  22. package/dist/js/PageTransition.js.map +1 -0
  23. package/dist/js/PathnameProvider.d.ts +15 -2
  24. package/dist/js/PathnameProvider.js +17 -2
  25. package/dist/js/PathnameProvider.js.map +1 -1
  26. package/dist/js/SegmentBoundary.js +27 -3
  27. package/dist/js/SegmentBoundary.js.map +1 -1
  28. package/dist/js/activityMarkers.d.ts +7 -0
  29. package/dist/js/activityMarkers.js +18 -0
  30. package/dist/js/activityMarkers.js.map +1 -0
  31. package/dist/js/createViteRscApp.js +122 -76
  32. package/dist/js/createViteRscApp.js.map +1 -1
  33. package/dist/js/earlyClicks.d.ts +3 -1
  34. package/dist/js/earlyClicks.js +28 -5
  35. package/dist/js/earlyClicks.js.map +1 -1
  36. package/dist/js/errors.d.ts +2 -0
  37. package/dist/js/errors.js +11 -0
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/imagePreload.d.ts +37 -0
  40. package/dist/js/imagePreload.js +116 -0
  41. package/dist/js/imagePreload.js.map +1 -0
  42. package/dist/js/navigate.d.ts +47 -1
  43. package/dist/js/navigate.js +249 -10
  44. package/dist/js/navigate.js.map +1 -1
  45. package/dist/js/perf.d.ts +25 -0
  46. package/dist/js/perf.js +57 -0
  47. package/dist/js/perf.js.map +1 -0
  48. package/dist/js/router.d.ts +10 -1
  49. package/dist/js/router.js +9 -2
  50. package/dist/js/router.js.map +1 -1
  51. package/dist/js/segmentStore.d.ts +52 -0
  52. package/dist/js/segmentStore.js +123 -2
  53. package/dist/js/segmentStore.js.map +1 -1
  54. package/dist/js/staleAssets.d.ts +10 -0
  55. package/dist/js/staleAssets.js +23 -2
  56. package/dist/js/staleAssets.js.map +1 -1
  57. package/dist/js/viewportPrefetch.d.ts +19 -10
  58. package/dist/js/viewportPrefetch.js +50 -15
  59. package/dist/js/viewportPrefetch.js.map +1 -1
  60. package/dist/shellHead.d.ts +22 -0
  61. package/dist/shellHead.js +43 -0
  62. package/dist/shellHead.js.map +1 -0
  63. package/dist/vite.js +250 -55
  64. package/dist/vite.js.map +1 -1
  65. package/package.json +5 -1
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * Whether this browser rasterises a transition snapshot at full element
4
+ * height. Apple's vendor string is WebKit's, wherever WebKit runs: Safari,
5
+ * every iOS browser, and WebKitGTK (Epiphany) too - all of them charge by
6
+ * snapshot size, so all of them are right to skip. Not a bug to "fix".
7
+ */
8
+ export declare function snapshotsWholePage(): boolean;
9
+ /** Whether the visitor asked for no motion; a transition then costs a snapshot for nothing. */
10
+ export declare function prefersReducedMotion(): boolean;
11
+ /** The rules a page fade needs, for the class the navigation carries. */
12
+ export declare function pageTransitionCss(transition: string, durationMs: number): string;
13
+ export declare function PageTransition({ children, className, transition, duration, webkit, }: {
14
+ children: ReactNode;
15
+ /** On the wrapper element the pair is made of; carry the body's layout here. */
16
+ className?: string;
17
+ /** The view-transition class a navigation gets, for CSS of your own on top. */
18
+ transition?: string;
19
+ /** How long the fade runs, in milliseconds. The group and root pairs match it. */
20
+ duration?: number;
21
+ /**
22
+ * What WebKit gets. `skip` for an app with long pages - the default,
23
+ * since a landing page usually is one; `run` for an app whose pages are
24
+ * a screen tall, where the snapshot is small and the fade is fine.
25
+ */
26
+ webkit?: "skip" | "run";
27
+ }): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The page-level view transition, with WebKit left out of it.
5
+ *
6
+ * WebKit - Safari, and every browser on iOS, Chrome included - rasterises
7
+ * the outgoing snapshot at the element's full height rather than the
8
+ * viewport's. A landing page 12,000 px tall is 44 megapixels at 3×: a port
9
+ * measured 600-900 ms with the screen frozen on every navigation away from
10
+ * it, and the memory can have iOS discard the tab, which comes back as a
11
+ * reload nobody asked for. Chromium caps the snapshot, so a desktop never
12
+ * shows it. Other sites' transitions work on the same phone because they
13
+ * name the root, which the spec clips to the viewport; React's
14
+ * <ViewTransition> names the element it wraps, and the page is tall.
15
+ *
16
+ * So the cross-fade runs where it is cheap and is skipped where it is not.
17
+ * Decided after mount, from navigator.vendor - a server render has no
18
+ * navigator, and a transition class only matters on an update, so the
19
+ * first render agrees with the server either way. Skipped, React starts
20
+ * no transition at all: no snapshot, no overlay, nothing to wait for.
21
+ *
22
+ * The CSS the fade needs travels with it, because every app rediscovered
23
+ * the same three lines on a phone: the group and root pairs keep the
24
+ * browser's 250 ms whatever the page pair is set to, so a "120 ms" fade
25
+ * ran 290; the pseudo-element tree sits over the document while it runs
26
+ * and swallows a second tap; and reduced motion should mean none.
27
+ */
28
+ import { ViewTransition, useEffect, useState } from "react";
29
+ /**
30
+ * Whether this browser rasterises a transition snapshot at full element
31
+ * height. Apple's vendor string is WebKit's, wherever WebKit runs: Safari,
32
+ * every iOS browser, and WebKitGTK (Epiphany) too - all of them charge by
33
+ * snapshot size, so all of them are right to skip. Not a bug to "fix".
34
+ */
35
+ export function snapshotsWholePage() {
36
+ return typeof navigator !== "undefined" && navigator.vendor === "Apple Computer, Inc.";
37
+ }
38
+ /** Whether the visitor asked for no motion; a transition then costs a snapshot for nothing. */
39
+ export function prefersReducedMotion() {
40
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
41
+ }
42
+ /** The rules a page fade needs, for the class the navigation carries. */
43
+ export function pageTransitionCss(transition, durationMs) {
44
+ return (`::view-transition-old(.${transition}),::view-transition-new(.${transition}),` +
45
+ `::view-transition-group(*),::view-transition-old(root),::view-transition-new(root){animation-duration:${durationMs}ms}` +
46
+ `::view-transition{pointer-events:none}` +
47
+ `@media (prefers-reduced-motion:reduce){::view-transition-old(*),::view-transition-new(*),::view-transition-group(*){animation:none}}`);
48
+ }
49
+ export function PageTransition({ children, className, transition = "page", duration = 120, webkit = "skip", }) {
50
+ const [skip, setSkip] = useState(false);
51
+ useEffect(() => {
52
+ if ((webkit === "skip" && snapshotsWholePage()) || prefersReducedMotion())
53
+ setSkip(true);
54
+ }, [webkit]);
55
+ return (_jsx(ViewTransition, { default: { "rsc-navigation": skip ? "none" : transition, default: "none" }, children: _jsxs("div", { className: className, children: [_jsx("style", { href: `rsc-kit-page-transition-${transition}`, precedence: "rsc-kit", children: pageTransitionCss(transition, duration) }), children] }) }));
56
+ }
57
+ //# sourceMappingURL=PageTransition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PageTransition.js","sourceRoot":"","sources":["../../src/js/PageTransition.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG5D;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB;IAChC,OAAO,OAAO,SAAS,KAAK,WAAW,IAAI,SAAS,CAAC,MAAM,KAAK,sBAAsB,CAAC;AACzF,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,oBAAoB;IAClC,OAAO,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,MAAM,CAAC,UAAU,KAAK,UAAU,IAAI,MAAM,CAAC,UAAU,CAAC,kCAAkC,CAAC,CAAC,OAAO,CAAC;AACnJ,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,iBAAiB,CAAC,UAAkB,EAAE,UAAkB;IACtE,OAAO,CACL,0BAA0B,UAAU,4BAA4B,UAAU,IAAI;QAC9E,yGAAyG,UAAU,KAAK;QACxH,wCAAwC;QACxC,sIAAsI,CACvI,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,EAC7B,QAAQ,EACR,SAAS,EACT,UAAU,GAAG,MAAM,EACnB,QAAQ,GAAG,GAAG,EACd,MAAM,GAAG,MAAM,GAehB;IACC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAExC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,MAAM,KAAK,MAAM,IAAI,kBAAkB,EAAE,CAAC,IAAI,oBAAoB,EAAE;YAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3F,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAEb,OAAO,CACL,KAAC,cAAc,IAAC,OAAO,EAAE,EAAE,gBAAgB,EAAE,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,YACxF,eAAK,SAAS,EAAE,SAAS,aACvB,gBAAO,IAAI,EAAE,2BAA2B,UAAU,EAAE,EAAE,UAAU,EAAC,SAAS,YACvE,iBAAiB,CAAC,UAAU,EAAE,QAAQ,CAAC,GAClC,EACP,QAAQ,IACL,GACS,CAClB,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The page-level view transition, with WebKit left out of it.\n *\n * WebKit - Safari, and every browser on iOS, Chrome included - rasterises\n * the outgoing snapshot at the element's full height rather than the\n * viewport's. A landing page 12,000 px tall is 44 megapixels at 3×: a port\n * measured 600-900 ms with the screen frozen on every navigation away from\n * it, and the memory can have iOS discard the tab, which comes back as a\n * reload nobody asked for. Chromium caps the snapshot, so a desktop never\n * shows it. Other sites' transitions work on the same phone because they\n * name the root, which the spec clips to the viewport; React's\n * <ViewTransition> names the element it wraps, and the page is tall.\n *\n * So the cross-fade runs where it is cheap and is skipped where it is not.\n * Decided after mount, from navigator.vendor - a server render has no\n * navigator, and a transition class only matters on an update, so the\n * first render agrees with the server either way. Skipped, React starts\n * no transition at all: no snapshot, no overlay, nothing to wait for.\n *\n * The CSS the fade needs travels with it, because every app rediscovered\n * the same three lines on a phone: the group and root pairs keep the\n * browser's 250 ms whatever the page pair is set to, so a \"120 ms\" fade\n * ran 290; the pseudo-element tree sits over the document while it runs\n * and swallows a second tap; and reduced motion should mean none.\n */\n\nimport { ViewTransition, useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Whether this browser rasterises a transition snapshot at full element\n * height. Apple's vendor string is WebKit's, wherever WebKit runs: Safari,\n * every iOS browser, and WebKitGTK (Epiphany) too - all of them charge by\n * snapshot size, so all of them are right to skip. Not a bug to \"fix\".\n */\nexport function snapshotsWholePage(): boolean {\n return typeof navigator !== \"undefined\" && navigator.vendor === \"Apple Computer, Inc.\";\n}\n\n/** Whether the visitor asked for no motion; a transition then costs a snapshot for nothing. */\nexport function prefersReducedMotion(): boolean {\n return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/** The rules a page fade needs, for the class the navigation carries. */\nexport function pageTransitionCss(transition: string, durationMs: number): string {\n return (\n `::view-transition-old(.${transition}),::view-transition-new(.${transition}),` +\n `::view-transition-group(*),::view-transition-old(root),::view-transition-new(root){animation-duration:${durationMs}ms}` +\n `::view-transition{pointer-events:none}` +\n `@media (prefers-reduced-motion:reduce){::view-transition-old(*),::view-transition-new(*),::view-transition-group(*){animation:none}}`\n );\n}\n\nexport function PageTransition({\n children,\n className,\n transition = \"page\",\n duration = 120,\n webkit = \"skip\",\n}: {\n children: ReactNode;\n /** On the wrapper element the pair is made of; carry the body's layout here. */\n className?: string;\n /** The view-transition class a navigation gets, for CSS of your own on top. */\n transition?: string;\n /** How long the fade runs, in milliseconds. The group and root pairs match it. */\n duration?: number;\n /**\n * What WebKit gets. `skip` for an app with long pages - the default,\n * since a landing page usually is one; `run` for an app whose pages are\n * a screen tall, where the snapshot is small and the fade is fine.\n */\n webkit?: \"skip\" | \"run\";\n}) {\n const [skip, setSkip] = useState(false);\n\n useEffect(() => {\n if ((webkit === \"skip\" && snapshotsWholePage()) || prefersReducedMotion()) setSkip(true);\n }, [webkit]);\n\n return (\n <ViewTransition default={{ \"rsc-navigation\": skip ? \"none\" : transition, default: \"none\" }}>\n <div className={className}>\n <style href={`rsc-kit-page-transition-${transition}`} precedence=\"rsc-kit\">\n {pageTransitionCss(transition, duration)}\n </style>\n {children}\n </div>\n </ViewTransition>\n );\n}\n"]}
@@ -1,7 +1,20 @@
1
1
  import type { ReactNode } from "react";
2
+ /**
3
+ * `value` is null where the url is not known: the shell of a route that
4
+ * listed no urls, rendered once for every url it matches, the holes that
5
+ * resume it, and the payload a document served from that shell boots from.
6
+ * The hooks answer "" then - no link active, no breadcrumb - and
7
+ * usePathname, a useSyncExternalStore, moves to the browser's url the
8
+ * moment hydration is done. The three renders agree with each other, which
9
+ * is what hydration needs; the browser knows the rest.
10
+ */
2
11
  export declare function PathnameProvider({ value, children }: {
3
- value: string;
12
+ value: string | null;
4
13
  children: ReactNode;
5
14
  }): import("react").JSX.Element;
6
- /** What the server rendered. On the client this is the url it was hydrated with. */
15
+ /**
16
+ * What the server rendered, or "" where it could not know. On the client
17
+ * this is what the payload said, so hydration matches the document; the
18
+ * live url is usePathname's, from the browser.
19
+ */
7
20
  export declare function useRenderedPathname(): string;
@@ -13,7 +13,18 @@ import { jsx as _jsx } from "react/jsx-runtime";
13
13
  * in the same process, and a variable would hand one page the other's url.
14
14
  */
15
15
  import { createContext, useContext, useEffect } from "react";
16
+ // "/" with no provider at all - a component rendered outside the tree, a test.
17
+ // null from a provider means the url is not known: see below.
16
18
  const PathnameContext = createContext("/");
19
+ /**
20
+ * `value` is null where the url is not known: the shell of a route that
21
+ * listed no urls, rendered once for every url it matches, the holes that
22
+ * resume it, and the payload a document served from that shell boots from.
23
+ * The hooks answer "" then - no link active, no breadcrumb - and
24
+ * usePathname, a useSyncExternalStore, moves to the browser's url the
25
+ * moment hydration is done. The three renders agree with each other, which
26
+ * is what hydration needs; the browser knows the rest.
27
+ */
17
28
  export function PathnameProvider({ value, children }) {
18
29
  // The outermost client component on every page with a runtime, so its
19
30
  // first effect is the moment hydration has committed - the moment a
@@ -30,8 +41,12 @@ export function PathnameProvider({ value, children }) {
30
41
  }, []);
31
42
  return _jsx(PathnameContext.Provider, { value: value, children: children });
32
43
  }
33
- /** What the server rendered. On the client this is the url it was hydrated with. */
44
+ /**
45
+ * What the server rendered, or "" where it could not know. On the client
46
+ * this is what the payload said, so hydration matches the document; the
47
+ * live url is usePathname's, from the browser.
48
+ */
34
49
  export function useRenderedPathname() {
35
- return useContext(PathnameContext);
50
+ return useContext(PathnameContext) ?? "";
36
51
  }
37
52
  //# sourceMappingURL=PathnameProvider.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"PathnameProvider.js","sourceRoot":"","sources":["../../src/js/PathnameProvider.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAG7D,MAAM,eAAe,GAAG,aAAa,CAAS,GAAG,CAAC,CAAC;AAEnD,MAAM,UAAU,gBAAgB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAA0C;IAC1F,sEAAsE;IACtE,oEAAoE;IACpE,mEAAmE;IACnE,yEAAyE;IACzE,sEAAsE;IACtE,qBAAqB;IACrB,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,CAAC,GAAG,MAAsE,CAAC;QAEjF,IAAI,CAAC,CAAC,cAAc;YAAE,OAAO;QAE7B,CAAC,CAAC,cAAc,GAAG,IAAI,CAAC;QACxB,CAAC,CAAC,iBAAiB,EAAE,EAAE,CAAC;IAC1B,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,KAAC,eAAe,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAAG,QAAQ,GAA4B,CAAC;AACvF,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,mBAAmB;IACjC,OAAO,UAAU,CAAC,eAAe,CAAC,CAAC;AACrC,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The url the server rendered, handed to the client hooks.\n *\n * usePathname used to answer \"/\" during a server render, because a hook has no\n * props and the module had nothing else to go on. Every prerendered page\n * therefore shipped with the wrong link marked active, corrected only once\n * hydration ran — a visible flash, and simply wrong on a route that ships no\n * client runtime at all.\n *\n * A context rather than a module-level variable: two requests render at once\n * in the same process, and a variable would hand one page the other's url.\n */\n\nimport { createContext, useContext, useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\n\nconst PathnameContext = createContext<string>(\"/\");\n\nexport function PathnameProvider({ value, children }: { value: string; children: ReactNode }) {\n // The outermost client component on every page with a runtime, so its\n // first effect is the moment hydration has committed - the moment a\n // click on a <Link> is React's to handle. Until then the bootstrap\n // script's listener holds the taps; see earlyClicks. Announced through a\n // global rather than a module, because this component and the runtime\n // are bundled apart.\n useEffect(() => {\n const w = window as { __rsc_hydrated?: boolean; __rsc_on_hydrated?: () => void };\n\n if (w.__rsc_hydrated) return;\n\n w.__rsc_hydrated = true;\n w.__rsc_on_hydrated?.();\n }, []);\n\n return <PathnameContext.Provider value={value}>{children}</PathnameContext.Provider>;\n}\n\n/** What the server rendered. On the client this is the url it was hydrated with. */\nexport function useRenderedPathname(): string {\n return useContext(PathnameContext);\n}\n"]}
1
+ {"version":3,"file":"PathnameProvider.js","sourceRoot":"","sources":["../../src/js/PathnameProvider.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAG7D,+EAA+E;AAC/E,8DAA8D;AAC9D,MAAM,eAAe,GAAG,aAAa,CAAgB,GAAG,CAAC,CAAC;AAE1D;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAiD;IACjG,sEAAsE;IACtE,oEAAoE;IACpE,mEAAmE;IACnE,yEAAyE;IACzE,sEAAsE;IACtE,qBAAqB;IACrB,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,CAAC,GAAG,MAAsE,CAAC;QAEjF,IAAI,CAAC,CAAC,cAAc;YAAE,OAAO;QAE7B,CAAC,CAAC,cAAc,GAAG,IAAI,CAAC;QACxB,CAAC,CAAC,iBAAiB,EAAE,EAAE,CAAC;IAC1B,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,KAAC,eAAe,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAAG,QAAQ,GAA4B,CAAC;AACvF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,UAAU,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The url the server rendered, handed to the client hooks.\n *\n * usePathname used to answer \"/\" during a server render, because a hook has no\n * props and the module had nothing else to go on. Every prerendered page\n * therefore shipped with the wrong link marked active, corrected only once\n * hydration ran — a visible flash, and simply wrong on a route that ships no\n * client runtime at all.\n *\n * A context rather than a module-level variable: two requests render at once\n * in the same process, and a variable would hand one page the other's url.\n */\n\nimport { createContext, useContext, useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// \"/\" with no provider at all - a component rendered outside the tree, a test.\n// null from a provider means the url is not known: see below.\nconst PathnameContext = createContext<string | null>(\"/\");\n\n/**\n * `value` is null where the url is not known: the shell of a route that\n * listed no urls, rendered once for every url it matches, the holes that\n * resume it, and the payload a document served from that shell boots from.\n * The hooks answer \"\" then - no link active, no breadcrumb - and\n * usePathname, a useSyncExternalStore, moves to the browser's url the\n * moment hydration is done. The three renders agree with each other, which\n * is what hydration needs; the browser knows the rest.\n */\nexport function PathnameProvider({ value, children }: { value: string | null; children: ReactNode }) {\n // The outermost client component on every page with a runtime, so its\n // first effect is the moment hydration has committed - the moment a\n // click on a <Link> is React's to handle. Until then the bootstrap\n // script's listener holds the taps; see earlyClicks. Announced through a\n // global rather than a module, because this component and the runtime\n // are bundled apart.\n useEffect(() => {\n const w = window as { __rsc_hydrated?: boolean; __rsc_on_hydrated?: () => void };\n\n if (w.__rsc_hydrated) return;\n\n w.__rsc_hydrated = true;\n w.__rsc_on_hydrated?.();\n }, []);\n\n return <PathnameContext.Provider value={value}>{children}</PathnameContext.Provider>;\n}\n\n/**\n * What the server rendered, or \"\" where it could not know. On the client\n * this is what the payload said, so hydration matches the document; the\n * live url is usePathname's, from the browser.\n */\nexport function useRenderedPathname(): string {\n return useContext(PathnameContext) ?? \"\";\n}\n"]}
@@ -44,7 +44,8 @@ const addTransitionType = React.addTransitionType ??
44
44
  React
45
45
  .unstable_addTransitionType;
46
46
  import { RedirectBoundary } from "./RedirectBoundary";
47
- import { getSegmentState, navigatedOnce, seedSegment, subscribeToSegment, } from "./segmentStore";
47
+ import { navigationCommitted } from "./perf";
48
+ import { getSegmentState, replaceActive, navigatedOnce, seedSegment, subscribeToSegment, } from "./segmentStore";
48
49
  export function SegmentBoundary({ depth, pageKey, children, }) {
49
50
  // The store still addresses the boundary; the render reads React state, so
50
51
  // the update can be a transition. Initialised from the store on the client
@@ -60,10 +61,22 @@ export function SegmentBoundary({ depth, pageKey, children, }) {
60
61
  setState(getSegmentState(depth));
61
62
  });
62
63
  }), [depth]);
64
+ // The commit that put a navigated segment on screen: the end of the
65
+ // navigation's measure, see perf.ts. Not on the first render, which is the
66
+ // seed.
67
+ useEffect(() => {
68
+ if (state !== null)
69
+ navigationCommitted();
70
+ }, [state]);
63
71
  // Record the page we arrived on, so a later navigation away and back can
64
72
  // return to it. Without this the first page is the one page you cannot keep.
73
+ // New children while the store already holds this depth is the document
74
+ // rendered again in place - revalidate("all") - and the page on screen
75
+ // takes them under the key it has; see replaceActive.
65
76
  useEffect(() => {
66
- if (pageKey)
77
+ if (getSegmentState(depth))
78
+ replaceActive(depth, children);
79
+ else if (pageKey)
67
80
  seedSegment(depth, pageKey, children);
68
81
  }, [depth, pageKey, children]);
69
82
  // Wrapped here rather than around the whole app because this is the closest
@@ -82,8 +95,19 @@ export function SegmentBoundary({ depth, pageKey, children, }) {
82
95
  // wrapper, keyed by the page, so when the store takes over after hydration
83
96
  // the tree changes state but not shape - and React keeps the DOM instead of
84
97
  // remounting the page, which was blank, then content, on every load.
98
+ //
99
+ // The Activity is there whether or not there is a page key to hang it on.
100
+ // A parameterised route's PPR shell is rendered for every url it matches
101
+ // and so carries no key - and the boundary used to leave the Activity out
102
+ // then, so the shell had no <!--&--> markers where the client, hydrating
103
+ // from the payload for the real url, rendered one. React 19.2 does not
104
+ // recover from a hydration mismatch at an Activity: the boundary retries
105
+ // hydrating, mismatches again, and the main thread never returns. A port
106
+ // found /agent/tools/3 freezing the tab on every document load. The
107
+ // markup of an Activity does not depend on its key, so the shell and the
108
+ // client agree whenever both render one.
85
109
  if (!state) {
86
- return pageKey ? (_jsx(Activity, { mode: "visible", children: _jsx(RedirectBoundary, { children: children }) }, pageKey)) : (_jsx(RedirectBoundary, { children: children }));
110
+ return (_jsx(Activity, { mode: "visible", children: _jsx(RedirectBoundary, { children: children }) }, pageKey));
87
111
  }
88
112
  return (_jsx(_Fragment, { children: state.entries.map((entry) => (_jsx(Activity, { mode: entry.key === state.activeKey ? "visible" : "hidden", children: _jsx(RedirectBoundary, { active: entry.key === state.activeKey, children: entry.tree }) }, entry.key))) }));
89
113
  }
@@ -1 +1 @@
1
- {"version":3,"file":"SegmentBoundary.js","sourceRoot":"","sources":["../../src/js/SegmentBoundary.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,gBAAgB,CAAC;AAE3D,4EAA4E;AAC5E,yEAAyE;AACzE,uDAAuD;AACvD,MAAM,iBAAiB,GACpB,KAAwD,CAAC,iBAAiB;IAC1E,KAAiE;SAC/D,0BAA0B,CAAC;AAEhC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EACL,eAAe,EACf,aAAa,EACb,WAAW,EACX,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AAExB,MAAM,UAAU,eAAe,CAAC,EAC9B,KAAK,EACL,OAAO,EACP,QAAQ,GAMT;IACC,2EAA2E;IAC3E,2EAA2E;IAC3E,+DAA+D;IAC/D,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAqC,GAAG,EAAE,CAC1E,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,CAC9D,CAAC;IAEF,SAAS,CACP,GAAG,EAAE,CACH,kBAAkB,CAAC,KAAK,EAAE,GAAG,EAAE;QAC7B,eAAe,CAAC,GAAG,EAAE;YACnB,+DAA+D;YAC/D,gEAAgE;YAChE,sBAAsB;YACtB,IAAI,aAAa,EAAE;gBAAE,iBAAiB,EAAE,CAAC,0BAA0B,CAAC,CAAC;YAErE,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,EACJ,CAAC,KAAK,CAAC,CACR,CAAC;IAEF,yEAAyE;IACzE,6EAA6E;IAC7E,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO;YAAE,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IACrD,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE/B,4EAA4E;IAC5E,yEAAyE;IACzE,4EAA4E;IAC5E,uEAAuE;IACvE,qEAAqE;IACrE,uEAAuE;IACvE,uEAAuE;IACvE,uEAAuE;IACvE,yEAAyE;IACzE,0EAA0E;IAC1E,QAAQ;IACR,wEAAwE;IACxE,sEAAsE;IACtE,2EAA2E;IAC3E,4EAA4E;IAC5E,qEAAqE;IACrE,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,OAAO,CAAC,CAAC,CAAC,CACf,KAAC,QAAQ,IAAe,IAAI,EAAC,SAAS,YACpC,KAAC,gBAAgB,cAAE,QAAQ,GAAoB,IADlC,OAAO,CAEX,CACZ,CAAC,CAAC,CAAC,CACF,KAAC,gBAAgB,cAAE,QAAQ,GAAoB,CAChD,CAAC;IACJ,CAAC;IAED,OAAO,CACL,4BACG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAC5B,KAAC,QAAQ,IAEP,IAAI,EAAE,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,YAE1D,KAAC,gBAAgB,IAAC,MAAM,EAAE,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,SAAS,YACpD,KAAK,CAAC,IAAiB,GACP,IALd,KAAK,CAAC,GAAG,CAML,CACZ,CAAC,GACD,CACJ,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The point in the tree a navigation can replace on its own.\n *\n * Sits between a layout and its children, so showing a different segment\n * re-renders from here down and leaves the layouts above it mounted. Server\n * components cannot be re-rendered on the client, which is why the seam has to\n * be a client component reading from a store rather than the layout itself.\n *\n * Pages it has already shown stay mounted behind <Activity mode=\"hidden\">.\n * Hidden is not unmounted: effects are torn down but state survives, so\n * returning to a page brings back the form you were filling in. Rendering only\n * the current one would throw that away, which is what replacing the root did.\n */\n\nimport * as React from \"react\";\nimport { Activity, startTransition, useEffect, useState } from \"react\";\n\n/**\n * The transition type a navigation carries.\n *\n * The engine does not animate a navigation itself; React's own\n * <ViewTransition> does, wherever an app puts one. What the app cannot do\n * from outside is tell a navigation from the seed after hydration - the\n * boundary taking over its own server-rendered children, which animated\n * would be the page fading into itself on every load. So a navigation's\n * update carries this type, and the seed's does not: a boundary keyed on it,\n * `default={{ \"rsc-navigation\": \"page\", default: \"none\" }}`, animates\n * navigations and nothing else.\n *\n * An earlier shape wrapped the segment in a boundary of the engine's own,\n * behind a flag. React names every top-level element under a boundary and\n * morphs each from where it was to where it is, so the stylesheet pulled\n * them back into one root fade - and fought React over the root, which it\n * cancels when nothing outside a boundary changed: a captured root with a\n * hidden group, a dark viewport on the first navigation. One wrapper element\n * under the app's own boundary is one snapshot pair, which is the dissolve\n * wanted, with nothing to fight.\n */\nexport const NAVIGATION_TRANSITION_TYPE = \"rsc-navigation\";\n\n// Exported by React 19.3; `unstable_` before it. Read at call time from the\n// namespace rather than imported by name, so a React that lacks it still\n// loads this module and simply does not mark the type.\nconst addTransitionType: ((type: string) => void) | undefined =\n (React as { addTransitionType?: (type: string) => void }).addTransitionType ??\n (React as { unstable_addTransitionType?: (type: string) => void })\n .unstable_addTransitionType;\nimport type { ReactNode } from \"react\";\nimport { RedirectBoundary } from \"./RedirectBoundary\";\nimport {\n getSegmentState,\n navigatedOnce,\n seedSegment,\n subscribeToSegment,\n} from \"./segmentStore\";\n\nexport function SegmentBoundary({\n depth,\n pageKey,\n children,\n}: {\n depth: number;\n /** The page these server-rendered children belong to. */\n pageKey?: string;\n children: ReactNode;\n}) {\n // The store still addresses the boundary; the render reads React state, so\n // the update can be a transition. Initialised from the store on the client\n // and null on the server — exactly what getServerSnapshot did.\n const [state, setState] = useState<ReturnType<typeof getSegmentState>>(() =>\n typeof window === \"undefined\" ? null : getSegmentState(depth),\n );\n\n useEffect(\n () =>\n subscribeToSegment(depth, () => {\n startTransition(() => {\n // A navigation, not the seed: the seed is the page taking over\n // its own server-rendered children, and fading that is the page\n // fading into itself.\n if (navigatedOnce()) addTransitionType?.(NAVIGATION_TRANSITION_TYPE);\n\n setState(getSegmentState(depth));\n });\n }),\n [depth],\n );\n\n // Record the page we arrived on, so a later navigation away and back can\n // return to it. Without this the first page is the one page you cannot keep.\n useEffect(() => {\n if (pageKey) seedSegment(depth, pageKey, children);\n }, [depth, pageKey, children]);\n\n // Wrapped here rather than around the whole app because this is the closest\n // client component above a page: a redirect thrown inside the page's own\n // Suspense boundary surfaces at the nearest error boundary, and catching it\n // here leaves the layouts above mounted while the navigation runs. One\n // boundary per page, inside its Activity, not one around them all: a\n // boundary that catches renders its fallback in place of everything it\n // wraps, and one around them all unmounted every page being kept alive\n // behind the one redirecting - the destination among them, when it was\n // held - and then kept showing the fallback over the destination once it\n // arrived. The port's report was a sidebar and a breadcrumb over an empty\n // main.\n // The same shape before and after the seed. The server's render and the\n // first client render already place the children inside the retention\n // wrapper, keyed by the page, so when the store takes over after hydration\n // the tree changes state but not shape - and React keeps the DOM instead of\n // remounting the page, which was blank, then content, on every load.\n if (!state) {\n return pageKey ? (\n <Activity key={pageKey} mode=\"visible\">\n <RedirectBoundary>{children}</RedirectBoundary>\n </Activity>\n ) : (\n <RedirectBoundary>{children}</RedirectBoundary>\n );\n }\n\n return (\n <>\n {state.entries.map((entry) => (\n <Activity\n key={entry.key}\n mode={entry.key === state.activeKey ? \"visible\" : \"hidden\"}\n >\n <RedirectBoundary active={entry.key === state.activeKey}>\n {entry.tree as ReactNode}\n </RedirectBoundary>\n </Activity>\n ))}\n </>\n );\n}\n"]}
1
+ {"version":3,"file":"SegmentBoundary.js","sourceRoot":"","sources":["../../src/js/SegmentBoundary.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,gBAAgB,CAAC;AAE3D,4EAA4E;AAC5E,yEAAyE;AACzE,uDAAuD;AACvD,MAAM,iBAAiB,GACpB,KAAwD,CAAC,iBAAiB;IAC1E,KAAiE;SAC/D,0BAA0B,CAAC;AAEhC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,EACL,eAAe,EACf,aAAa,EACb,aAAa,EACb,WAAW,EACX,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AAExB,MAAM,UAAU,eAAe,CAAC,EAC9B,KAAK,EACL,OAAO,EACP,QAAQ,GAMT;IACC,2EAA2E;IAC3E,2EAA2E;IAC3E,+DAA+D;IAC/D,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAqC,GAAG,EAAE,CAC1E,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,CAC9D,CAAC;IAEF,SAAS,CACP,GAAG,EAAE,CACH,kBAAkB,CAAC,KAAK,EAAE,GAAG,EAAE;QAC7B,eAAe,CAAC,GAAG,EAAE;YACnB,+DAA+D;YAC/D,gEAAgE;YAChE,sBAAsB;YACtB,IAAI,aAAa,EAAE;gBAAE,iBAAiB,EAAE,CAAC,0BAA0B,CAAC,CAAC;YAErE,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,EACJ,CAAC,KAAK,CAAC,CACR,CAAC;IAEF,oEAAoE;IACpE,2EAA2E;IAC3E,QAAQ;IACR,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,KAAK,KAAK,IAAI;YAAE,mBAAmB,EAAE,CAAC;IAC5C,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAEZ,yEAAyE;IACzE,6EAA6E;IAC7E,wEAAwE;IACxE,uEAAuE;IACvE,sDAAsD;IACtD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,eAAe,CAAC,KAAK,CAAC;YAAE,aAAa,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;aACtD,IAAI,OAAO;YAAE,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC1D,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE/B,4EAA4E;IAC5E,yEAAyE;IACzE,4EAA4E;IAC5E,uEAAuE;IACvE,qEAAqE;IACrE,uEAAuE;IACvE,uEAAuE;IACvE,uEAAuE;IACvE,yEAAyE;IACzE,0EAA0E;IAC1E,QAAQ;IACR,wEAAwE;IACxE,sEAAsE;IACtE,2EAA2E;IAC3E,4EAA4E;IAC5E,qEAAqE;IACrE,EAAE;IACF,0EAA0E;IAC1E,yEAAyE;IACzE,0EAA0E;IAC1E,yEAAyE;IACzE,uEAAuE;IACvE,yEAAyE;IACzE,yEAAyE;IACzE,oEAAoE;IACpE,yEAAyE;IACzE,yCAAyC;IACzC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,CACL,KAAC,QAAQ,IAAe,IAAI,EAAC,SAAS,YACpC,KAAC,gBAAgB,cAAE,QAAQ,GAAoB,IADlC,OAAO,CAEX,CACZ,CAAC;IACJ,CAAC;IAED,OAAO,CACL,4BACG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAC5B,KAAC,QAAQ,IAEP,IAAI,EAAE,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,YAE1D,KAAC,gBAAgB,IAAC,MAAM,EAAE,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,SAAS,YACpD,KAAK,CAAC,IAAiB,GACP,IALd,KAAK,CAAC,GAAG,CAML,CACZ,CAAC,GACD,CACJ,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The point in the tree a navigation can replace on its own.\n *\n * Sits between a layout and its children, so showing a different segment\n * re-renders from here down and leaves the layouts above it mounted. Server\n * components cannot be re-rendered on the client, which is why the seam has to\n * be a client component reading from a store rather than the layout itself.\n *\n * Pages it has already shown stay mounted behind <Activity mode=\"hidden\">.\n * Hidden is not unmounted: effects are torn down but state survives, so\n * returning to a page brings back the form you were filling in. Rendering only\n * the current one would throw that away, which is what replacing the root did.\n */\n\nimport * as React from \"react\";\nimport { Activity, startTransition, useEffect, useState } from \"react\";\n\n/**\n * The transition type a navigation carries.\n *\n * The engine does not animate a navigation itself; React's own\n * <ViewTransition> does, wherever an app puts one. What the app cannot do\n * from outside is tell a navigation from the seed after hydration - the\n * boundary taking over its own server-rendered children, which animated\n * would be the page fading into itself on every load. So a navigation's\n * update carries this type, and the seed's does not: a boundary keyed on it,\n * `default={{ \"rsc-navigation\": \"page\", default: \"none\" }}`, animates\n * navigations and nothing else.\n *\n * An earlier shape wrapped the segment in a boundary of the engine's own,\n * behind a flag. React names every top-level element under a boundary and\n * morphs each from where it was to where it is, so the stylesheet pulled\n * them back into one root fade - and fought React over the root, which it\n * cancels when nothing outside a boundary changed: a captured root with a\n * hidden group, a dark viewport on the first navigation. One wrapper element\n * under the app's own boundary is one snapshot pair, which is the dissolve\n * wanted, with nothing to fight.\n */\nexport const NAVIGATION_TRANSITION_TYPE = \"rsc-navigation\";\n\n// Exported by React 19.3; `unstable_` before it. Read at call time from the\n// namespace rather than imported by name, so a React that lacks it still\n// loads this module and simply does not mark the type.\nconst addTransitionType: ((type: string) => void) | undefined =\n (React as { addTransitionType?: (type: string) => void }).addTransitionType ??\n (React as { unstable_addTransitionType?: (type: string) => void })\n .unstable_addTransitionType;\nimport type { ReactNode } from \"react\";\nimport { RedirectBoundary } from \"./RedirectBoundary\";\nimport { navigationCommitted } from \"./perf\";\nimport {\n getSegmentState,\n replaceActive,\n navigatedOnce,\n seedSegment,\n subscribeToSegment,\n} from \"./segmentStore\";\n\nexport function SegmentBoundary({\n depth,\n pageKey,\n children,\n}: {\n depth: number;\n /** The page these server-rendered children belong to. */\n pageKey?: string;\n children: ReactNode;\n}) {\n // The store still addresses the boundary; the render reads React state, so\n // the update can be a transition. Initialised from the store on the client\n // and null on the server — exactly what getServerSnapshot did.\n const [state, setState] = useState<ReturnType<typeof getSegmentState>>(() =>\n typeof window === \"undefined\" ? null : getSegmentState(depth),\n );\n\n useEffect(\n () =>\n subscribeToSegment(depth, () => {\n startTransition(() => {\n // A navigation, not the seed: the seed is the page taking over\n // its own server-rendered children, and fading that is the page\n // fading into itself.\n if (navigatedOnce()) addTransitionType?.(NAVIGATION_TRANSITION_TYPE);\n\n setState(getSegmentState(depth));\n });\n }),\n [depth],\n );\n\n // The commit that put a navigated segment on screen: the end of the\n // navigation's measure, see perf.ts. Not on the first render, which is the\n // seed.\n useEffect(() => {\n if (state !== null) navigationCommitted();\n }, [state]);\n\n // Record the page we arrived on, so a later navigation away and back can\n // return to it. Without this the first page is the one page you cannot keep.\n // New children while the store already holds this depth is the document\n // rendered again in place - revalidate(\"all\") - and the page on screen\n // takes them under the key it has; see replaceActive.\n useEffect(() => {\n if (getSegmentState(depth)) replaceActive(depth, children);\n else if (pageKey) seedSegment(depth, pageKey, children);\n }, [depth, pageKey, children]);\n\n // Wrapped here rather than around the whole app because this is the closest\n // client component above a page: a redirect thrown inside the page's own\n // Suspense boundary surfaces at the nearest error boundary, and catching it\n // here leaves the layouts above mounted while the navigation runs. One\n // boundary per page, inside its Activity, not one around them all: a\n // boundary that catches renders its fallback in place of everything it\n // wraps, and one around them all unmounted every page being kept alive\n // behind the one redirecting - the destination among them, when it was\n // held - and then kept showing the fallback over the destination once it\n // arrived. The port's report was a sidebar and a breadcrumb over an empty\n // main.\n // The same shape before and after the seed. The server's render and the\n // first client render already place the children inside the retention\n // wrapper, keyed by the page, so when the store takes over after hydration\n // the tree changes state but not shape - and React keeps the DOM instead of\n // remounting the page, which was blank, then content, on every load.\n //\n // The Activity is there whether or not there is a page key to hang it on.\n // A parameterised route's PPR shell is rendered for every url it matches\n // and so carries no key - and the boundary used to leave the Activity out\n // then, so the shell had no <!--&--> markers where the client, hydrating\n // from the payload for the real url, rendered one. React 19.2 does not\n // recover from a hydration mismatch at an Activity: the boundary retries\n // hydrating, mismatches again, and the main thread never returns. A port\n // found /agent/tools/3 freezing the tab on every document load. The\n // markup of an Activity does not depend on its key, so the shell and the\n // client agree whenever both render one.\n if (!state) {\n return (\n <Activity key={pageKey} mode=\"visible\">\n <RedirectBoundary>{children}</RedirectBoundary>\n </Activity>\n );\n }\n\n return (\n <>\n {state.entries.map((entry) => (\n <Activity\n key={entry.key}\n mode={entry.key === state.activeKey ? \"visible\" : \"hidden\"}\n >\n <RedirectBoundary active={entry.key === state.activeKey}>\n {entry.tree as ReactNode}\n </RedirectBoundary>\n </Activity>\n ))}\n </>\n );\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * How many <Activity> boundaries the server put in this document.
3
+ *
4
+ * React marks each with a comment, <!--&--> to <!--/&-->; the opening ones
5
+ * are counted.
6
+ */
7
+ export declare function activityMarkersIn(container: Element | Document): number;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * How many <Activity> boundaries the server put in this document.
3
+ *
4
+ * React marks each with a comment, <!--&--> to <!--/&-->; the opening ones
5
+ * are counted.
6
+ */
7
+ export function activityMarkersIn(container) {
8
+ if (typeof document === "undefined" || typeof document.createTreeWalker !== "function")
9
+ return 1;
10
+ const walker = document.createTreeWalker(container, 128 /* NodeFilter.SHOW_COMMENT */);
11
+ let count = 0;
12
+ while (walker.nextNode()) {
13
+ if (walker.currentNode.data === "&")
14
+ count++;
15
+ }
16
+ return count;
17
+ }
18
+ //# sourceMappingURL=activityMarkers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activityMarkers.js","sourceRoot":"","sources":["../../src/js/activityMarkers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAA6B;IAC7D,IAAI,OAAO,QAAQ,KAAK,WAAW,IAAI,OAAO,QAAQ,CAAC,gBAAgB,KAAK,UAAU;QAAE,OAAO,CAAC,CAAC;IAEjG,MAAM,MAAM,GAAG,QAAQ,CAAC,gBAAgB,CAAC,SAAS,EAAE,GAAG,CAAC,6BAA6B,CAAC,CAAC;IACvF,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,OAAO,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC;QACzB,IAAK,MAAM,CAAC,WAAuB,CAAC,IAAI,KAAK,GAAG;YAAE,KAAK,EAAE,CAAC;IAC5D,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC","sourcesContent":["/**\n * How many <Activity> boundaries the server put in this document.\n *\n * React marks each with a comment, <!--&--> to <!--/&-->; the opening ones\n * are counted.\n */\nexport function activityMarkersIn(container: Element | Document): number {\n if (typeof document === \"undefined\" || typeof document.createTreeWalker !== \"function\") return 1;\n\n const walker = document.createTreeWalker(container, 128 /* NodeFilter.SHOW_COMMENT */);\n let count = 0;\n\n while (walker.nextNode()) {\n if ((walker.currentNode as Comment).data === \"&\") count++;\n }\n\n return count;\n}\n"]}
@@ -12,14 +12,15 @@ import { caughtByLoading } from "./fallbackReport";
12
12
  import { noteNavigation } from "./segmentStore";
13
13
  import { isSafeRedirect } from "../safeUrl.js";
14
14
  import { createFromReadableStream, encodeReply, setServerCallback, } from "@vitejs/plugin-rsc/browser";
15
- import { createElement } from "react";
16
- import { hydrateRoot } from "react-dom/client";
15
+ import { createElement, startTransition } from "react";
16
+ import { createRoot, hydrateRoot } from "react-dom/client";
17
+ import { activityMarkersIn } from "./activityMarkers";
17
18
  import { ActivityRoot } from "./ActivityRouter";
18
19
  import { ServerRedirectError, noteRedirected, throwForFailedAction } from "./errors";
19
20
  import { fetchPagePayload } from "./pagePayload";
20
21
  import { claimRead, setQueryCodec } from "./queryClient";
21
- import { clearSegments, restoreSegments, setSegment } from "./segmentStore";
22
- import { cancelPrefetch, isPrefetched, navigate, setApiRoutes, refresh, applyRevalidated, prefetch, retentionKey, setCallServer, setHeldLayouts, setStaticPayloads, setStaticRoutes, seedStaticChain, payloadUrl, setDeserializer, setInterceptManifest, setNavigateHandler, setRestoreHandler, setVersion, } from "./navigate";
22
+ import { clearSegments, dropHidden, isHeld, prerenderSegment, restoreSegments, setSegment } from "./segmentStore";
23
+ import { cancelPrefetch, isPrefetched, navigate, setApiRoutes, refresh, applyRevalidations, prefetch, retentionKey, setCallServer, setHeldLayouts, setStaticPayloads, setStaticRoutes, seedStaticChain, payloadUrl, setDeserializer, setInterceptManifest, getHeldLayouts, setNavigateHandler, setHeldHandlers, setPrerenderHandler, setReplaceRootHandler, setRestoreHandler, setVersion, } from "./navigate";
23
24
  /**
24
25
  * A redirect that travelled as an error: the digest of the error itself, or
25
26
  * of the cause React wraps it in when it reports client-rendering a boundary
@@ -72,15 +73,17 @@ export async function createViteRscApp(container = document, interceptEntries =
72
73
  body = encoded;
73
74
  realContentType = "text/plain;charset=UTF-8";
74
75
  }
76
+ // Where the action was invoked from. The host resolves it to the
77
+ // components that render the page, so anything the action says it
78
+ // invalidated can come back with the answer instead of being fetched
79
+ // afterwards - and what comes back is for this page, not whichever is
80
+ // showing when it lands.
81
+ const from = window.location.pathname + window.location.search;
75
82
  const res = await fetch("/_rsc/action", {
76
83
  method: "POST",
77
84
  headers: {
78
85
  "X-RSC-Action": id,
79
- // Where the action was invoked from. The host resolves it to the
80
- // components that render the page, so anything the action says it
81
- // invalidated can come back with the answer instead of being fetched
82
- // afterwards.
83
- "X-RSC-Referer": window.location.pathname + window.location.search,
86
+ "X-RSC-Referer": from,
84
87
  "X-RSC-Content-Type": realContentType,
85
88
  "Content-Type": "application/octet-stream",
86
89
  "X-XSRF-TOKEN": decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ""),
@@ -120,7 +123,7 @@ export async function createViteRscApp(container = document, interceptEntries =
120
123
  throw err;
121
124
  }
122
125
  const answer = await createFromReadableStream(res.body, { callServer });
123
- return unwrapRevalidated(answer);
126
+ return unwrapRevalidated(answer, from);
124
127
  }
125
128
  /**
126
129
  * Put anything the action re-rendered on screen, and hand back its result.
@@ -129,16 +132,14 @@ export async function createViteRscApp(container = document, interceptEntries =
129
132
  * the caller sees only what its action returned and never knows the page
130
133
  * was updated around it.
131
134
  */
132
- function unwrapRevalidated(answer) {
135
+ function unwrapRevalidated(answer, from) {
133
136
  if (answer === null ||
134
137
  typeof answer !== "object" ||
135
138
  !("__rscRevalidated" in answer)) {
136
139
  return answer;
137
140
  }
138
141
  const envelope = answer;
139
- for (const [target, tree] of Object.entries(envelope.__rscRevalidated)) {
140
- applyRevalidated(target, tree);
141
- }
142
+ applyRevalidations(from, envelope.__rscRevalidated);
142
143
  return envelope.result;
143
144
  }
144
145
  setDeserializer(createFromReadableStream);
@@ -215,68 +216,90 @@ export async function createViteRscApp(container = document, interceptEntries =
215
216
  initialTree: tree,
216
217
  })
217
218
  : tree;
218
- const root = hydrateRoot(container, shell, {
219
- onRecoverableError(error, errorInfo) {
220
- // A client component whose chunk the server no longer has fails while
221
- // hydrating, and React reports that as recoverable: the page is loaded
222
- // again, with the current names.
223
- if (recoverFromStaleAssets(error))
224
- return;
225
- // A redirect decided inside a boundary: the server's row carried it as
226
- // the digest, React client-renders the boundary and reports that it
227
- // did, and the RedirectBoundary is already performing it. Nothing to
228
- // read in the console about a page doing what it was told.
229
- if (isRedirect(error))
230
- return;
231
- // A PPR shell is served with its Suspense boundaries deliberately
232
- // unfinished — the build aborts the render once the static part is out.
233
- // React reports that as #419 and client-renders the boundary from the
234
- // Flight payload, which is the intended path, not a fault to report.
235
- const message = String(error?.message ?? error);
236
- if (message.includes("419") ||
237
- message.includes("did not finish this Suspense boundary")) {
238
- return;
239
- }
240
- // A view transition the browser cancelled because the tab was not
241
- // visible. React reports it here; the update still landed, and a tab
242
- // in the background is not a fault in the page.
243
- if (message.includes("Transition was aborted"))
244
- return;
245
- // A navigation another one overtook: its request was aborted, and a
246
- // row of its payload that a component was still reading rejects with
247
- // the abort. React recovers by rendering the root again synchronously
248
- // and reports the recovery here as #520, with the abort as the cause.
249
- // The page it recovered to is the one the second navigation asked for;
250
- // the abort was the design, not a fault.
251
- const cause = error?.cause;
252
- if (cause?.name === "AbortError" ||
253
- /\baborted\b/i.test(cause?.message ?? "") ||
254
- /\baborted\b/i.test(message)) {
255
- return;
256
- }
257
- // A page whose query string was read under a boundary carries the
258
- // fallback there; React reports the recovery on hydration. Under a
259
- // boundary the developer wrote, that is the designed path and nothing
260
- // is said. With nothing closer than a loading.tsx, the whole segment
261
- // showed the fallback until the query arrived - said here, in the
262
- // page and the console, for whoever is looking at either. Production
263
- // has no stack to tell the two apart; the build's note on the route is
264
- // the record there.
265
- if (error?.digest === SEARCH_PARAMS_FALLBACK ||
266
- message.includes("useSearchParams()")) {
267
- const stack = errorInfo
268
- ?.componentStack;
269
- if (!caughtByLoading(stack))
270
- return;
271
- const hint = "useSearchParams() was read on the server with nothing closer than a loading.tsx, so the " +
272
- "whole segment showed that fallback until the query arrived. A <Suspense> around the " +
273
- "component that reads keeps the rest of the page painted.";
274
- showDevNotice(hint, stack);
275
- console.error(new Error(hint), errorInfo);
219
+ // A document that cannot be hydrated by this tree is client-rendered
220
+ // instead - a repaint, not a freeze. React 19.2 does not recover from a
221
+ // hydration mismatch at an <Activity>: it retries hydrating the boundary,
222
+ // mismatches again, and never returns to the main thread. Every segment
223
+ // boundary is an Activity, one per layout, so a document with none where
224
+ // the page has layouts is that mismatch, guaranteed, before React starts.
225
+ // What produced one: a parameterised route's PPR shell, rendered without
226
+ // a page key and, by a build before this one, without the Activities too.
227
+ // None rather than fewer: a boundary inside a Suspense hole the shell left
228
+ // unfinished has no marker and is client-rendered by React on its own,
229
+ // which is not a mismatch. The stale-asset reload does not apply - the
230
+ // document would come back the same.
231
+ const missing = retains && getHeldLayouts().length > 0 && activityMarkersIn(container) === 0;
232
+ if (missing) {
233
+ console.warn("[rsc-kit] The document has no <Activity> boundaries where the page has layouts, so it cannot be hydrated; rendering the page instead. A PPR shell built by an earlier release lacks them - rebuild.");
234
+ }
235
+ // The payload answered with an error status - the not-found page for a
236
+ // url the shell was served for anyway. It is not the document on screen,
237
+ // so it is rendered, not hydrated: a mismatch at an Activity is the loop
238
+ // above, and this one is certain.
239
+ const disagrees = !res.ok;
240
+ const onRecoverableError = (error, errorInfo) => {
241
+ // A client component whose chunk the server no longer has fails while
242
+ // hydrating, and React reports that as recoverable: the page is loaded
243
+ // again, with the current names.
244
+ if (recoverFromStaleAssets(error))
245
+ return;
246
+ // A redirect decided inside a boundary: the server's row carried it as
247
+ // the digest, React client-renders the boundary and reports that it
248
+ // did, and the RedirectBoundary is already performing it. Nothing to
249
+ // read in the console about a page doing what it was told.
250
+ if (isRedirect(error))
251
+ return;
252
+ // A PPR shell is served with its Suspense boundaries deliberately
253
+ // unfinished — the build aborts the render once the static part is out.
254
+ // React reports that as #419 and client-renders the boundary from the
255
+ // Flight payload, which is the intended path, not a fault to report.
256
+ const message = String(error?.message ?? error);
257
+ if (message.includes("419") ||
258
+ message.includes("did not finish this Suspense boundary")) {
259
+ return;
260
+ }
261
+ // A view transition the browser cancelled because the tab was not
262
+ // visible. React reports it here; the update still landed, and a tab
263
+ // in the background is not a fault in the page.
264
+ if (message.includes("Transition was aborted"))
265
+ return;
266
+ // A navigation another one overtook: its request was aborted, and a
267
+ // row of its payload that a component was still reading rejects with
268
+ // the abort. React recovers by rendering the root again synchronously
269
+ // and reports the recovery here as #520, with the abort as the cause.
270
+ // The page it recovered to is the one the second navigation asked for;
271
+ // the abort was the design, not a fault.
272
+ const cause = error?.cause;
273
+ if (cause?.name === "AbortError" ||
274
+ /\baborted\b/i.test(cause?.message ?? "") ||
275
+ /\baborted\b/i.test(message)) {
276
+ return;
277
+ }
278
+ // A page whose query string was read under a boundary carries the
279
+ // fallback there; React reports the recovery on hydration. Under a
280
+ // boundary the developer wrote, that is the designed path and nothing
281
+ // is said. With nothing closer than a loading.tsx, the whole segment
282
+ // showed the fallback until the query arrived - said here, in the
283
+ // page and the console, for whoever is looking at either. Production
284
+ // has no stack to tell the two apart; the build's note on the route is
285
+ // the record there.
286
+ if (error?.digest === SEARCH_PARAMS_FALLBACK ||
287
+ message.includes("useSearchParams()")) {
288
+ const stack = errorInfo
289
+ ?.componentStack;
290
+ if (!caughtByLoading(stack))
276
291
  return;
277
- }
278
- console.error(error, errorInfo);
279
- },
292
+ const hint = "useSearchParams() was read on the server with nothing closer than a loading.tsx, so the " +
293
+ "whole segment showed that fallback until the query arrived. A <Suspense> around the " +
294
+ "component that reads keeps the rest of the page painted.";
295
+ showDevNotice(hint, stack);
296
+ console.error(new Error(hint), errorInfo);
297
+ return;
298
+ }
299
+ console.error(error, errorInfo);
300
+ };
301
+ const rootOptions = {
302
+ onRecoverableError,
280
303
  // A chunk the deploy no longer serves is not a fault in the page; the
281
304
  // document is loaded again, and the new names come with it. Anything
282
305
  // else is reported the way React would have.
@@ -293,7 +316,14 @@ export async function createViteRscApp(container = document, interceptEntries =
293
316
  return;
294
317
  console.error(error, errorInfo);
295
318
  },
296
- });
319
+ };
320
+ const root = missing || disagrees
321
+ ? (() => {
322
+ const created = createRoot(container, rootOptions);
323
+ created.render(shell);
324
+ return created;
325
+ })()
326
+ : hydrateRoot(container, shell, rootOptions);
297
327
  // Depth 0 is a whole document and replaces the root. Anything deeper is one
298
328
  // segment: handing it to the boundary at that depth leaves the layouts above
299
329
  // it mounted, which is the point of asking for a partial render at all.
@@ -308,9 +338,25 @@ export async function createViteRscApp(container = document, interceptEntries =
308
338
  clearSegments();
309
339
  root.render(newTree);
310
340
  });
341
+ // The whole document again, in place - revalidate("all"). Not through the
342
+ // handler above: that clears the boundaries and renders the tree as a new
343
+ // page, and a boundary emptied re-keys its Activity to the current url and
344
+ // remounts everything under it. A port's "added to cart" went with the
345
+ // form's state that way, on every product reached by a link. Here the root
346
+ // takes the tree in a transition and each boundary hands its page the new
347
+ // children under the key it has - see replaceActive.
348
+ setReplaceRootHandler((tree) => {
349
+ startTransition(() => root.render(tree));
350
+ });
311
351
  // Back and forward reveal a page the boundaries are still holding, with the
312
352
  // form you were filling in still filled in, and without asking the server.
313
353
  setRestoreHandler((key, maxAge) => restoreSegments(key, maxAge));
354
+ setHeldHandlers(isHeld, dropHidden);
355
+ // A touch or a settled hover: the page is rendered hidden now, and the
356
+ // click reveals it. See warm() in navigate.ts and prerenderSegment.
357
+ setPrerenderHandler((tree, key, segmentDepth) => {
358
+ prerenderSegment(segmentDepth, key, tree);
359
+ });
314
360
  window.addEventListener("popstate", () => {
315
361
  // restore: back and forward reveal the page you were on, with its state.
316
362
  // Wherever the browser just went; not a literal this app wrote.