UNPKG

react-router

Version:
590 lines (588 loc) • 21.8 kB
/** * 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 };