react-router
Version:
Declarative routing for React
590 lines (588 loc) • 21.8 kB
JavaScript
/**
* react-router v8.0.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
import { matchRoutes } from "../../router/utils.js";
import { DataRouterContext, DataRouterStateContext, useIsRSCRouterContext } from "../../context.js";
import { useLocation } from "../../hooks.js";
import { warnOnce } from "../../server-runtime/warnings.js";
import invariant from "./invariant.js";
import { getKeyedLinksForMatches, getKeyedPrefetchLinks, getModuleLinkHrefs, getNewMatchesForLinks, isPageLinkDescriptor } from "./links.js";
import { escapeHtml } from "./markup.js";
import { singleFetchUrl } from "./single-fetch.js";
import { getPartialManifest, isFogOfWarEnabled } from "./fog-of-war.js";
import * as React$1 from "react";
//#region lib/dom/ssr/components.tsx
function useDataRouterContext() {
let context = React$1.useContext(DataRouterContext);
invariant(context, "You must render this element inside a <DataRouterContext.Provider> element");
return context;
}
function useDataRouterStateContext() {
let context = React$1.useContext(DataRouterStateContext);
invariant(context, "You must render this element inside a <DataRouterStateContext.Provider> element");
return context;
}
const FrameworkContext = React$1.createContext(void 0);
FrameworkContext.displayName = "FrameworkContext";
function useFrameworkContext() {
let context = React$1.useContext(FrameworkContext);
invariant(context, "You must render this element inside a <HydratedRouter> element");
return context;
}
function usePrefetchBehavior(prefetch, theirElementProps) {
let frameworkContext = React$1.useContext(FrameworkContext);
let [maybePrefetch, setMaybePrefetch] = React$1.useState(false);
let [shouldPrefetch, setShouldPrefetch] = React$1.useState(false);
let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } = theirElementProps;
let ref = React$1.useRef(null);
React$1.useEffect(() => {
if (prefetch === "render") setShouldPrefetch(true);
if (prefetch === "viewport") {
let callback = (entries) => {
entries.forEach((entry) => {
setShouldPrefetch(entry.isIntersecting);
});
};
let observer = new IntersectionObserver(callback, { threshold: .5 });
if (ref.current) observer.observe(ref.current);
return () => {
observer.disconnect();
};
}
}, [prefetch]);
React$1.useEffect(() => {
if (maybePrefetch) {
let id = setTimeout(() => {
setShouldPrefetch(true);
}, 100);
return () => {
clearTimeout(id);
};
}
}, [maybePrefetch]);
let setIntent = () => {
setMaybePrefetch(true);
};
let cancelIntent = () => {
setMaybePrefetch(false);
setShouldPrefetch(false);
};
if (!frameworkContext) return [
false,
ref,
{}
];
if (prefetch !== "intent") return [
shouldPrefetch,
ref,
{}
];
return [
shouldPrefetch,
ref,
{
onFocus: composeEventHandlers(onFocus, setIntent),
onBlur: composeEventHandlers(onBlur, cancelIntent),
onMouseEnter: composeEventHandlers(onMouseEnter, setIntent),
onMouseLeave: composeEventHandlers(onMouseLeave, cancelIntent),
onTouchStart: composeEventHandlers(onTouchStart, setIntent)
}
];
}
function composeEventHandlers(theirHandler, ourHandler) {
return (event) => {
theirHandler && theirHandler(event);
if (!event.defaultPrevented) ourHandler(event);
};
}
function getActiveMatches(matches, errors, isSpaMode) {
if (isSpaMode && !isHydrated) return [matches[0]];
if (errors) {
let errorIdx = matches.findIndex((m) => errors[m.route.id] !== void 0);
return matches.slice(0, errorIdx + 1);
}
return matches;
}
const CRITICAL_CSS_DATA_ATTRIBUTE = "data-react-router-critical-css";
/**
* Renders all the [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
* tags created by the route module's [`links`](../../start/framework/route-module#links)
* export. You should render it inside the [`<head>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head)
* of your document.
*
* @example
* import { Links } from "react-router";
*
* export default function Root() {
* return (
* <html>
* <head>
* <Links />
* </head>
* <body></body>
* </html>
* );
* }
*
* @public
* @category Components
* @mode framework
* @param props Props
* @param {LinksProps.nonce} props.nonce n/a
* @param {LinksProps.crossOrigin} props.crossOrigin n/a
* @returns A collection of React elements for [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
* tags
*/
function Links({ nonce, crossOrigin }) {
let { isSpaMode, manifest, routeModules, criticalCss, nonce: contextNonce } = useFrameworkContext();
let { errors, matches: routerMatches } = useDataRouterStateContext();
let matches = getActiveMatches(routerMatches, errors, isSpaMode);
let keyedLinks = React$1.useMemo(() => getKeyedLinksForMatches(matches, routeModules, manifest), [
matches,
routeModules,
manifest
]);
if (nonce == null && contextNonce) nonce = contextNonce;
return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, typeof criticalCss === "string" ? /* @__PURE__ */ React$1.createElement("style", {
[CRITICAL_CSS_DATA_ATTRIBUTE]: "",
nonce,
dangerouslySetInnerHTML: { __html: criticalCss }
}) : null, typeof criticalCss === "object" ? /* @__PURE__ */ React$1.createElement("link", {
[CRITICAL_CSS_DATA_ATTRIBUTE]: "",
rel: "stylesheet",
href: criticalCss.href,
nonce,
crossOrigin
}) : null, keyedLinks.map(({ key, link }) => isPageLinkDescriptor(link) ? /* @__PURE__ */ React$1.createElement(PrefetchPageLinks, {
key,
nonce,
...link,
crossOrigin: link.crossOrigin ?? crossOrigin
}) : /* @__PURE__ */ React$1.createElement("link", {
key,
nonce,
...link,
crossOrigin: link.crossOrigin ?? crossOrigin
})));
}
/**
* Renders [`<link rel=prefetch|modulepreload>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/rel)
* tags for modules and data of another page to enable an instant navigation to
* that page. [`<Link prefetch>`](./Link#prefetch) uses this internally, but you
* can render it to prefetch a page for any other reason.
*
* For example, you may render one of this as the user types into a search field
* to prefetch search results before they click through to their selection.
*
* @example
* import { PrefetchPageLinks } from "react-router";
*
* <PrefetchPageLinks page="/absolute/path" />
*
* @public
* @category Components
* @mode framework
* @param props Props
* @param {PageLinkDescriptor.page} props.page n/a
* @param props.linkProps Additional props to spread onto the [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
* tags, such as [`crossOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/crossOrigin),
* [`integrity`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/integrity),
* [`rel`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/rel),
* etc.
* @returns A collection of React elements for [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
* tags
*/
function PrefetchPageLinks({ page, ...linkProps }) {
let rsc = useIsRSCRouterContext();
let { nonce: contextNonce } = useFrameworkContext();
let { router } = useDataRouterContext();
let matches = React$1.useMemo(() => matchRoutes(router.routes, page, router.basename), [
router.routes,
page,
router.basename
]);
if (!matches) return null;
if (linkProps.nonce == null && contextNonce) linkProps = {
...linkProps,
nonce: contextNonce
};
if (rsc) return /* @__PURE__ */ React$1.createElement(RSCPrefetchPageLinksImpl, {
page,
matches,
...linkProps
});
return /* @__PURE__ */ React$1.createElement(PrefetchPageLinksImpl, {
page,
matches,
...linkProps
});
}
function useKeyedPrefetchLinks(matches) {
let { manifest, routeModules } = useFrameworkContext();
let [keyedPrefetchLinks, setKeyedPrefetchLinks] = React$1.useState([]);
React$1.useEffect(() => {
let interrupted = false;
getKeyedPrefetchLinks(matches, manifest, routeModules).then((links) => {
if (!interrupted) setKeyedPrefetchLinks(links);
});
return () => {
interrupted = true;
};
}, [
matches,
manifest,
routeModules
]);
return keyedPrefetchLinks;
}
function RSCPrefetchPageLinksImpl({ page, matches: nextMatches, ...linkProps }) {
let location = useLocation();
let dataHrefs = React$1.useMemo(() => {
if (page === location.pathname + location.search + location.hash) return [];
let url = singleFetchUrl(page, "rsc");
let hasSomeRoutesWithShouldRevalidate = false;
let targetRoutes = [];
for (let match of nextMatches) if (typeof match.route.shouldRevalidate === "function") hasSomeRoutesWithShouldRevalidate = true;
else targetRoutes.push(match.route.id);
if (hasSomeRoutesWithShouldRevalidate && targetRoutes.length > 0) url.searchParams.set("_routes", targetRoutes.join(","));
return [url.pathname + url.search];
}, [
page,
location,
nextMatches
]);
return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, dataHrefs.map((href) => /* @__PURE__ */ React$1.createElement("link", {
key: href,
rel: "prefetch",
as: "fetch",
href,
...linkProps
})));
}
function PrefetchPageLinksImpl({ page, matches: nextMatches, ...linkProps }) {
let location = useLocation();
let { manifest, routeModules } = useFrameworkContext();
let { loaderData, matches } = useDataRouterStateContext();
let newMatchesForData = React$1.useMemo(() => getNewMatchesForLinks(page, nextMatches, matches, manifest, location, "data"), [
page,
nextMatches,
matches,
manifest,
location
]);
let newMatchesForAssets = React$1.useMemo(() => getNewMatchesForLinks(page, nextMatches, matches, manifest, location, "assets"), [
page,
nextMatches,
matches,
manifest,
location
]);
let dataHrefs = React$1.useMemo(() => {
if (page === location.pathname + location.search + location.hash) return [];
let routesParams = /* @__PURE__ */ new Set();
let foundOptOutRoute = false;
nextMatches.forEach((m) => {
let manifestRoute = manifest.routes[m.route.id];
if (!manifestRoute || !manifestRoute.hasLoader) return;
if (!newMatchesForData.some((m2) => m2.route.id === m.route.id) && m.route.id in loaderData && routeModules[m.route.id]?.shouldRevalidate) foundOptOutRoute = true;
else if (manifestRoute.hasClientLoader) foundOptOutRoute = true;
else routesParams.add(m.route.id);
});
if (routesParams.size === 0) return [];
let url = singleFetchUrl(page, "data");
if (foundOptOutRoute && routesParams.size > 0) url.searchParams.set("_routes", nextMatches.filter((m) => routesParams.has(m.route.id)).map((m) => m.route.id).join(","));
return [url.pathname + url.search];
}, [
loaderData,
location,
manifest,
newMatchesForData,
nextMatches,
page,
routeModules
]);
let moduleHrefs = React$1.useMemo(() => getModuleLinkHrefs(newMatchesForAssets, manifest), [newMatchesForAssets, manifest]);
let keyedPrefetchLinks = useKeyedPrefetchLinks(newMatchesForAssets);
return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, dataHrefs.map((href) => /* @__PURE__ */ React$1.createElement("link", {
key: href,
rel: "prefetch",
as: "fetch",
href,
...linkProps
})), moduleHrefs.map((href) => /* @__PURE__ */ React$1.createElement("link", {
key: href,
rel: "modulepreload",
href,
...linkProps
})), keyedPrefetchLinks.map(({ key, link }) => /* @__PURE__ */ React$1.createElement("link", {
key,
nonce: linkProps.nonce,
...link,
crossOrigin: link.crossOrigin ?? linkProps.crossOrigin
})));
}
/**
* Renders all the [`<meta>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta)
* tags created by the route module's [`meta`](../../start/framework/route-module#meta)
* export. You should render it inside the [`<head>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head)
* of your document.
*
* @example
* import { Meta } from "react-router";
*
* export default function Root() {
* return (
* <html>
* <head>
* <Meta />
* </head>
* </html>
* );
* }
*
* @public
* @category Components
* @mode framework
* @returns A collection of React elements for [`<meta>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta)
* tags
*/
function Meta() {
let { isSpaMode, routeModules } = useFrameworkContext();
let { errors, matches: routerMatches, loaderData } = useDataRouterStateContext();
let location = useLocation();
let _matches = getActiveMatches(routerMatches, errors, isSpaMode);
let error = null;
if (errors) error = errors[_matches[_matches.length - 1].route.id];
let meta = [];
let leafMeta = null;
let matches = [];
for (let i = 0; i < _matches.length; i++) {
let _match = _matches[i];
let routeId = _match.route.id;
let data = loaderData[routeId];
let params = _match.params;
let routeModule = routeModules[routeId];
let routeMeta = [];
let match = {
id: routeId,
loaderData: data,
meta: [],
params: _match.params,
pathname: _match.pathname,
handle: _match.route.handle,
error
};
matches[i] = match;
if (routeModule?.meta) routeMeta = typeof routeModule.meta === "function" ? routeModule.meta({
loaderData: data,
params,
location,
matches,
error
}) : Array.isArray(routeModule.meta) ? [...routeModule.meta] : routeModule.meta;
else if (leafMeta) routeMeta = [...leafMeta];
routeMeta = routeMeta || [];
if (!Array.isArray(routeMeta)) throw new Error("The route at " + _match.route.path + " returns an invalid value. All route meta functions must return an array of meta objects.\n\nTo reference the meta function API, see https://reactrouter.com/start/framework/route-module#meta");
match.meta = routeMeta;
matches[i] = match;
meta = [...routeMeta];
leafMeta = meta;
}
return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, meta.flat().map((metaProps) => {
if (!metaProps) return null;
if ("tagName" in metaProps) {
let { tagName, ...rest } = metaProps;
if (!isValidMetaTag(tagName)) {
console.warn(`A meta object uses an invalid tagName: ${tagName}. Expected either 'link' or 'meta'`);
return null;
}
let Comp = tagName;
return /* @__PURE__ */ React$1.createElement(Comp, {
key: JSON.stringify(rest),
...rest
});
}
if ("title" in metaProps) return /* @__PURE__ */ React$1.createElement("title", { key: "title" }, String(metaProps.title));
if ("charset" in metaProps) {
metaProps.charSet ??= metaProps.charset;
delete metaProps.charset;
}
if ("charSet" in metaProps && metaProps.charSet != null) return typeof metaProps.charSet === "string" ? /* @__PURE__ */ React$1.createElement("meta", {
key: "charSet",
charSet: metaProps.charSet
}) : null;
if ("script:ld+json" in metaProps) try {
let json = JSON.stringify(metaProps["script:ld+json"]);
return /* @__PURE__ */ React$1.createElement("script", {
key: `script:ld+json:${json}`,
type: "application/ld+json",
dangerouslySetInnerHTML: { __html: escapeHtml(json) }
});
} catch (e) {
return null;
}
return /* @__PURE__ */ React$1.createElement("meta", {
key: JSON.stringify(metaProps),
...metaProps
});
}));
}
function isValidMetaTag(tagName) {
return typeof tagName === "string" && /^(meta|link)$/.test(tagName);
}
/**
* Tracks whether hydration is finished, so scripts can be skipped
* during client-side updates.
*/
let isHydrated = false;
function setIsHydrated() {
isHydrated = true;
}
/**
* Renders the client runtime of your app. It should be rendered inside the
* [`<body>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/body)
* of the document.
*
* If server rendering, you can omit `<Scripts/>` and the app will work as a
* traditional web app without JavaScript, relying solely on HTML and browser
* behaviors.
*
* @example
* import { Scripts } from "react-router";
*
* export default function Root() {
* return (
* <html>
* <head />
* <body>
* <Scripts />
* </body>
* </html>
* );
* }
*
* @public
* @category Components
* @mode framework
* @param scriptProps Additional props to spread onto the [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script)
* tags, such as [`crossOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement/crossOrigin),
* [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce),
* etc.
* @returns A collection of React elements for [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script)
* tags
*/
function Scripts(scriptProps) {
let { manifest, serverHandoffString, isSpaMode, renderMeta, routeDiscovery, ssr, nonce: contextNonce } = useFrameworkContext();
let { router, static: isStatic, staticContext } = useDataRouterContext();
let { matches: routerMatches } = useDataRouterStateContext();
let isRSCRouterContext = useIsRSCRouterContext();
let enableFogOfWar = isFogOfWarEnabled(routeDiscovery, ssr);
if (scriptProps.nonce == null && contextNonce) scriptProps = {
...scriptProps,
nonce: contextNonce
};
if (renderMeta) renderMeta.didRenderScripts = true;
let matches = getActiveMatches(routerMatches, null, isSpaMode);
React$1.useEffect(() => {
setIsHydrated();
}, []);
let initialScripts = React$1.useMemo(() => {
if (isRSCRouterContext) return null;
let contextScript = staticContext ? `window.__reactRouterContext = ${serverHandoffString};window.__reactRouterContext.stream = new ReadableStream({start(controller){window.__reactRouterContext.streamController = controller;}}).pipeThrough(new TextEncoderStream());` : " ";
let routeModulesScript = !isStatic ? " " : `${manifest.hmr?.runtime ? `import ${JSON.stringify(manifest.hmr.runtime)};` : ""}${!enableFogOfWar ? `import ${JSON.stringify(manifest.url)}` : ""};
${matches.map((match, routeIndex) => {
let routeVarName = `route${routeIndex}`;
let manifestEntry = manifest.routes[match.route.id];
invariant(manifestEntry, `Route ${match.route.id} not found in manifest`);
let { clientActionModule, clientLoaderModule, clientMiddlewareModule, hydrateFallbackModule, module } = manifestEntry;
let chunks = [
...clientActionModule ? [{
module: clientActionModule,
varName: `${routeVarName}_clientAction`
}] : [],
...clientLoaderModule ? [{
module: clientLoaderModule,
varName: `${routeVarName}_clientLoader`
}] : [],
...clientMiddlewareModule ? [{
module: clientMiddlewareModule,
varName: `${routeVarName}_clientMiddleware`
}] : [],
...hydrateFallbackModule ? [{
module: hydrateFallbackModule,
varName: `${routeVarName}_HydrateFallback`
}] : [],
{
module,
varName: `${routeVarName}_main`
}
];
if (chunks.length === 1) return `import * as ${routeVarName} from ${JSON.stringify(module)};`;
return [chunks.map((chunk) => `import * as ${chunk.varName} from "${chunk.module}";`).join("\n"), `const ${routeVarName} = {${chunks.map((chunk) => `...${chunk.varName}`).join(",")}};`].join("\n");
}).join("\n")}
${enableFogOfWar ? `window.__reactRouterManifest = ${JSON.stringify(getPartialManifest(manifest, router), null, 2)};` : ""}
window.__reactRouterRouteModules = {${matches.map((match, index) => `${JSON.stringify(match.route.id)}:route${index}`).join(",")}};
import(${JSON.stringify(manifest.entry.module)});`;
return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, /* @__PURE__ */ React$1.createElement("script", {
...scriptProps,
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: contextScript },
type: void 0
}), /* @__PURE__ */ React$1.createElement("script", {
...scriptProps,
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: routeModulesScript },
type: "module",
async: true
}));
}, []);
let preloads = isHydrated || isRSCRouterContext ? [] : [...new Set(manifest.entry.imports.concat(getModuleLinkHrefs(matches, manifest, { includeHydrateFallback: true })))];
let sri = typeof manifest.sri === "object" ? manifest.sri : {};
warnOnce(!isRSCRouterContext, "The <Scripts /> element is a no-op when using RSC and can be safely removed.");
return isHydrated || isRSCRouterContext ? null : /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, typeof manifest.sri === "object" ? /* @__PURE__ */ React$1.createElement("script", {
...scriptProps,
"rr-importmap": "",
type: "importmap",
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: JSON.stringify({ integrity: sri }) }
}) : null, !enableFogOfWar ? /* @__PURE__ */ React$1.createElement("link", {
rel: "modulepreload",
href: manifest.url,
crossOrigin: scriptProps.crossOrigin,
integrity: sri[manifest.url],
nonce: scriptProps.nonce,
suppressHydrationWarning: true
}) : null, /* @__PURE__ */ React$1.createElement("link", {
rel: "modulepreload",
href: manifest.entry.module,
crossOrigin: scriptProps.crossOrigin,
integrity: sri[manifest.entry.module],
nonce: scriptProps.nonce,
suppressHydrationWarning: true
}), preloads.map((path) => /* @__PURE__ */ React$1.createElement("link", {
key: path,
rel: "modulepreload",
href: path,
crossOrigin: scriptProps.crossOrigin,
integrity: sri[path],
nonce: scriptProps.nonce,
suppressHydrationWarning: true
})), initialScripts);
}
function mergeRefs(...refs) {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") ref(value);
else if (ref != null) ref.current = value;
});
};
}
//#endregion
export { CRITICAL_CSS_DATA_ATTRIBUTE, FrameworkContext, Links, Meta, PrefetchPageLinks, Scripts, mergeRefs, setIsHydrated, useFrameworkContext, usePrefetchBehavior };