@rsc-kit/core 0.20.6 → 0.20.7
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.
- package/dist/js/ActivityRouter.js +5 -0
- package/dist/js/ActivityRouter.js.map +1 -1
- package/dist/js/Link.d.ts +1 -1
- package/dist/js/Link.js +25 -9
- package/dist/js/Link.js.map +1 -1
- package/dist/js/PageTransition.d.ts +27 -0
- package/dist/js/PageTransition.js +57 -0
- package/dist/js/PageTransition.js.map +1 -0
- package/dist/js/SegmentBoundary.js +8 -0
- package/dist/js/SegmentBoundary.js.map +1 -1
- package/dist/js/navigate.d.ts +14 -1
- package/dist/js/navigate.js +56 -5
- package/dist/js/navigate.js.map +1 -1
- package/dist/js/perf.d.ts +25 -0
- package/dist/js/perf.js +57 -0
- package/dist/js/perf.js.map +1 -0
- package/dist/js/router.d.ts +10 -1
- package/dist/js/router.js +9 -2
- package/dist/js/router.js.map +1 -1
- package/dist/js/viewportPrefetch.d.ts +19 -10
- package/dist/js/viewportPrefetch.js +23 -13
- package/dist/js/viewportPrefetch.js.map +1 -1
- package/dist/vite.js +13 -0
- package/dist/vite.js.map +1 -1
- package/package.json +5 -1
|
@@ -15,6 +15,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
|
15
15
|
*/
|
|
16
16
|
import { Activity, useCallback, useEffect, useRef, useState } from 'react';
|
|
17
17
|
import { setNavigateHandler, setRestoreHandler } from './navigate';
|
|
18
|
+
import { navigationCommitted } from './perf';
|
|
18
19
|
import { clearSegments, setSegment } from './segmentStore';
|
|
19
20
|
/** Pages kept alive behind the visible one. Four covers ordinary back-and-forth. */
|
|
20
21
|
export const DEFAULT_RETENTION = 4;
|
|
@@ -69,6 +70,10 @@ export function useActivityRouter(initial, retention = DEFAULT_RETENTION) {
|
|
|
69
70
|
*/
|
|
70
71
|
export function ActivityRoot({ initialKey, initialTree }) {
|
|
71
72
|
const { entries, activeKey, handle } = useActivityRouter({ key: initialKey, tree: initialTree });
|
|
73
|
+
// A whole document shown, or a held page revealed: the navigation's commit.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
navigationCommitted();
|
|
76
|
+
}, [activeKey, entries]);
|
|
72
77
|
useEffect(() => {
|
|
73
78
|
setNavigateHandler((tree, key, segmentDepth) => {
|
|
74
79
|
// A partial payload replaces one segment inside the page that is already
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ActivityRouter.js","sourceRoot":"","sources":["../../src/js/ActivityRouter.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * Keeps recently visited pages mounted but hidden, so going back restores them\n * rather than rebuilding them.\n *\n * Rendering each navigation with root.render() unmounts the previous tree, and\n * unmounting destroys client state: a half-typed form, an open disclosure, a\n * scrolled list. React's <Activity mode=\"hidden\"> hides a tree without\n * unmounting it — effects are torn down, but state survives — which is what\n * makes returning feel like the browser's own back button rather than a reload.\n *\n * Retention is bounded. Hidden trees keep their DOM, so an unbounded history\n * would grow the document for the length of the session; the least recently\n * visited entry is dropped past the limit.\n */\n\nimport { Activity, useCallback, useEffect, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { setNavigateHandler, setRestoreHandler } from './navigate'\nimport { clearSegments, setSegment } from './segmentStore'\n\nexport interface RouterEntry {\n /** Identifies the page: its URL, or the intercept variant of one. */\n key: string\n tree: ReactNode\n}\n\nexport interface ActivityRouterHandle {\n /** Show `tree` for `key`, retaining whatever was visible before. */\n show(key: string, tree: ReactNode): void\n /** Reveal a retained page without refetching. False when not retained. */\n restore(key: string): boolean\n /** Drop every retained page but the visible one. */\n clear(): void\n}\n\n/** Pages kept alive behind the visible one. Four covers ordinary back-and-forth. */\nexport const DEFAULT_RETENTION = 4\n\nexport function useActivityRouter(\n initial: RouterEntry,\n retention: number = DEFAULT_RETENTION,\n): { entries: RouterEntry[]; activeKey: string; handle: ActivityRouterHandle } {\n const [entries, setEntries] = useState<RouterEntry[]>([initial])\n const [activeKey, setActiveKey] = useState(initial.key)\n\n // The router calls into this from outside React, where the values captured by\n // the last render may already be stale. Refs mirror state so those calls read\n // what is actually mounted.\n const entriesRef = useRef(entries)\n const orderRef = useRef<string[]>([initial.key])\n\n const commit = useCallback((next: RouterEntry[]) => {\n entriesRef.current = next\n setEntries(next)\n }, [])\n\n // Recency, most recent last. Retention is by visit order rather than\n // insertion, so bouncing between two pages evicts neither.\n const touch = useCallback(\n (key: string) => {\n const next = orderRef.current.filter((k) => k !== key)\n next.push(key)\n orderRef.current = next.slice(-Math.max(1, retention))\n\n return orderRef.current\n },\n [retention],\n )\n\n const show = useCallback(\n (key: string, tree: ReactNode) => {\n const kept = touch(key)\n const others = entriesRef.current.filter((entry) => entry.key !== key && kept.includes(entry.key))\n\n commit([...others, { key, tree }])\n setActiveKey(key)\n },\n [commit, touch],\n )\n\n const restore = useCallback(\n (key: string) => {\n if (!entriesRef.current.some((entry) => entry.key === key)) return false\n\n touch(key)\n setActiveKey(key)\n\n return true\n },\n [touch],\n )\n\n const clear = useCallback(() => {\n const kept = entriesRef.current.filter((entry) => entry.key === activeKey)\n\n commit(kept)\n orderRef.current = [activeKey]\n }, [activeKey, commit])\n\n const handleRef = useRef<ActivityRouterHandle>({ show, restore, clear })\n handleRef.current = { show, restore, clear }\n\n return { entries, activeKey, handle: handleRef.current }\n}\n\n/**\n * Root of the hydrated app.\n *\n * Registers itself with the navigation engine so a navigation becomes a state\n * update here rather than a fresh root.render(), which is what allows the\n * previous page to stay mounted.\n */\nexport function ActivityRoot({ initialKey, initialTree }: { initialKey: string; initialTree: ReactNode }) {\n const { entries, activeKey, handle } = useActivityRouter({ key: initialKey, tree: initialTree })\n\n useEffect(() => {\n setNavigateHandler((tree, key, segmentDepth) => {\n // A partial payload replaces one segment inside the page that is already\n // showing; only a whole document is a new page to retain.\n if (segmentDepth > 0) {\n setSegment(segmentDepth, key, tree as ReactNode)\n\n return\n }\n\n clearSegments()\n handle.show(key, tree as ReactNode)\n })\n\n setRestoreHandler((key) => handle.restore(key))\n }, [handle])\n\n return (\n <>\n {entries.map((entry) => (\n <Activity key={entry.key} mode={entry.key === activeKey ? 'visible' : 'hidden'}>\n {entry.tree}\n </Activity>\n ))}\n </>\n )\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ActivityRouter.js","sourceRoot":"","sources":["../../src/js/ActivityRouter.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * Keeps recently visited pages mounted but hidden, so going back restores them\n * rather than rebuilding them.\n *\n * Rendering each navigation with root.render() unmounts the previous tree, and\n * unmounting destroys client state: a half-typed form, an open disclosure, a\n * scrolled list. React's <Activity mode=\"hidden\"> hides a tree without\n * unmounting it — effects are torn down, but state survives — which is what\n * makes returning feel like the browser's own back button rather than a reload.\n *\n * Retention is bounded. Hidden trees keep their DOM, so an unbounded history\n * would grow the document for the length of the session; the least recently\n * visited entry is dropped past the limit.\n */\n\nimport { Activity, useCallback, useEffect, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { setNavigateHandler, setRestoreHandler } from './navigate'\nimport { navigationCommitted } from './perf'\nimport { clearSegments, setSegment } from './segmentStore'\n\nexport interface RouterEntry {\n /** Identifies the page: its URL, or the intercept variant of one. */\n key: string\n tree: ReactNode\n}\n\nexport interface ActivityRouterHandle {\n /** Show `tree` for `key`, retaining whatever was visible before. */\n show(key: string, tree: ReactNode): void\n /** Reveal a retained page without refetching. False when not retained. */\n restore(key: string): boolean\n /** Drop every retained page but the visible one. */\n clear(): void\n}\n\n/** Pages kept alive behind the visible one. Four covers ordinary back-and-forth. */\nexport const DEFAULT_RETENTION = 4\n\nexport function useActivityRouter(\n initial: RouterEntry,\n retention: number = DEFAULT_RETENTION,\n): { entries: RouterEntry[]; activeKey: string; handle: ActivityRouterHandle } {\n const [entries, setEntries] = useState<RouterEntry[]>([initial])\n const [activeKey, setActiveKey] = useState(initial.key)\n\n // The router calls into this from outside React, where the values captured by\n // the last render may already be stale. Refs mirror state so those calls read\n // what is actually mounted.\n const entriesRef = useRef(entries)\n const orderRef = useRef<string[]>([initial.key])\n\n const commit = useCallback((next: RouterEntry[]) => {\n entriesRef.current = next\n setEntries(next)\n }, [])\n\n // Recency, most recent last. Retention is by visit order rather than\n // insertion, so bouncing between two pages evicts neither.\n const touch = useCallback(\n (key: string) => {\n const next = orderRef.current.filter((k) => k !== key)\n next.push(key)\n orderRef.current = next.slice(-Math.max(1, retention))\n\n return orderRef.current\n },\n [retention],\n )\n\n const show = useCallback(\n (key: string, tree: ReactNode) => {\n const kept = touch(key)\n const others = entriesRef.current.filter((entry) => entry.key !== key && kept.includes(entry.key))\n\n commit([...others, { key, tree }])\n setActiveKey(key)\n },\n [commit, touch],\n )\n\n const restore = useCallback(\n (key: string) => {\n if (!entriesRef.current.some((entry) => entry.key === key)) return false\n\n touch(key)\n setActiveKey(key)\n\n return true\n },\n [touch],\n )\n\n const clear = useCallback(() => {\n const kept = entriesRef.current.filter((entry) => entry.key === activeKey)\n\n commit(kept)\n orderRef.current = [activeKey]\n }, [activeKey, commit])\n\n const handleRef = useRef<ActivityRouterHandle>({ show, restore, clear })\n handleRef.current = { show, restore, clear }\n\n return { entries, activeKey, handle: handleRef.current }\n}\n\n/**\n * Root of the hydrated app.\n *\n * Registers itself with the navigation engine so a navigation becomes a state\n * update here rather than a fresh root.render(), which is what allows the\n * previous page to stay mounted.\n */\nexport function ActivityRoot({ initialKey, initialTree }: { initialKey: string; initialTree: ReactNode }) {\n const { entries, activeKey, handle } = useActivityRouter({ key: initialKey, tree: initialTree })\n\n // A whole document shown, or a held page revealed: the navigation's commit.\n useEffect(() => {\n navigationCommitted()\n }, [activeKey, entries])\n\n useEffect(() => {\n setNavigateHandler((tree, key, segmentDepth) => {\n // A partial payload replaces one segment inside the page that is already\n // showing; only a whole document is a new page to retain.\n if (segmentDepth > 0) {\n setSegment(segmentDepth, key, tree as ReactNode)\n\n return\n }\n\n clearSegments()\n handle.show(key, tree as ReactNode)\n })\n\n setRestoreHandler((key) => handle.restore(key))\n }, [handle])\n\n return (\n <>\n {entries.map((entry) => (\n <Activity key={entry.key} mode={entry.key === activeKey ? 'visible' : 'hidden'}>\n {entry.tree}\n </Activity>\n ))}\n </>\n )\n}\n"]}
|
package/dist/js/Link.d.ts
CHANGED
|
@@ -28,5 +28,5 @@ interface LinkBaseProps<H extends Route> extends Omit<AnchorHTMLAttributes<HTMLA
|
|
|
28
28
|
* here. With no schema, any scalars. See SearchFor in routes.ts.
|
|
29
29
|
*/
|
|
30
30
|
type LinkProps<H extends Route> = LinkBaseProps<H> & SearchProp<H>;
|
|
31
|
-
export default function Link<H extends Route>({ href: path, search, prefetch: prefetchProp, cacheFor, replace, preserveScroll, children, onClick, onMouseEnter, onMouseLeave, ref: callerRef, ...rest }: LinkProps<H>): import("react").JSX.Element;
|
|
31
|
+
export default function Link<H extends Route>({ href: path, search, prefetch: prefetchProp, cacheFor, replace, preserveScroll, children, onClick, onMouseEnter, onMouseLeave, onMouseDown, ref: callerRef, ...rest }: LinkProps<H>): import("react").JSX.Element;
|
|
32
32
|
export {};
|
package/dist/js/Link.js
CHANGED
|
@@ -30,7 +30,7 @@ function shouldInterceptClick(e) {
|
|
|
30
30
|
* hover this short would not have had its payload back anyway.
|
|
31
31
|
*/
|
|
32
32
|
const HOVER_PREFETCH_DELAY_MS = 100;
|
|
33
|
-
export default function Link({ href: path, search, prefetch: prefetchProp = "hover", cacheFor, replace = false, preserveScroll = false, children, onClick, onMouseEnter, onMouseLeave, ref: callerRef, ...rest }) {
|
|
33
|
+
export default function Link({ href: path, search, prefetch: prefetchProp = "hover", cacheFor, replace = false, preserveScroll = false, children, onClick, onMouseEnter, onMouseLeave, onMouseDown, ref: callerRef, ...rest }) {
|
|
34
34
|
// The string the anchor and the router both use: the path, with the typed
|
|
35
35
|
// search params serialised onto it.
|
|
36
36
|
const href = (search ? withSearch(path, search) : path);
|
|
@@ -41,11 +41,14 @@ export default function Link({ href: path, search, prefetch: prefetchProp = "hov
|
|
|
41
41
|
: prefetchProp === false
|
|
42
42
|
? "none"
|
|
43
43
|
: prefetchProp;
|
|
44
|
-
|
|
44
|
+
// `intent` is a hover that settled or a touch: the payload is decoded as
|
|
45
|
+
// it lands, so the chunks the page names are loading before the click. A
|
|
46
|
+
// link that only came into view fetches the bytes and stops there.
|
|
47
|
+
const doPrefetch = useCallback((intent = false) => {
|
|
45
48
|
if (isExternalUrl(href))
|
|
46
49
|
return;
|
|
47
50
|
const fn = window.__rsc_prefetch;
|
|
48
|
-
fn?.(href, cacheFor);
|
|
51
|
+
fn?.(href, cacheFor, intent);
|
|
49
52
|
}, [href, cacheFor]);
|
|
50
53
|
// Only useEffect needed: prefetch on mount strategy
|
|
51
54
|
useEffect(() => {
|
|
@@ -53,13 +56,13 @@ export default function Link({ href: path, search, prefetch: prefetchProp = "hov
|
|
|
53
56
|
doPrefetch();
|
|
54
57
|
}
|
|
55
58
|
}, [prefetchStrategy, doPrefetch]);
|
|
56
|
-
//
|
|
57
|
-
//
|
|
59
|
+
// The link prefetches as it comes into view - the bytes, so a click finds
|
|
60
|
+
// them however quick it is. See viewportPrefetch.
|
|
58
61
|
const anchor = useRef(null);
|
|
59
62
|
useEffect(() => {
|
|
60
63
|
if (prefetchStrategy !== "hover")
|
|
61
64
|
return;
|
|
62
|
-
return prefetchWhenVisible(anchor.current, doPrefetch);
|
|
65
|
+
return prefetchWhenVisible(anchor.current, () => doPrefetch());
|
|
63
66
|
}, [prefetchStrategy, doPrefetch]);
|
|
64
67
|
const handleClick = useCallback((e) => {
|
|
65
68
|
onClick?.(e);
|
|
@@ -96,7 +99,7 @@ export default function Link({ href: path, search, prefetch: prefetchProp = "hov
|
|
|
96
99
|
clearTimeout(hoverTimer.current);
|
|
97
100
|
hoverTimer.current = setTimeout(() => {
|
|
98
101
|
hoverTimer.current = null;
|
|
99
|
-
doPrefetch();
|
|
102
|
+
doPrefetch(true);
|
|
100
103
|
}, HOVER_PREFETCH_DELAY_MS);
|
|
101
104
|
}, [prefetchStrategy, doPrefetch, onMouseEnter]);
|
|
102
105
|
const handleMouseLeave = useCallback((e) => {
|
|
@@ -115,11 +118,24 @@ export default function Link({ href: path, search, prefetch: prefetchProp = "hov
|
|
|
115
118
|
// Touch gets no delay. There is no hovering to disambiguate — a touch is
|
|
116
119
|
// already the start of a tap — and touchstart leads the click by little
|
|
117
120
|
// enough that spending any of it waiting would waste the head start.
|
|
121
|
+
//
|
|
122
|
+
// And it decodes: the viewport prefetch that came before it, if one did,
|
|
123
|
+
// fetched the bytes and left the chunks for the tap. This is the tap.
|
|
118
124
|
const handleTouchStart = useCallback(() => {
|
|
119
125
|
if (prefetchStrategy === "hover" || prefetchStrategy === "click") {
|
|
120
|
-
doPrefetch();
|
|
126
|
+
doPrefetch(true);
|
|
121
127
|
}
|
|
122
128
|
}, [prefetchStrategy, doPrefetch]);
|
|
129
|
+
// A press is a click 60-100 ms from landing, and a quick one comes before
|
|
130
|
+
// the hover has settled. Whatever the hover did not, this does; whatever
|
|
131
|
+
// it did, this finds done.
|
|
132
|
+
const handleMouseDown = useCallback((e) => {
|
|
133
|
+
onMouseDown?.(e);
|
|
134
|
+
if (e.button !== 0)
|
|
135
|
+
return;
|
|
136
|
+
if (prefetchStrategy === "hover" || prefetchStrategy === "click")
|
|
137
|
+
doPrefetch(true);
|
|
138
|
+
}, [prefetchStrategy, doPrefetch, onMouseDown]);
|
|
123
139
|
// A link unmounted mid-hover (navigating away) must not prefetch afterwards.
|
|
124
140
|
useEffect(() => () => {
|
|
125
141
|
if (hoverTimer.current !== null)
|
|
@@ -134,6 +150,6 @@ export default function Link({ href: path, search, prefetch: prefetchProp = "hov
|
|
|
134
150
|
callerRef(node);
|
|
135
151
|
else if (callerRef)
|
|
136
152
|
callerRef.current = node;
|
|
137
|
-
}, href: href, "data-rsc": "", onClick: handleClick, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onTouchStart: handleTouchStart, "data-pending": pending ? "" : undefined, ...rest, children: children }) }));
|
|
153
|
+
}, href: href, "data-rsc": "", onClick: handleClick, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onTouchStart: handleTouchStart, onMouseDown: handleMouseDown, "data-pending": pending ? "" : undefined, ...rest, children: children }) }));
|
|
138
154
|
}
|
|
139
155
|
//# sourceMappingURL=Link.js.map
|
package/dist/js/Link.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Link.js","sourceRoot":"","sources":["../../src/js/Link.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { LinkStatusContext } from \"./useLinkStatus\";\nimport { prefetchWhenVisible } from \"./viewportPrefetch\";\nimport { type Route, type SearchProp, withSearch } from \"../routes.js\";\nimport {\n type AnchorHTMLAttributes,\n type MouseEvent,\n type Ref,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from \"react\";\n\ntype PrefetchStrategy = \"hover\" | \"mount\" | \"click\" | \"none\" | boolean;\n\ninterface LinkBaseProps<H extends Route> extends Omit<\n AnchorHTMLAttributes<HTMLAnchorElement>,\n \"href\"\n> {\n /**\n * Where this goes. Typed to the routes the build found, so a link to a page\n * that does not exist stops compiling; `path as Route` when it is computed.\n */\n href: H;\n /**\n * The underlying anchor.\n *\n * Spread onto it like any other prop — React 19 passes ref through a\n * function component without forwardRef — but declared here because it is\n * not part of AnchorHTMLAttributes. A link that cannot be focused is a link\n * a dialog cannot move focus to when it opens.\n */\n ref?: Ref<HTMLAnchorElement>;\n prefetch?: PrefetchStrategy;\n cacheFor?: number;\n replace?: boolean;\n preserveScroll?: boolean;\n}\n\n/**\n * `search` is typed to the page's own `searchParams` schema when it exports\n * one — the same schema the page parses with, so a key it never reads or a\n * number written as text does not compile, and a key it requires is required\n * here. With no schema, any scalars. See SearchFor in routes.ts.\n */\ntype LinkProps<H extends Route> = LinkBaseProps<H> & SearchProp<H>;\n\nfunction isExternalUrl(url: string): boolean {\n try {\n return (\n new URL(url, window.location.origin).origin !== window.location.origin\n );\n } catch {\n return false;\n }\n}\n\nfunction shouldInterceptClick(e: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !e.defaultPrevented &&\n e.button === 0 &&\n !e.metaKey &&\n !e.ctrlKey &&\n !e.shiftKey &&\n !e.altKey\n );\n}\n\n/**\n * How long a pointer has to settle on a link before it prefetches.\n *\n * A pointer crossing a nav bar enters every link on the way, and each one used\n * to fire a request immediately — enough to fill the browser's per-origin\n * connection limit with pages the user never meant to visit. Waiting is close\n * to free: the prefetch only has to beat the click, and a click that follows a\n * hover this short would not have had its payload back anyway.\n */\nconst HOVER_PREFETCH_DELAY_MS = 100;\n\nexport default function Link<H extends Route>({\n href: path,\n search,\n prefetch: prefetchProp = \"hover\",\n cacheFor,\n replace = false,\n preserveScroll = false,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n ref: callerRef,\n ...rest\n}: LinkProps<H>) {\n // The string the anchor and the router both use: the path, with the typed\n // search params serialised onto it.\n const href = (search ? withSearch(path, search as object) : path) as Route;\n const [pending, setPending] = useState(false);\n const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const prefetchStrategy =\n prefetchProp === true\n ? \"hover\"\n : prefetchProp === false\n ? \"none\"\n : prefetchProp;\n\n const doPrefetch = useCallback(() => {\n if (isExternalUrl(href)) return;\n const fn = (window as any).__rsc_prefetch;\n fn?.(href, cacheFor);\n }, [href, cacheFor]);\n\n // Only useEffect needed: prefetch on mount strategy\n useEffect(() => {\n if (prefetchStrategy === \"mount\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // Where nothing can hover, the link prefetches as it comes into view -\n // the head start a touch gives is a round trip short. See viewportPrefetch.\n const anchor = useRef<HTMLAnchorElement | null>(null);\n\n useEffect(() => {\n if (prefetchStrategy !== \"hover\") return;\n\n return prefetchWhenVisible(anchor.current, doPrefetch);\n }, [prefetchStrategy, doPrefetch]);\n\n const handleClick = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onClick?.(e);\n\n if (e.defaultPrevented) return;\n\n const target = (e.currentTarget as HTMLAnchorElement).target;\n if (target && target !== \"_self\") return;\n if (!shouldInterceptClick(e) || isExternalUrl(href)) return;\n\n // Hash-only links (#section) — let the browser scroll natively\n if (href.startsWith(\"#\")) return;\n\n e.preventDefault();\n setPending(true);\n\n // A hover that had not yet prefetched: the click is the navigation, and\n // a prefetch of the page being left for, firing after it, is a request\n // for nothing - seen as a stray 204 for a guarded link after landing\n // on the login page.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // navigate() returns a Promise — clear pending when it resolves or rejects\n const nav = (window as any).__rsc_navigate;\n const promise = nav?.(href, { replace, preserveScroll });\n promise?.then(\n () => setPending(false),\n () => setPending(false),\n );\n },\n [href, replace, preserveScroll, onClick],\n );\n\n const handleMouseEnter = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseEnter?.(e);\n\n if (prefetchStrategy !== \"hover\" && prefetchStrategy !== \"click\") return;\n\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n\n hoverTimer.current = setTimeout(() => {\n hoverTimer.current = null;\n doPrefetch();\n }, HOVER_PREFETCH_DELAY_MS);\n },\n [prefetchStrategy, doPrefetch, onMouseEnter],\n );\n\n const handleMouseLeave = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseLeave?.(e);\n\n // Never fired: the pointer passed over on its way somewhere else.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // Already in flight: give the connection back to whatever the pointer is\n // heading for. A prefetch that has landed is kept — see cancelPrefetch.\n if (isExternalUrl(href)) return;\n (window as any).__rsc_cancel_prefetch?.(href);\n },\n [href, onMouseLeave],\n );\n\n // Touch gets no delay. There is no hovering to disambiguate — a touch is\n // already the start of a tap — and touchstart leads the click by little\n // enough that spending any of it waiting would waste the head start.\n const handleTouchStart = useCallback(() => {\n if (prefetchStrategy === \"hover\" || prefetchStrategy === \"click\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // A link unmounted mid-hover (navigating away) must not prefetch afterwards.\n useEffect(\n () => () => {\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n },\n [],\n );\n\n return (\n <LinkStatusContext.Provider value={{ pending }}>\n <a\n // The caller's ref still fills: a dialog moving focus to its link\n // needs the element as much as the observer above does.\n ref={(node) => {\n anchor.current = node;\n\n if (typeof callerRef === \"function\") callerRef(node);\n else if (callerRef) (callerRef as { current: HTMLAnchorElement | null }).current = node;\n }}\n href={href}\n // The mark the bootstrap script's click listener looks for: a tap\n // on this before the router exists is held for it. See earlyClicks.\n data-rsc=\"\"\n onClick={handleClick}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onTouchStart={handleTouchStart}\n data-pending={pending ? \"\" : undefined}\n {...rest}\n >\n {children}\n </a>\n </LinkStatusContext.Provider>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"Link.js","sourceRoot":"","sources":["../../src/js/Link.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { LinkStatusContext } from \"./useLinkStatus\";\nimport { prefetchWhenVisible } from \"./viewportPrefetch\";\nimport { type Route, type SearchProp, withSearch } from \"../routes.js\";\nimport {\n type AnchorHTMLAttributes,\n type MouseEvent,\n type Ref,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from \"react\";\n\ntype PrefetchStrategy = \"hover\" | \"mount\" | \"click\" | \"none\" | boolean;\n\ninterface LinkBaseProps<H extends Route> extends Omit<\n AnchorHTMLAttributes<HTMLAnchorElement>,\n \"href\"\n> {\n /**\n * Where this goes. Typed to the routes the build found, so a link to a page\n * that does not exist stops compiling; `path as Route` when it is computed.\n */\n href: H;\n /**\n * The underlying anchor.\n *\n * Spread onto it like any other prop — React 19 passes ref through a\n * function component without forwardRef — but declared here because it is\n * not part of AnchorHTMLAttributes. A link that cannot be focused is a link\n * a dialog cannot move focus to when it opens.\n */\n ref?: Ref<HTMLAnchorElement>;\n prefetch?: PrefetchStrategy;\n cacheFor?: number;\n replace?: boolean;\n preserveScroll?: boolean;\n}\n\n/**\n * `search` is typed to the page's own `searchParams` schema when it exports\n * one — the same schema the page parses with, so a key it never reads or a\n * number written as text does not compile, and a key it requires is required\n * here. With no schema, any scalars. See SearchFor in routes.ts.\n */\ntype LinkProps<H extends Route> = LinkBaseProps<H> & SearchProp<H>;\n\nfunction isExternalUrl(url: string): boolean {\n try {\n return (\n new URL(url, window.location.origin).origin !== window.location.origin\n );\n } catch {\n return false;\n }\n}\n\nfunction shouldInterceptClick(e: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !e.defaultPrevented &&\n e.button === 0 &&\n !e.metaKey &&\n !e.ctrlKey &&\n !e.shiftKey &&\n !e.altKey\n );\n}\n\n/**\n * How long a pointer has to settle on a link before it prefetches.\n *\n * A pointer crossing a nav bar enters every link on the way, and each one used\n * to fire a request immediately — enough to fill the browser's per-origin\n * connection limit with pages the user never meant to visit. Waiting is close\n * to free: the prefetch only has to beat the click, and a click that follows a\n * hover this short would not have had its payload back anyway.\n */\nconst HOVER_PREFETCH_DELAY_MS = 100;\n\nexport default function Link<H extends Route>({\n href: path,\n search,\n prefetch: prefetchProp = \"hover\",\n cacheFor,\n replace = false,\n preserveScroll = false,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n onMouseDown,\n ref: callerRef,\n ...rest\n}: LinkProps<H>) {\n // The string the anchor and the router both use: the path, with the typed\n // search params serialised onto it.\n const href = (search ? withSearch(path, search as object) : path) as Route;\n const [pending, setPending] = useState(false);\n const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const prefetchStrategy =\n prefetchProp === true\n ? \"hover\"\n : prefetchProp === false\n ? \"none\"\n : prefetchProp;\n\n // `intent` is a hover that settled or a touch: the payload is decoded as\n // it lands, so the chunks the page names are loading before the click. A\n // link that only came into view fetches the bytes and stops there.\n const doPrefetch = useCallback(\n (intent = false) => {\n if (isExternalUrl(href)) return;\n const fn = (window as any).__rsc_prefetch;\n fn?.(href, cacheFor, intent);\n },\n [href, cacheFor],\n );\n\n // Only useEffect needed: prefetch on mount strategy\n useEffect(() => {\n if (prefetchStrategy === \"mount\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // The link prefetches as it comes into view - the bytes, so a click finds\n // them however quick it is. See viewportPrefetch.\n const anchor = useRef<HTMLAnchorElement | null>(null);\n\n useEffect(() => {\n if (prefetchStrategy !== \"hover\") return;\n\n return prefetchWhenVisible(anchor.current, () => doPrefetch());\n }, [prefetchStrategy, doPrefetch]);\n\n const handleClick = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onClick?.(e);\n\n if (e.defaultPrevented) return;\n\n const target = (e.currentTarget as HTMLAnchorElement).target;\n if (target && target !== \"_self\") return;\n if (!shouldInterceptClick(e) || isExternalUrl(href)) return;\n\n // Hash-only links (#section) — let the browser scroll natively\n if (href.startsWith(\"#\")) return;\n\n e.preventDefault();\n setPending(true);\n\n // A hover that had not yet prefetched: the click is the navigation, and\n // a prefetch of the page being left for, firing after it, is a request\n // for nothing - seen as a stray 204 for a guarded link after landing\n // on the login page.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // navigate() returns a Promise — clear pending when it resolves or rejects\n const nav = (window as any).__rsc_navigate;\n const promise = nav?.(href, { replace, preserveScroll });\n promise?.then(\n () => setPending(false),\n () => setPending(false),\n );\n },\n [href, replace, preserveScroll, onClick],\n );\n\n const handleMouseEnter = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseEnter?.(e);\n\n if (prefetchStrategy !== \"hover\" && prefetchStrategy !== \"click\") return;\n\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n\n hoverTimer.current = setTimeout(() => {\n hoverTimer.current = null;\n doPrefetch(true);\n }, HOVER_PREFETCH_DELAY_MS);\n },\n [prefetchStrategy, doPrefetch, onMouseEnter],\n );\n\n const handleMouseLeave = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseLeave?.(e);\n\n // Never fired: the pointer passed over on its way somewhere else.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // Already in flight: give the connection back to whatever the pointer is\n // heading for. A prefetch that has landed is kept — see cancelPrefetch.\n if (isExternalUrl(href)) return;\n (window as any).__rsc_cancel_prefetch?.(href);\n },\n [href, onMouseLeave],\n );\n\n // Touch gets no delay. There is no hovering to disambiguate — a touch is\n // already the start of a tap — and touchstart leads the click by little\n // enough that spending any of it waiting would waste the head start.\n //\n // And it decodes: the viewport prefetch that came before it, if one did,\n // fetched the bytes and left the chunks for the tap. This is the tap.\n const handleTouchStart = useCallback(() => {\n if (prefetchStrategy === \"hover\" || prefetchStrategy === \"click\") {\n doPrefetch(true);\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // A press is a click 60-100 ms from landing, and a quick one comes before\n // the hover has settled. Whatever the hover did not, this does; whatever\n // it did, this finds done.\n const handleMouseDown = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseDown?.(e);\n\n if (e.button !== 0) return;\n if (prefetchStrategy === \"hover\" || prefetchStrategy === \"click\") doPrefetch(true);\n },\n [prefetchStrategy, doPrefetch, onMouseDown],\n );\n\n // A link unmounted mid-hover (navigating away) must not prefetch afterwards.\n useEffect(\n () => () => {\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n },\n [],\n );\n\n return (\n <LinkStatusContext.Provider value={{ pending }}>\n <a\n // The caller's ref still fills: a dialog moving focus to its link\n // needs the element as much as the observer above does.\n ref={(node) => {\n anchor.current = node;\n\n if (typeof callerRef === \"function\") callerRef(node);\n else if (callerRef) (callerRef as { current: HTMLAnchorElement | null }).current = node;\n }}\n href={href}\n // The mark the bootstrap script's click listener looks for: a tap\n // on this before the router exists is held for it. See earlyClicks.\n data-rsc=\"\"\n onClick={handleClick}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onTouchStart={handleTouchStart}\n onMouseDown={handleMouseDown}\n data-pending={pending ? \"\" : undefined}\n {...rest}\n >\n {children}\n </a>\n </LinkStatusContext.Provider>\n );\n}\n"]}
|
|
@@ -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"]}
|
|
@@ -44,6 +44,7 @@ const addTransitionType = React.addTransitionType ??
|
|
|
44
44
|
React
|
|
45
45
|
.unstable_addTransitionType;
|
|
46
46
|
import { RedirectBoundary } from "./RedirectBoundary";
|
|
47
|
+
import { navigationCommitted } from "./perf";
|
|
47
48
|
import { getSegmentState, 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
|
|
@@ -60,6 +61,13 @@ 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.
|
|
65
73
|
useEffect(() => {
|
|
@@ -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":"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","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 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 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"]}
|
package/dist/js/navigate.d.ts
CHANGED
|
@@ -107,7 +107,20 @@ export declare function applyRevalidated(target: string, tree: ReactNode): void;
|
|
|
107
107
|
* count living in a layout will not move until you ask for 'all'.
|
|
108
108
|
*/
|
|
109
109
|
export declare function refresh(target?: string): Promise<void>;
|
|
110
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Fetch a page's payload ahead of the navigation that may ask for it.
|
|
112
|
+
*
|
|
113
|
+
* `intent` is the difference between a link that came into view and one the
|
|
114
|
+
* visitor is about to follow: a hover that settled, a touch. Only then is the
|
|
115
|
+
* payload decoded on arrival - decoding is what loads the client chunks the
|
|
116
|
+
* page names, and a viewport prefetch that decoded put the sign-in page's
|
|
117
|
+
* thirty chunks onto a landing page for every phone visitor. A touch leads
|
|
118
|
+
* its click by 100-300 ms, about a round trip, and a tap that found the
|
|
119
|
+
* bytes here but not the chunks spent that round trip after the click
|
|
120
|
+
* instead of before it: the tap-to-paint that was "not zero" beside Next,
|
|
121
|
+
* whose prefetch decodes as it lands.
|
|
122
|
+
*/
|
|
123
|
+
export declare function prefetch(url: string, cacheForMs?: number, intent?: boolean): void;
|
|
111
124
|
/**
|
|
112
125
|
* Drop a prefetch that is still in flight — the pointer left the link.
|
|
113
126
|
*
|
package/dist/js/navigate.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { isStaleAssetError, loadDocumentOnce } from "./staleAssets";
|
|
9
9
|
import { isUpdated, markStale } from "./updateStore";
|
|
10
|
+
import { navigationAbandoned, navigationCommitted, navigationReached, navigationStarted } from "./perf";
|
|
10
11
|
import { isSafeRedirect } from "../safeUrl.js";
|
|
11
12
|
import { reportReachable } from "./onlineStore";
|
|
12
13
|
import { clearSlots, setSlot } from "./slotStore";
|
|
@@ -61,6 +62,16 @@ let interceptedAtDepth = null;
|
|
|
61
62
|
*/
|
|
62
63
|
let interceptedOver = null;
|
|
63
64
|
const DEFAULT_PREFETCH_TTL = 30_000;
|
|
65
|
+
/**
|
|
66
|
+
* How long a payload the host marked `public` is kept.
|
|
67
|
+
*
|
|
68
|
+
* The same build-time bytes for everyone, so the only way it changes is a
|
|
69
|
+
* deploy, and the version handshake catches that on the tap. Thirty seconds
|
|
70
|
+
* was Next's figure for a dynamic page; its static pages are held five
|
|
71
|
+
* minutes, and a landing page read for a minute before the tap on Sign in
|
|
72
|
+
* was, at thirty, an expired entry and a round trip on a phone.
|
|
73
|
+
*/
|
|
74
|
+
const STATIC_PREFETCH_TTL = 300_000;
|
|
64
75
|
/**
|
|
65
76
|
* How many prefetched payloads are kept. Entries carry a TTL but were only
|
|
66
77
|
* dropped when read again, so a long session hovering its way through a big
|
|
@@ -504,6 +515,7 @@ export async function navigate(url, opts) {
|
|
|
504
515
|
}
|
|
505
516
|
// Abort any in-flight navigation
|
|
506
517
|
activeController?.abort();
|
|
518
|
+
navigationStarted(url);
|
|
507
519
|
// Not window.stop(). It used to be called here while the document was
|
|
508
520
|
// still loading, to free a single-threaded server for the new request -
|
|
509
521
|
// and it cancels every load the page has in flight: the client chunks a
|
|
@@ -546,6 +558,7 @@ export async function navigate(url, opts) {
|
|
|
546
558
|
history.pushState({ rscUrl: url }, "", url);
|
|
547
559
|
}
|
|
548
560
|
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
561
|
+
navigationCommitted();
|
|
549
562
|
return;
|
|
550
563
|
}
|
|
551
564
|
// Not when the target is the page already showing: asking for this page
|
|
@@ -580,6 +593,7 @@ export async function navigate(url, opts) {
|
|
|
580
593
|
history.pushState({ rscUrl: url }, "", url);
|
|
581
594
|
}
|
|
582
595
|
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
596
|
+
navigationCommitted();
|
|
583
597
|
return;
|
|
584
598
|
}
|
|
585
599
|
// A prefetched payload was rendered against the chain held at prefetch time.
|
|
@@ -673,6 +687,7 @@ export async function navigate(url, opts) {
|
|
|
673
687
|
let tree;
|
|
674
688
|
try {
|
|
675
689
|
tree = await treePromise;
|
|
690
|
+
navigationReached("decoded");
|
|
676
691
|
}
|
|
677
692
|
catch (error) {
|
|
678
693
|
// A navigation another one overtook: its request was aborted, and
|
|
@@ -729,6 +744,7 @@ export async function navigate(url, opts) {
|
|
|
729
744
|
clearSlots();
|
|
730
745
|
interceptedOver = null;
|
|
731
746
|
interceptedAtDepth = interceptSlot ? segmentDepth : null;
|
|
747
|
+
navigationReached("applied");
|
|
732
748
|
onNavigate?.(tree, activityKey, segmentDepth);
|
|
733
749
|
if (!opts?.preserveScroll && !interceptSlot) {
|
|
734
750
|
// Wait for React to commit the DOM update before scrolling.
|
|
@@ -740,6 +756,7 @@ export async function navigate(url, opts) {
|
|
|
740
756
|
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
741
757
|
}
|
|
742
758
|
catch (err) {
|
|
759
|
+
navigationAbandoned();
|
|
743
760
|
if (err instanceof DOMException && err.name === "AbortError")
|
|
744
761
|
return;
|
|
745
762
|
// A chunk the deploy no longer serves: the browser would have loaded the
|
|
@@ -903,7 +920,20 @@ function restoreScroll(positions) {
|
|
|
903
920
|
};
|
|
904
921
|
apply(1);
|
|
905
922
|
}
|
|
906
|
-
|
|
923
|
+
/**
|
|
924
|
+
* Fetch a page's payload ahead of the navigation that may ask for it.
|
|
925
|
+
*
|
|
926
|
+
* `intent` is the difference between a link that came into view and one the
|
|
927
|
+
* visitor is about to follow: a hover that settled, a touch. Only then is the
|
|
928
|
+
* payload decoded on arrival - decoding is what loads the client chunks the
|
|
929
|
+
* page names, and a viewport prefetch that decoded put the sign-in page's
|
|
930
|
+
* thirty chunks onto a landing page for every phone visitor. A touch leads
|
|
931
|
+
* its click by 100-300 ms, about a round trip, and a tap that found the
|
|
932
|
+
* bytes here but not the chunks spent that round trip after the click
|
|
933
|
+
* instead of before it: the tap-to-paint that was "not zero" beside Next,
|
|
934
|
+
* whose prefetch decodes as it lands.
|
|
935
|
+
*/
|
|
936
|
+
export function prefetch(url, cacheForMs, intent = false) {
|
|
907
937
|
// Never a route.ts: fetching one runs it, and a hover is not a click.
|
|
908
938
|
if (isApiRoute(url))
|
|
909
939
|
return;
|
|
@@ -920,20 +950,31 @@ export function prefetch(url, cacheForMs) {
|
|
|
920
950
|
return;
|
|
921
951
|
const ttl = cacheForMs ?? DEFAULT_PREFETCH_TTL;
|
|
922
952
|
const interceptSlot = matchIntercept(url);
|
|
953
|
+
const held = { intent, explicitTtl: cacheForMs !== undefined };
|
|
923
954
|
if (interceptSlot) {
|
|
924
955
|
// Intercepted route — only prefetch the intercepted variant
|
|
925
956
|
const currentUrl = window.location.pathname + window.location.search;
|
|
926
957
|
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
927
|
-
prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl);
|
|
958
|
+
prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl, held);
|
|
928
959
|
}
|
|
929
960
|
else {
|
|
930
|
-
prefetchUrl(url, url, ttl);
|
|
961
|
+
prefetchUrl(url, url, ttl, undefined, undefined, held);
|
|
931
962
|
}
|
|
932
963
|
}
|
|
933
|
-
|
|
964
|
+
/** Decode a held payload now, so the chunks it names are loading before the tap. */
|
|
965
|
+
function warm(entry) {
|
|
966
|
+
// The tree is read by the navigation that takes the entry, and a decode
|
|
967
|
+
// that fails - a chunk the deploy no longer serves - fails there, where it
|
|
968
|
+
// is handled. Here the rejection has nobody to reach.
|
|
969
|
+
entry.tree.catch(() => { });
|
|
970
|
+
}
|
|
971
|
+
function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { intent: false, explicitTtl: false }) {
|
|
934
972
|
const chain = claimedChain(interceptSlot ?? null);
|
|
935
973
|
const existing = cache.get(cacheKey);
|
|
936
974
|
if (existing && existing.expiresAt > Date.now()) {
|
|
975
|
+
// Fetched as it came into view, undecoded; the touch says decode it.
|
|
976
|
+
if (held.intent)
|
|
977
|
+
warm(existing);
|
|
937
978
|
return;
|
|
938
979
|
}
|
|
939
980
|
// A navigation is already fetching it; the answer is on its way.
|
|
@@ -994,7 +1035,15 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
|
|
|
994
1035
|
else if (local) {
|
|
995
1036
|
entry.layouts = local.chain;
|
|
996
1037
|
}
|
|
997
|
-
//
|
|
1038
|
+
// Marked public: the build's bytes, the same for everyone, and held
|
|
1039
|
+
// for as long as Next holds a static page - unless the link said how
|
|
1040
|
+
// long. A deploy is caught by the version handshake, not by the clock.
|
|
1041
|
+
if (!held.explicitTtl &&
|
|
1042
|
+
/\bpublic\b/.test(response.headers.get("Cache-Control") ?? "")) {
|
|
1043
|
+
entry.expiresAt = Math.max(entry.expiresAt, Date.now() + STATIC_PREFETCH_TTL);
|
|
1044
|
+
}
|
|
1045
|
+
// The bytes, not the page: decoding is the navigation's, see CacheEntry
|
|
1046
|
+
// - unless the visitor is already on the way, see prefetch().
|
|
998
1047
|
return response.text();
|
|
999
1048
|
})
|
|
1000
1049
|
.catch(() => {
|
|
@@ -1010,6 +1059,8 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
|
|
|
1010
1059
|
}
|
|
1011
1060
|
});
|
|
1012
1061
|
cache.set(cacheKey, entry);
|
|
1062
|
+
if (held.intent)
|
|
1063
|
+
warm(entry);
|
|
1013
1064
|
}
|
|
1014
1065
|
/**
|
|
1015
1066
|
* Drop a prefetch that is still in flight — the pointer left the link.
|