@rsc-kit/core 0.20.7 → 0.20.10

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 (55) 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/earlyHints.d.ts +14 -2
  7. package/dist/earlyHints.js +15 -8
  8. package/dist/earlyHints.js.map +1 -1
  9. package/dist/files.d.ts +38 -0
  10. package/dist/files.js +109 -0
  11. package/dist/files.js.map +1 -1
  12. package/dist/host.d.ts +11 -2
  13. package/dist/host.js +38 -8
  14. package/dist/host.js.map +1 -1
  15. package/dist/js/ActivityRouter.d.ts +11 -0
  16. package/dist/js/ActivityRouter.js +19 -4
  17. package/dist/js/ActivityRouter.js.map +1 -1
  18. package/dist/js/Form.js +24 -2
  19. package/dist/js/Form.js.map +1 -1
  20. package/dist/js/PathnameProvider.d.ts +15 -2
  21. package/dist/js/PathnameProvider.js +17 -2
  22. package/dist/js/PathnameProvider.js.map +1 -1
  23. package/dist/js/SegmentBoundary.js +19 -3
  24. package/dist/js/SegmentBoundary.js.map +1 -1
  25. package/dist/js/activityMarkers.d.ts +7 -0
  26. package/dist/js/activityMarkers.js +18 -0
  27. package/dist/js/activityMarkers.js.map +1 -0
  28. package/dist/js/createViteRscApp.js +122 -76
  29. package/dist/js/createViteRscApp.js.map +1 -1
  30. package/dist/js/earlyClicks.d.ts +3 -1
  31. package/dist/js/earlyClicks.js +28 -5
  32. package/dist/js/earlyClicks.js.map +1 -1
  33. package/dist/js/errors.d.ts +2 -0
  34. package/dist/js/errors.js +11 -0
  35. package/dist/js/errors.js.map +1 -1
  36. package/dist/js/imagePreload.d.ts +37 -0
  37. package/dist/js/imagePreload.js +116 -0
  38. package/dist/js/imagePreload.js.map +1 -0
  39. package/dist/js/navigate.d.ts +33 -0
  40. package/dist/js/navigate.js +200 -12
  41. package/dist/js/navigate.js.map +1 -1
  42. package/dist/js/segmentStore.d.ts +58 -0
  43. package/dist/js/segmentStore.js +134 -4
  44. package/dist/js/segmentStore.js.map +1 -1
  45. package/dist/js/staleAssets.d.ts +10 -0
  46. package/dist/js/staleAssets.js +23 -2
  47. package/dist/js/staleAssets.js.map +1 -1
  48. package/dist/js/viewportPrefetch.js +27 -2
  49. package/dist/js/viewportPrefetch.js.map +1 -1
  50. package/dist/shellHead.d.ts +22 -0
  51. package/dist/shellHead.js +43 -0
  52. package/dist/shellHead.js.map +1 -0
  53. package/dist/vite.js +237 -55
  54. package/dist/vite.js.map +1 -1
  55. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/js/errors.ts"],"names":[],"mappings":"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","sourcesContent":["export class ServerValidationError extends Error {\n public readonly errors: Record<string, string[]>;\n\n constructor(message: string, errors: Record<string, string[]>) {\n super(message);\n this.name = \"ServerValidationError\";\n this.errors = errors;\n }\n}\n\n/** An action answered with a location instead of a result. */\n/**\n * The redirect the last action answered with, for the one caller that asks.\n *\n * An action that redirects resolves - the navigation is already under way\n * and the caller has nothing to do - which leaves a form that wants to know\n * whether to say \"saved\" with no way to tell a redirect from a void answer.\n * callServer notes the destination here; <Form> reads and clears it right\n * after its await. Nothing else needs to.\n */\nlet lastRedirect: string | null = null;\n\nexport function noteRedirected(location: string): void {\n lastRedirect = location;\n}\n\n/** The redirect the action just performed, if it did - read once. */\nexport function redirectedTo(): string | null {\n const location = lastRedirect;\n\n lastRedirect = null;\n\n return location;\n}\n\nexport class ServerRedirectError extends Error {\n public readonly location: string;\n\n constructor(location: string) {\n super(`Server action redirected to ${location}`);\n this.name = \"ServerRedirectError\";\n this.location = location;\n }\n}\n\nexport class ServerAuthenticationError extends Error {\n constructor(message: string = \"Unauthenticated.\") {\n super(message);\n this.name = \"ServerAuthenticationError\";\n }\n}\n\nexport class ServerAuthorizationError extends Error {\n constructor(message: string = \"This action is unauthorized.\") {\n super(message);\n this.name = \"ServerAuthorizationError\";\n }\n}\n\nexport class ServerDumpError extends Error {\n constructor() {\n super(\"Server returned a dump response.\");\n this.name = \"ServerDumpError\";\n }\n}\n\nexport class ServerSessionExpiredError extends Error {\n constructor(message: string = \"Your session has expired. Please refresh the page.\") {\n super(message);\n this.name = \"ServerSessionExpiredError\";\n }\n}\n\n/**\n * Turn a failed server-action response into the error it describes.\n *\n * A server action that does not succeed answers with JSON or a redirect\n * header rather than a Flight stream. Passing one of those to the Flight\n * decoder does not produce the server's message — it produces an internal\n * parser failure (\"enqueueModel is not a function\") or a truncated read\n * (\"Connection closed.\"), which is what reached onError before this existed.\n *\n * Returns without throwing when the response is a stream to be decoded.\n */\nexport async function throwForFailedAction(response: Response): Promise<void> {\n // Before the status: a redirect the action asked for is a 204 with the\n // destination in this header - an ok answer with no Flight in it - and\n // an expired session's is a 401. Read first, the header decides for both.\n // Read after `ok`, the 204 fell through to the Flight decoder with an\n // empty body, and the form waited forever.\n const location = response.headers.get(\"X-RSC-Redirect\");\n\n if (location !== null && location !== \"\") {\n throw new ServerRedirectError(location);\n }\n\n if (response.ok) return;\n\n if (response.status === 422) {\n const payload = (await response.json().catch(() => null)) as\n | { message?: string; errors?: Record<string, string[]> }\n | null;\n\n throw new ServerValidationError(\n payload?.message ?? \"Validation failed\",\n payload?.errors ?? {},\n );\n }\n\n if (response.status === 413) {\n throw new Error(\n \"Server action refused: the request body is larger than the server accepts. \" +\n \"A file upload is the usual cause; the limit is the host's maxActionBody.\",\n );\n }\n\n throw new Error(`Server action failed with ${response.status}`);\n}\n\n/**\n * Reject a payload response that is not one.\n *\n * The page a PPR route serves is a static shell: real HTML, status 200, with\n * its Suspense fallbacks showing. Everything below them arrives in a second\n * request. If that request fails there is nothing on screen to say so — the\n * skeletons simply stay, for ever — and handing the failure body to the Flight\n * decoder reports the decoder's confusion rather than the status.\n */\nexport function throwForFailedPayload(response: Response): void {\n if (response.ok) return;\n\n throw new Error(`RSC payload request failed with ${response.status}`);\n}\n\n/**\n * Announce a failure that nothing else will.\n *\n * Dispatched as well as logged: an app that wants to replace a stuck skeleton\n * with something honest has no other way to find out.\n */\nexport function reportClientFailure(scope: string, error: unknown): void {\n if (typeof window !== \"undefined\") {\n window.dispatchEvent(new CustomEvent(\"rsc-client-error\", { detail: { scope, error } }));\n }\n\n console.error(`[rsc-kit] ${scope}`, error);\n}\n"]}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/js/errors.ts"],"names":[],"mappings":"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","sourcesContent":["export class ServerValidationError extends Error {\n public readonly errors: Record<string, string[]>;\n\n constructor(message: string, errors: Record<string, string[]>) {\n super(message);\n this.name = \"ServerValidationError\";\n this.errors = errors;\n }\n}\n\n/** An action answered with a location instead of a result. */\n/**\n * The redirect the last action answered with, for the one caller that asks.\n *\n * An action that redirects resolves - the navigation is already under way\n * and the caller has nothing to do - which leaves a form that wants to know\n * whether to say \"saved\" with no way to tell a redirect from a void answer.\n * callServer notes the destination here; <Form> reads and clears it right\n * after its await. Nothing else needs to.\n */\nlet lastRedirect: string | null = null;\n\nexport function noteRedirected(location: string): void {\n lastRedirect = location;\n}\n\n/** The redirect the action just performed, if it did - read once. */\nexport function redirectedTo(): string | null {\n const location = lastRedirect;\n\n lastRedirect = null;\n\n return location;\n}\n\nexport class ServerRedirectError extends Error {\n public readonly location: string;\n\n constructor(location: string) {\n super(`Server action redirected to ${location}`);\n this.name = \"ServerRedirectError\";\n this.location = location;\n }\n}\n\nexport class ServerAuthenticationError extends Error {\n constructor(message: string = \"Unauthenticated.\") {\n super(message);\n this.name = \"ServerAuthenticationError\";\n }\n}\n\nexport class ServerAuthorizationError extends Error {\n constructor(message: string = \"This action is unauthorized.\") {\n super(message);\n this.name = \"ServerAuthorizationError\";\n }\n}\n\nexport class ServerDumpError extends Error {\n constructor() {\n super(\"Server returned a dump response.\");\n this.name = \"ServerDumpError\";\n }\n}\n\nexport class ServerSessionExpiredError extends Error {\n constructor(message: string = \"Your session has expired. Please refresh the page.\") {\n super(message);\n this.name = \"ServerSessionExpiredError\";\n }\n}\n\n/**\n * Turn a failed server-action response into the error it describes.\n *\n * A server action that does not succeed answers with JSON or a redirect\n * header rather than a Flight stream. Passing one of those to the Flight\n * decoder does not produce the server's message — it produces an internal\n * parser failure (\"enqueueModel is not a function\") or a truncated read\n * (\"Connection closed.\"), which is what reached onError before this existed.\n *\n * Returns without throwing when the response is a stream to be decoded.\n */\nexport async function throwForFailedAction(response: Response): Promise<void> {\n // Before the status: a redirect the action asked for is a 204 with the\n // destination in this header - an ok answer with no Flight in it - and\n // an expired session's is a 401. Read first, the header decides for both.\n // Read after `ok`, the 204 fell through to the Flight decoder with an\n // empty body, and the form waited forever.\n const location = response.headers.get(\"X-RSC-Redirect\");\n\n if (location !== null && location !== \"\") {\n throw new ServerRedirectError(location);\n }\n\n if (response.ok) return;\n\n if (response.status === 422) {\n const payload = (await response.json().catch(() => null)) as\n | { message?: string; errors?: Record<string, string[]> }\n | null;\n\n throw new ServerValidationError(\n payload?.message ?? \"Validation failed\",\n payload?.errors ?? {},\n );\n }\n\n if (response.status === 413) {\n throw new Error(\n \"Server action refused: the request body is larger than the server accepts. \" +\n \"A file upload is the usual cause; the limit is the host's maxActionBody.\",\n );\n }\n\n throw new Error(`Server action failed with ${response.status}`);\n}\n\n/**\n * Reject a payload response that is not one.\n *\n * The page a PPR route serves is a static shell: real HTML, status 200, with\n * its Suspense fallbacks showing. Everything below them arrives in a second\n * request. If that request fails there is nothing on screen to say so — the\n * skeletons simply stay, for ever — and handing the failure body to the Flight\n * decoder reports the decoder's confusion rather than the status.\n */\nexport function throwForFailedPayload(response: Response): void {\n if (response.ok) return;\n\n // A 404 that is a payload is the not-found page, rendered: a url a\n // pattern's shell answered with a 200 and a page that, run for real,\n // found no row - a subcategory under the wrong category. The page is\n // what the visitor should see; refusing it left the shell's content on\n // screen with nothing hydrated behind it, and every tap dead.\n if (isPayload(response)) return;\n\n throw new Error(`RSC payload request failed with ${response.status}`);\n}\n\n/** Whether a response is a Flight payload, whatever its status. */\nexport function isPayload(response: Response): boolean {\n return (response.headers.get(\"Content-Type\") ?? \"\").includes(\"text/x-component\");\n}\n\n/**\n * Announce a failure that nothing else will.\n *\n * Dispatched as well as logged: an app that wants to replace a stuck skeleton\n * with something honest has no other way to find out.\n */\nexport function reportClientFailure(scope: string, error: unknown): void {\n if (typeof window !== \"undefined\") {\n window.dispatchEvent(new CustomEvent(\"rsc-client-error\", { detail: { scope, error } }));\n }\n\n console.error(`[rsc-kit] ${scope}`, error);\n}\n"]}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The pictures a prefetched page shows, fetched before the click.
3
+ *
4
+ * A payload names them: every <img> the page renders is a row in it, src
5
+ * and srcSet included. The page is rendered hidden on touch, and a hidden
6
+ * page's images do load then - but a touch leads its click by a few hundred
7
+ * milliseconds, and a product picture on a phone's connection takes
8
+ * longer. The original of a port fetched the next page's image list from a
9
+ * route as each link came into view; here the list is already in the
10
+ * payload the router holds, so the images are asked for as it lands, at low
11
+ * priority, and the click finds them decoded.
12
+ *
13
+ * Only the ones the page would load at once: an <img loading="lazy"> waits
14
+ * for the viewport on the page too. Bounded per payload, and each url once
15
+ * per document; nothing under Save-Data.
16
+ */
17
+ export type Priority = 'low' | 'high';
18
+ export interface ImageProps {
19
+ src?: string;
20
+ srcSet?: string;
21
+ sizes?: string;
22
+ loading?: string;
23
+ alt?: string;
24
+ }
25
+ /** The props of every <img> element row in a flight payload, in order. */
26
+ export declare function imagesIn(payload: string): ImageProps[];
27
+ /**
28
+ * Ask the browser for the eager images a payload names.
29
+ *
30
+ * Low as the payload lands on sight, behind everything the page itself is
31
+ * loading. High on intent - the touch, the settled hover - when this is the
32
+ * page about to show: a listing's two dozen tiles each preload their
33
+ * product's picture, and the one tapped first was queued behind the rest,
34
+ * for three hundred milliseconds on a phone; asked again as urgent, the
35
+ * browser moves it to the front, ahead of the pictures nobody touched.
36
+ */
37
+ export declare function preloadImages(payload: string, priority?: Priority): number;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The pictures a prefetched page shows, fetched before the click.
3
+ *
4
+ * A payload names them: every <img> the page renders is a row in it, src
5
+ * and srcSet included. The page is rendered hidden on touch, and a hidden
6
+ * page's images do load then - but a touch leads its click by a few hundred
7
+ * milliseconds, and a product picture on a phone's connection takes
8
+ * longer. The original of a port fetched the next page's image list from a
9
+ * route as each link came into view; here the list is already in the
10
+ * payload the router holds, so the images are asked for as it lands, at low
11
+ * priority, and the click finds them decoded.
12
+ *
13
+ * Only the ones the page would load at once: an <img loading="lazy"> waits
14
+ * for the viewport on the page too. Bounded per payload, and each url once
15
+ * per document; nothing under Save-Data.
16
+ */
17
+ /**
18
+ * Per payload, the first few: the pictures a page shows first are the ones
19
+ * at the top of its payload - a product's own picture before its related
20
+ * ones. Twenty-four a page, times the dozen pages a listing prefetches on
21
+ * sight, was three hundred requests on a phone's radio the moment the home
22
+ * page settled, and the next tap's payload queued behind them: "clicking a
23
+ * category breaks navigation for a few seconds".
24
+ */
25
+ const PER_PAGE = 6;
26
+ /** Across the document: a home page with five hundred links in view is not five hundred pages of pictures. */
27
+ const PER_DOCUMENT = 96;
28
+ /** Each picture asked for, and how urgently. */
29
+ const asked = new Map();
30
+ /** The props of every <img> element row in a flight payload, in order. */
31
+ export function imagesIn(payload) {
32
+ const found = [];
33
+ let at = 0;
34
+ while (found.length < PER_PAGE) {
35
+ const start = payload.indexOf('["$","img",', at);
36
+ if (start === -1)
37
+ break;
38
+ // Past the key: `["$","img","key",{` or `["$","img",null,{`.
39
+ const brace = payload.indexOf('{', start);
40
+ if (brace === -1)
41
+ break;
42
+ const end = closingBrace(payload, brace);
43
+ at = end === -1 ? brace + 1 : end + 1;
44
+ if (end === -1)
45
+ continue;
46
+ try {
47
+ found.push(JSON.parse(payload.slice(brace, end + 1)));
48
+ }
49
+ catch {
50
+ // A props object with a reference in it that is not JSON; not a picture worth guessing at.
51
+ }
52
+ }
53
+ return found;
54
+ }
55
+ /** The index of the brace closing the object opened at `open`, honouring strings. */
56
+ function closingBrace(text, open) {
57
+ let depth = 0;
58
+ let inString = false;
59
+ for (let i = open; i < text.length; i++) {
60
+ const c = text[i];
61
+ if (inString) {
62
+ if (c === '\\')
63
+ i++;
64
+ else if (c === '"')
65
+ inString = false;
66
+ continue;
67
+ }
68
+ if (c === '"')
69
+ inString = true;
70
+ else if (c === '{')
71
+ depth++;
72
+ else if (c === '}' && --depth === 0)
73
+ return i;
74
+ }
75
+ return -1;
76
+ }
77
+ /**
78
+ * Ask the browser for the eager images a payload names.
79
+ *
80
+ * Low as the payload lands on sight, behind everything the page itself is
81
+ * loading. High on intent - the touch, the settled hover - when this is the
82
+ * page about to show: a listing's two dozen tiles each preload their
83
+ * product's picture, and the one tapped first was queued behind the rest,
84
+ * for three hundred milliseconds on a phone; asked again as urgent, the
85
+ * browser moves it to the front, ahead of the pictures nobody touched.
86
+ */
87
+ export function preloadImages(payload, priority = 'low') {
88
+ if (typeof document === 'undefined')
89
+ return 0;
90
+ if (navigator.connection?.saveData)
91
+ return 0;
92
+ let started = 0;
93
+ for (const props of imagesIn(payload)) {
94
+ if (asked.size >= PER_DOCUMENT && priority === 'low')
95
+ break;
96
+ if (props.loading === 'lazy' || !props.src)
97
+ continue;
98
+ const key = props.srcSet ?? props.src;
99
+ const before = asked.get(key);
100
+ if (before === 'high' || before === priority)
101
+ continue;
102
+ asked.set(key, priority);
103
+ const img = new Image();
104
+ img.decoding = 'async';
105
+ img.fetchPriority = priority;
106
+ // sizes before srcset before src: the browser chooses on assignment.
107
+ if (props.sizes)
108
+ img.sizes = props.sizes;
109
+ if (props.srcSet)
110
+ img.srcset = props.srcSet;
111
+ img.src = props.src;
112
+ started++;
113
+ }
114
+ return started;
115
+ }
116
+ //# sourceMappingURL=imagePreload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"imagePreload.js","sourceRoot":"","sources":["../../src/js/imagePreload.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The pictures a prefetched page shows, fetched before the click.\n *\n * A payload names them: every <img> the page renders is a row in it, src\n * and srcSet included. The page is rendered hidden on touch, and a hidden\n * page's images do load then - but a touch leads its click by a few hundred\n * milliseconds, and a product picture on a phone's connection takes\n * longer. The original of a port fetched the next page's image list from a\n * route as each link came into view; here the list is already in the\n * payload the router holds, so the images are asked for as it lands, at low\n * priority, and the click finds them decoded.\n *\n * Only the ones the page would load at once: an <img loading=\"lazy\"> waits\n * for the viewport on the page too. Bounded per payload, and each url once\n * per document; nothing under Save-Data.\n */\n\n/**\n * Per payload, the first few: the pictures a page shows first are the ones\n * at the top of its payload - a product's own picture before its related\n * ones. Twenty-four a page, times the dozen pages a listing prefetches on\n * sight, was three hundred requests on a phone's radio the moment the home\n * page settled, and the next tap's payload queued behind them: \"clicking a\n * category breaks navigation for a few seconds\".\n */\nconst PER_PAGE = 6\n/** Across the document: a home page with five hundred links in view is not five hundred pages of pictures. */\nconst PER_DOCUMENT = 96\n/** Each picture asked for, and how urgently. */\nconst asked = new Map<string, Priority>()\n\nexport type Priority = 'low' | 'high'\n\nexport interface ImageProps {\n src?: string\n srcSet?: string\n sizes?: string\n loading?: string\n alt?: string\n}\n\n/** The props of every <img> element row in a flight payload, in order. */\nexport function imagesIn(payload: string): ImageProps[] {\n const found: ImageProps[] = []\n let at = 0\n\n while (found.length < PER_PAGE) {\n const start = payload.indexOf('[\"$\",\"img\",', at)\n\n if (start === -1) break\n\n // Past the key: `[\"$\",\"img\",\"key\",{` or `[\"$\",\"img\",null,{`.\n const brace = payload.indexOf('{', start)\n\n if (brace === -1) break\n\n const end = closingBrace(payload, brace)\n\n at = end === -1 ? brace + 1 : end + 1\n\n if (end === -1) continue\n\n try {\n found.push(JSON.parse(payload.slice(brace, end + 1)) as ImageProps)\n } catch {\n // A props object with a reference in it that is not JSON; not a picture worth guessing at.\n }\n }\n\n return found\n}\n\n/** The index of the brace closing the object opened at `open`, honouring strings. */\nfunction closingBrace(text: string, open: number): number {\n let depth = 0\n let inString = false\n\n for (let i = open; i < text.length; i++) {\n const c = text[i]\n\n if (inString) {\n if (c === '\\\\') i++\n else if (c === '\"') inString = false\n\n continue\n }\n\n if (c === '\"') inString = true\n else if (c === '{') depth++\n else if (c === '}' && --depth === 0) return i\n }\n\n return -1\n}\n\n/**\n * Ask the browser for the eager images a payload names.\n *\n * Low as the payload lands on sight, behind everything the page itself is\n * loading. High on intent - the touch, the settled hover - when this is the\n * page about to show: a listing's two dozen tiles each preload their\n * product's picture, and the one tapped first was queued behind the rest,\n * for three hundred milliseconds on a phone; asked again as urgent, the\n * browser moves it to the front, ahead of the pictures nobody touched.\n */\nexport function preloadImages(payload: string, priority: Priority = 'low'): number {\n if (typeof document === 'undefined') return 0\n if ((navigator as { connection?: { saveData?: boolean } }).connection?.saveData) return 0\n\n let started = 0\n\n for (const props of imagesIn(payload)) {\n if (asked.size >= PER_DOCUMENT && priority === 'low') break\n if (props.loading === 'lazy' || !props.src) continue\n\n const key = props.srcSet ?? props.src\n const before = asked.get(key)\n\n if (before === 'high' || before === priority) continue\n\n asked.set(key, priority)\n\n const img = new Image()\n\n img.decoding = 'async'\n ;(img as { fetchPriority?: string }).fetchPriority = priority\n // sizes before srcset before src: the browser chooses on assignment.\n if (props.sizes) img.sizes = props.sizes\n if (props.srcSet) img.srcset = props.srcSet\n img.src = props.src\n started++\n }\n\n return started\n}\n"]}
@@ -37,6 +37,20 @@ export declare function setVersion(v: string): void;
37
37
  export declare function setHeldLayouts(chain: string[]): void;
38
38
  export declare function getHeldLayouts(): string[];
39
39
  export declare function setNavigateHandler(fn: (tree: ReactNode, key: string, segmentDepth: number) => void): void;
40
+ export declare function setReplaceRootHandler(fn: ((tree: ReactNode) => void) | null): void;
41
+ export declare function setHeldHandlers(held: ((key: string, maxAge?: number) => boolean) | null, drop: (() => void) | null): void;
42
+ /**
43
+ * Everything the router holds about pages other than the one on screen,
44
+ * dropped: prefetched payloads, and pages kept behind this one.
45
+ *
46
+ * After an action that revalidated. The list a link prefetched before the
47
+ * mutation still shows the table without the new row - visit() landed on
48
+ * it with no request made, and a reload showed the row. What was fetched
49
+ * before a write is not a cache of what is true after it; the pages held
50
+ * for the back button are from before it too.
51
+ */
52
+ export declare function forgetOtherPages(): void;
53
+ export declare function setPrerenderHandler(fn: ((tree: ReactNode, key: string, segmentDepth: number) => void) | null): void;
40
54
  /**
41
55
  * How the router reveals a page that is still mounted behind the current one.
42
56
  *
@@ -88,6 +102,25 @@ export declare function navigate(url: Route, opts?: {
88
102
  * is the same apply path a navigation uses — without a request, a url change
89
103
  * or a history entry.
90
104
  */
105
+ /**
106
+ * Whether the page an action was invoked from is the one on screen.
107
+ *
108
+ * The one underneath, when an interception is showing: a modal opened after
109
+ * the submit sits over the same page.
110
+ */
111
+ export declare function stillShowing(url: string): boolean;
112
+ /**
113
+ * Put what an action re-rendered on screen.
114
+ *
115
+ * `from` is the url the action was invoked on - what the host rendered the
116
+ * trees for. A tap that left the page while the action was in flight has
117
+ * changed what is showing, and a document rendered for the page before
118
+ * cannot go under the url after: "Add to cart", then the brand link at
119
+ * once, showed the home page and then, when the answer landed, the product
120
+ * again under `/`. The write still happened, and the page on screen was
121
+ * fetched before it - so that page is asked for again, whole, instead.
122
+ */
123
+ export declare function applyRevalidations(from: string, revalidated: Record<string, ReactNode>): void;
91
124
  export declare function applyRevalidated(target: string, tree: ReactNode): void;
92
125
  /**
93
126
  * Ask the server for part of this page again.
@@ -5,9 +5,10 @@
5
5
  * The Flight deserializer is injected by createViteRscApp to avoid
6
6
  * duplicate bundling of react-server-dom-webpack.
7
7
  */
8
- import { isStaleAssetError, loadDocumentOnce } from "./staleAssets";
8
+ import { announceDocumentLoad, isStaleAssetError, loadDocumentOnce, } from "./staleAssets";
9
9
  import { isUpdated, markStale } from "./updateStore";
10
10
  import { navigationAbandoned, navigationCommitted, navigationReached, navigationStarted } from "./perf";
11
+ import { preloadImages } from "./imagePreload";
11
12
  import { isSafeRedirect } from "../safeUrl.js";
12
13
  import { reportReachable } from "./onlineStore";
13
14
  import { clearSlots, setSlot } from "./slotStore";
@@ -19,6 +20,22 @@ let version = "";
19
20
  const navigating = new Set();
20
21
  let onNavigate = null;
21
22
  let onRestore = null;
23
+ /**
24
+ * Re-render the whole document in place, for revalidate("all"): the page the
25
+ * visitor is on, not a page they went to. Without one registered, the
26
+ * navigation handler at depth 0 is used, which shows the tree as a page.
27
+ */
28
+ let onReplaceRoot = null;
29
+ /** Whether a navigation to the key would reveal a held page, asked without revealing it. */
30
+ let isHeldPage = null;
31
+ /** Drop the pages held behind the one on screen - after a mutation. */
32
+ let dropHeld = null;
33
+ /**
34
+ * Render a decoded page in the background, hidden, before the click - see
35
+ * warm(). Given the same tree the navigation will hand to onNavigate, so
36
+ * that the navigation is a reveal of work already done rather than a render.
37
+ */
38
+ let onPrerender = null;
22
39
  /**
23
40
  * How stale a held page may be and still be revealed by a link.
24
41
  *
@@ -42,6 +59,33 @@ let interceptManifest = [];
42
59
  // The layout chain currently mounted, outermost first. Sent so the server can
43
60
  // skip re-rendering the layouts still on screen.
44
61
  let heldLayouts = [];
62
+ /**
63
+ * The chain each page was shown under, by retention key.
64
+ *
65
+ * A held page revealed - the back button, or a link to the page just left -
66
+ * puts its layouts back on screen, and the chain has to say so. It used to
67
+ * keep the chain of the page being left: home, a category, back to home,
68
+ * then another category claimed the first category's layout as mounted. The
69
+ * server sent the page alone, and the client put it in the boundary that
70
+ * layout owns - inside the hidden category. The url changed and the page did
71
+ * not, and every tap after it did the same, until a reload. The demo froze
72
+ * on a phone within a dozen taps.
73
+ *
74
+ * Bounded like the payload cache: a key that is no longer held is never
75
+ * asked for, so the oldest can go.
76
+ */
77
+ const chainOf = new Map();
78
+ const MAX_CHAINS = 64;
79
+ function rememberChain(key) {
80
+ chainOf.delete(key);
81
+ chainOf.set(key, heldLayouts);
82
+ while (chainOf.size > MAX_CHAINS) {
83
+ const oldest = chainOf.keys().next().value;
84
+ if (oldest === undefined)
85
+ break;
86
+ chainOf.delete(oldest);
87
+ }
88
+ }
45
89
  /**
46
90
  * The boundary depth an interception was rendered at, while one is showing.
47
91
  *
@@ -61,6 +105,11 @@ let interceptedAtDepth = null;
61
105
  * screen with everything the user typed into it.
62
106
  */
63
107
  let interceptedOver = null;
108
+ /**
109
+ * The url an interception was opened from, whichever way it was rendered -
110
+ * the page still on screen under the modal. Only stillShowing asks.
111
+ */
112
+ let interceptedFrom = null;
64
113
  const DEFAULT_PREFETCH_TTL = 30_000;
65
114
  /**
66
115
  * How long a payload the host marked `public` is kept.
@@ -130,6 +179,7 @@ export function seedStaticChain(url) {
130
179
  if (!segments)
131
180
  return false;
132
181
  heldLayouts = segments.chain;
182
+ rememberChain(retentionKey(url, null));
133
183
  return true;
134
184
  }
135
185
  /**
@@ -218,6 +268,7 @@ async function tellWorkerServerBuild(build) {
218
268
  */
219
269
  export function setHeldLayouts(chain) {
220
270
  heldLayouts = chain;
271
+ rememberChain(retentionKey(window.location.href, null));
221
272
  }
222
273
  export function getHeldLayouts() {
223
274
  return heldLayouts;
@@ -225,6 +276,34 @@ export function getHeldLayouts() {
225
276
  export function setNavigateHandler(fn) {
226
277
  onNavigate = fn;
227
278
  }
279
+ export function setReplaceRootHandler(fn) {
280
+ onReplaceRoot = fn;
281
+ }
282
+ export function setHeldHandlers(held, drop) {
283
+ isHeldPage = held;
284
+ dropHeld = drop;
285
+ }
286
+ /**
287
+ * Everything the router holds about pages other than the one on screen,
288
+ * dropped: prefetched payloads, and pages kept behind this one.
289
+ *
290
+ * After an action that revalidated. The list a link prefetched before the
291
+ * mutation still shows the table without the new row - visit() landed on
292
+ * it with no request made, and a reload showed the row. What was fetched
293
+ * before a write is not a cache of what is true after it; the pages held
294
+ * for the back button are from before it too.
295
+ */
296
+ export function forgetOtherPages() {
297
+ for (const [key, controller] of prefetchControllers) {
298
+ controller.abort();
299
+ prefetchControllers.delete(key);
300
+ }
301
+ cache.clear();
302
+ dropHeld?.();
303
+ }
304
+ export function setPrerenderHandler(fn) {
305
+ onPrerender = fn;
306
+ }
228
307
  /**
229
308
  * How the router reveals a page that is still mounted behind the current one.
230
309
  *
@@ -491,6 +570,7 @@ export async function navigate(url, opts) {
491
570
  // advice. Not for a restore: going back to a page still held asks the
492
571
  // server for nothing.
493
572
  if (!opts?.restore && isUpdated()) {
573
+ announceDocumentLoad(url, "newer-build");
494
574
  window.location.href = url;
495
575
  return;
496
576
  }
@@ -499,12 +579,14 @@ export async function navigate(url, opts) {
499
579
  }
500
580
  // External URLs can't be fetched (CORS) — go directly to full page navigation
501
581
  if (isExternalUrl(url)) {
582
+ announceDocumentLoad(url, "external");
502
583
  window.location.href = url;
503
584
  return;
504
585
  }
505
586
  // A route.ts answers with a Response, not a page: a download, a redirect
506
587
  // that decides where someone belongs, a sign-out. The browser goes there.
507
588
  if (isApiRoute(url)) {
589
+ announceDocumentLoad(url, "api-route");
508
590
  window.location.href = url;
509
591
  return;
510
592
  }
@@ -550,6 +632,7 @@ export async function navigate(url, opts) {
550
632
  retentionKey(url, null) === retentionKey(interceptedOver, null)) {
551
633
  clearSlots();
552
634
  interceptedOver = null;
635
+ interceptedFrom = null;
553
636
  interceptedAtDepth = null;
554
637
  if (opts?.replace) {
555
638
  history.replaceState({ rscUrl: url }, "", url);
@@ -581,8 +664,14 @@ export async function navigate(url, opts) {
581
664
  onRestore?.(activityKey, opts?.restore ? undefined : revealWithin)) {
582
665
  // A restored tree carries its own slot contents, so the flag only has to
583
666
  // reflect whether what is now showing is an intercepted view.
584
- if (!interceptSlot)
667
+ if (!interceptSlot) {
585
668
  interceptedAtDepth = null;
669
+ interceptedFrom = null;
670
+ }
671
+ // Its layouts are the ones on screen now - see chainOf.
672
+ const chain = chainOf.get(activityKey);
673
+ if (chain)
674
+ heldLayouts = chain;
586
675
  // opts?.replace, not opts.replace: this branch used to be reachable only
587
676
  // with opts.restore set, so opts was always there. A link reaches it now
588
677
  // with nothing passed at all.
@@ -665,6 +754,7 @@ export async function navigate(url, opts) {
665
754
  const contentType = response.headers.get("Content-Type") ?? "";
666
755
  if (staticPayloadSuffix === null &&
667
756
  !contentType.includes("text/x-component")) {
757
+ announceDocumentLoad(url, `not-a-payload:${response.status}:${contentType.split(";")[0]}`);
668
758
  window.location.href = url;
669
759
  return;
670
760
  }
@@ -729,6 +819,7 @@ export async function navigate(url, opts) {
729
819
  }
730
820
  if (nextLayouts !== null)
731
821
  heldLayouts = nextLayouts;
822
+ rememberChain(activityKey);
732
823
  // The answer is one region, not a piece of the page: the host rendered
733
824
  // only the interceptor because the page underneath is already mounted and
734
825
  // still correct. Putting it in the slot leaves that page — and everything
@@ -736,6 +827,7 @@ export async function navigate(url, opts) {
736
827
  if (slotPayload !== null) {
737
828
  setSlot(slotPayload, tree);
738
829
  interceptedOver = interceptedOver ?? previousUrl;
830
+ interceptedFrom = interceptedFrom ?? previousUrl;
739
831
  interceptedAtDepth = null;
740
832
  return;
741
833
  }
@@ -744,6 +836,7 @@ export async function navigate(url, opts) {
744
836
  clearSlots();
745
837
  interceptedOver = null;
746
838
  interceptedAtDepth = interceptSlot ? segmentDepth : null;
839
+ interceptedFrom = interceptSlot ? (interceptedFrom ?? previousUrl) : null;
747
840
  navigationReached("applied");
748
841
  onNavigate?.(tree, activityKey, segmentDepth);
749
842
  if (!opts?.preserveScroll && !interceptSlot) {
@@ -762,6 +855,7 @@ export async function navigate(url, opts) {
762
855
  // A chunk the deploy no longer serves: the browser would have loaded the
763
856
  // document, and the new names with it. Do what it would have done.
764
857
  if (isStaleAssetError(err)) {
858
+ announceDocumentLoad(url, `stale-asset:${String(err?.message ?? err).slice(0, 120)}`);
765
859
  window.location.href = url;
766
860
  return;
767
861
  }
@@ -785,12 +879,51 @@ export async function navigate(url, opts) {
785
879
  * is the same apply path a navigation uses — without a request, a url change
786
880
  * or a history entry.
787
881
  */
882
+ /**
883
+ * Whether the page an action was invoked from is the one on screen.
884
+ *
885
+ * The one underneath, when an interception is showing: a modal opened after
886
+ * the submit sits over the same page.
887
+ */
888
+ export function stillShowing(url) {
889
+ const key = retentionKey(url, null);
890
+ const now = retentionKey(window.location.pathname + window.location.search, null);
891
+ return key === now || (interceptedFrom !== null && key === retentionKey(interceptedFrom, null));
892
+ }
893
+ /**
894
+ * Put what an action re-rendered on screen.
895
+ *
896
+ * `from` is the url the action was invoked on - what the host rendered the
897
+ * trees for. A tap that left the page while the action was in flight has
898
+ * changed what is showing, and a document rendered for the page before
899
+ * cannot go under the url after: "Add to cart", then the brand link at
900
+ * once, showed the home page and then, when the answer landed, the product
901
+ * again under `/`. The write still happened, and the page on screen was
902
+ * fetched before it - so that page is asked for again, whole, instead.
903
+ */
904
+ export function applyRevalidations(from, revalidated) {
905
+ // What was fetched or held before the write is from before it.
906
+ forgetOtherPages();
907
+ if (!stillShowing(from)) {
908
+ void refresh("all");
909
+ return;
910
+ }
911
+ for (const [target, tree] of Object.entries(revalidated)) {
912
+ applyRevalidated(target, tree);
913
+ }
914
+ }
788
915
  export function applyRevalidated(target, tree) {
789
916
  const url = window.location.pathname + window.location.search;
790
917
  const key = retentionKey(url, null);
791
918
  if (target === "all") {
792
- // Depth 0 replaces the root, which is what re-rendering the layouts means.
793
- onNavigate?.(tree, key, 0);
919
+ // The whole document again, in place. Not a navigation to this url:
920
+ // the visible entry may be keyed by the url the document loaded with,
921
+ // and showing the tree under the current one made a second entry and
922
+ // remounted the app under it.
923
+ if (onReplaceRoot)
924
+ onReplaceRoot(tree);
925
+ else
926
+ onNavigate?.(tree, key, 0);
794
927
  return;
795
928
  }
796
929
  if (target === "page") {
@@ -818,6 +951,9 @@ export function applyRevalidated(target, tree) {
818
951
  */
819
952
  export async function refresh(target = "page") {
820
953
  const url = window.location.pathname + window.location.search;
954
+ // Asked because the data moved on; what was fetched or held before is
955
+ // from before.
956
+ forgetOtherPages();
821
957
  if (target !== "page" && target !== "all") {
822
958
  const response = await fetch(payloadUrl(url), {
823
959
  headers: {
@@ -941,6 +1077,11 @@ export function prefetch(url, cacheForMs, intent = false) {
941
1077
  // in view, was a 14 KB payload for the page already on screen.
942
1078
  if (retentionKey(url, null) === retentionKey(window.location.href, null))
943
1079
  return;
1080
+ // Nor a page still held behind this one, which a navigation would reveal
1081
+ // rather than fetch: the page just left, whose link is on every page,
1082
+ // was one wasted payload per navigation.
1083
+ if (!matchIntercept(url) && isHeldPage?.(retentionKey(url, null), revealWithin))
1084
+ return;
944
1085
  // Nor anything, once this page is known to be the previous build: the
945
1086
  // next navigation is a document load, and a payload it would not use is
946
1087
  // a 409 for nothing - one per tap, measured.
@@ -958,15 +1099,57 @@ export function prefetch(url, cacheForMs, intent = false) {
958
1099
  prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl, held);
959
1100
  }
960
1101
  else {
961
- prefetchUrl(url, url, ttl, undefined, undefined, held);
1102
+ prefetchUrl(retentionKeyFor(url, null), url, ttl, undefined, undefined, held);
962
1103
  }
963
1104
  }
964
- /** Decode a held payload now, so the chunks it names are loading before the tap. */
965
- function warm(entry) {
1105
+ /**
1106
+ * Do the click's work now: decode the held payload, which loads the chunks
1107
+ * it names, and render the page hidden so the click is a reveal.
1108
+ *
1109
+ * The numbers this came from, on an iPhone with everything prefetched: a
1110
+ * first visit to the landing page was 107 ms from tap to paint, 4 of them
1111
+ * decoding and 87 rendering; a page still held from a visit before was
1112
+ * 15 ms, a reveal. The render is the page's size on the phone's CPU, and
1113
+ * nothing in the payload's path shrinks it - but it can be paid before the
1114
+ * finger lifts. A touch leads its click by 100-300 ms; a settled hover, by
1115
+ * 200 or more. React renders a hidden Activity at idle priority, so the
1116
+ * work yields to scrolling, and the navigation then hands the boundary the
1117
+ * same tree it prerendered: React bails out of the whole subtree and flips
1118
+ * it visible.
1119
+ *
1120
+ * Only a segment at depth 1 or more, rendered against the chain held now: a
1121
+ * whole document replaces the root, and a slot is a region of a page not on
1122
+ * screen yet. A prefetch that landed on a redirect - a guarded link, on a
1123
+ * landing page all of them - warms the destination it prefetched instead:
1124
+ * the tap on Sign in decoded the login page and loaded its chunks after the
1125
+ * click, 154 ms of a 225 ms tap.
1126
+ */
1127
+ function warm(entry, cacheKey) {
966
1128
  // The tree is read by the navigation that takes the entry, and a decode
967
1129
  // that fails - a chunk the deploy no longer serves - fails there, where it
968
1130
  // is handled. Here the rejection has nobody to reach.
969
- entry.tree.catch(() => { });
1131
+ entry.body
1132
+ .then((text) => {
1133
+ if (entry.redirectTo) {
1134
+ const destination = cache.get(retentionKeyFor(entry.redirectTo, matchIntercept(entry.redirectTo)));
1135
+ if (destination)
1136
+ warm(destination, retentionKeyFor(entry.redirectTo, matchIntercept(entry.redirectTo)));
1137
+ return;
1138
+ }
1139
+ if (text === null)
1140
+ return;
1141
+ // The page about to show: its pictures ahead of every other page's.
1142
+ preloadImages(text, "high");
1143
+ return entry.tree.then((tree) => {
1144
+ if (entry.slot !== null ||
1145
+ entry.segmentDepth === 0 ||
1146
+ !isUsable(entry, claimedChain(null)) ||
1147
+ cache.get(cacheKey) !== entry)
1148
+ return;
1149
+ onPrerender?.(tree, cacheKey, entry.segmentDepth);
1150
+ });
1151
+ })
1152
+ .catch(() => { });
970
1153
  }
971
1154
  function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { intent: false, explicitTtl: false }) {
972
1155
  const chain = claimedChain(interceptSlot ?? null);
@@ -974,7 +1157,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
974
1157
  if (existing && existing.expiresAt > Date.now()) {
975
1158
  // Fetched as it came into view, undecoded; the touch says decode it.
976
1159
  if (held.intent)
977
- warm(existing);
1160
+ warm(existing, cacheKey);
978
1161
  return;
979
1162
  }
980
1163
  // A navigation is already fetching it; the answer is on its way.
@@ -1043,8 +1226,13 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1043
1226
  entry.expiresAt = Math.max(entry.expiresAt, Date.now() + STATIC_PREFETCH_TTL);
1044
1227
  }
1045
1228
  // The bytes, not the page: decoding is the navigation's, see CacheEntry
1046
- // - unless the visitor is already on the way, see prefetch().
1047
- return response.text();
1229
+ // - unless the visitor is already on the way, see prefetch(). The
1230
+ // pictures the page shows are asked for now, though: on a phone they
1231
+ // take longer than the touch-to-click a hidden render has.
1232
+ return response.text().then((text) => {
1233
+ preloadImages(text);
1234
+ return text;
1235
+ });
1048
1236
  })
1049
1237
  .catch(() => {
1050
1238
  entry.failed = true;
@@ -1060,7 +1248,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1060
1248
  });
1061
1249
  cache.set(cacheKey, entry);
1062
1250
  if (held.intent)
1063
- warm(entry);
1251
+ warm(entry, cacheKey);
1064
1252
  }
1065
1253
  /**
1066
1254
  * Drop a prefetch that is still in flight — the pointer left the link.