UNPKG

react-router

Version:
604 lines (603 loc) • 23 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 { PROTOCOL_RELATIVE_URL_REGEX } from "../router/url.js"; import { createBrowserHistory, invariant } from "../router/history.js"; import { ErrorResponseImpl, createContext, resolvePath } from "../router/utils.js"; import { createRouter, hasInvalidProtocol, isMutationMethod } from "../router/router.js"; import { RSCRouterContext } from "../context.js"; import { RouterProvider } from "../components.js"; import { createRequestInit } from "../dom/ssr/data.js"; import { getSingleFetchDataStrategyImpl, singleFetchUrl, stripIndexParam } from "../dom/ssr/single-fetch.js"; import { noActionDefinedError, shouldHydrateRouteLoader } from "../dom/ssr/routes.js"; import { getPathsWithAncestors } from "../dom/ssr/fog-of-war.js"; import { FrameworkContext, setIsHydrated } from "../dom/ssr/components.js"; import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries.js"; import { populateRSCRouteModules } from "./route-modules.js"; import { getHydrationData } from "../dom/ssr/hydration.js"; import * as React$1 from "react"; import * as ReactDOM from "react-dom"; //#region lib/rsc/browser.tsx const defaultManifestPath = "/__manifest"; /** * Create a React `callServer` implementation for React Router. * * @example * import { * createFromReadableStream, * createTemporaryReferenceSet, * encodeReply, * setServerCallback, * } from "@vitejs/plugin-rsc/browser"; * import { unstable_createCallServer as createCallServer } from "react-router"; * * setServerCallback( * createCallServer({ * createFromReadableStream, * createTemporaryReferenceSet, * encodeReply, * }) * ); * * @name unstable_createCallServer * @public * @category RSC * @mode data * @param opts Options * @param opts.createFromReadableStream Your `react-server-dom-xyz/client`'s * `createFromReadableStream`. Used to decode payloads from the server. * @param opts.createTemporaryReferenceSet A function that creates a temporary * reference set for the [RSC](https://react.dev/reference/rsc/server-components) * payload. * @param opts.encodeReply Your `react-server-dom-xyz/client`'s `encodeReply`. * Used when sending payloads to the server. * @param opts.fetch Optional [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) * implementation. Defaults to global [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch). * @returns A function that can be used to call server actions. */ function createCallServer({ createFromReadableStream, createTemporaryReferenceSet, encodeReply, fetch: fetchImplementation = fetch }) { const globalVar = window; let landedActionId = 0; return async (id, args) => { let actionId = globalVar.__routerActionID = (globalVar.__routerActionID ??= 0) + 1; const temporaryReferences = createTemporaryReferenceSet(); const payloadPromise = fetchImplementation(new Request(location.href, { body: await encodeReply(args, { temporaryReferences }), method: "POST", headers: { Accept: "text/x-component", "rsc-action-id": id } })).then((response) => { if (!response.body) throw new Error("No response body"); return createFromReadableStream(response.body, { temporaryReferences }); }); React$1.startTransition(() => Promise.resolve(payloadPromise).then(async (payload) => { if (payload.type === "redirect") { let location = normalizeRedirectLocation(payload.location); if (payload.reload || isExternalLocation(location)) { if (hasInvalidProtocol(location)) throw new Error("Invalid redirect location"); window.location.href = location; return; } React$1.startTransition(() => { globalVar.__reactRouterDataRouter.navigate(location, { replace: payload.replace }); }); return; } if (payload.type !== "action") throw new Error("Unexpected payload type"); const rerender = await payload.rerender; if (rerender && landedActionId < actionId && globalVar.__routerActionID <= actionId) { if (rerender.type === "redirect") { let location = normalizeRedirectLocation(rerender.location); if (rerender.reload || isExternalLocation(location)) { if (hasInvalidProtocol(location)) throw new Error("Invalid redirect location"); window.location.href = location; return; } React$1.startTransition(() => { globalVar.__reactRouterDataRouter.navigate(location, { replace: rerender.replace }); }); return; } React$1.startTransition(() => { let lastMatch; for (const match of rerender.matches) { globalVar.__reactRouterDataRouter.patchRoutes(lastMatch?.id ?? null, [createRouteFromServerManifest(match)], true); lastMatch = match; } window.__reactRouterDataRouter._internalSetStateDoNotUseOrYouWillBreakYourApp({ loaderData: Object.assign({}, globalVar.__reactRouterDataRouter.state.loaderData, rerender.loaderData), errors: rerender.errors ? Object.assign({}, globalVar.__reactRouterDataRouter.state.errors, rerender.errors) : null }); }); } }).catch(() => {})); return payloadPromise.then((payload) => { if (payload.type !== "action" && payload.type !== "redirect") throw new Error("Unexpected payload type"); return payload.actionResult; }); }; } function createRouterFromPayload({ fetchImplementation, createFromReadableStream, getContext, payload }) { const globalVar = window; if (globalVar.__reactRouterDataRouter && globalVar.__reactRouterRouteModules) return { router: globalVar.__reactRouterDataRouter, routeModules: globalVar.__reactRouterRouteModules }; if (payload.type !== "render") throw new Error("Invalid payload type"); globalVar.__reactRouterRouteModules = globalVar.__reactRouterRouteModules ?? {}; populateRSCRouteModules(globalVar.__reactRouterRouteModules, payload.matches); let routes = payload.matches.reduceRight((previous, match) => { const route = createRouteFromServerManifest(match, payload); if (previous.length > 0) route.children = previous; else if (!route.index) route.children = []; return [route]; }, []); let applyPatchesPromise; globalVar.__reactRouterDataRouter = createRouter({ routes, getContext, basename: payload.basename, history: createBrowserHistory(), hydrationData: getHydrationData({ state: { loaderData: payload.loaderData, actionData: payload.actionData, errors: payload.errors }, routes, getRouteInfo: (routeId) => { let match = payload.matches.find((m) => m.id === routeId); invariant(match, "Route not found in payload"); return { clientLoader: match.clientLoader, hasLoader: match.hasLoader, hasHydrateFallback: match.hydrateFallbackElement != null }; }, location: payload.location, basename: payload.basename, isSpaMode: false }), async patchRoutesOnNavigation({ path, signal }) { if (payload.routeDiscovery.mode === "initial") { if (!applyPatchesPromise) applyPatchesPromise = (async () => { if (!payload.patches) return; let patches = await payload.patches; React$1.startTransition(() => { patches.forEach((p) => { window.__reactRouterDataRouter.patchRoutes(p.parentId ?? null, [createRouteFromServerManifest(p)]); }); }); })(); await applyPatchesPromise; return; } if (discoveredPaths.has(path)) return; await fetchAndApplyManifestPatches([path], createFromReadableStream, fetchImplementation, signal); }, dataStrategy: getRSCSingleFetchDataStrategy(() => globalVar.__reactRouterDataRouter, true, createFromReadableStream, fetchImplementation) }); if (globalVar.__reactRouterDataRouter.state.initialized) { globalVar.__routerInitialized = true; globalVar.__reactRouterDataRouter.initialize(); } else globalVar.__routerInitialized = false; let lastLoaderData = void 0; globalVar.__reactRouterDataRouter.subscribe(({ loaderData, actionData }) => { if (lastLoaderData !== loaderData) globalVar.__routerActionID = (globalVar.__routerActionID ??= 0) + 1; }); globalVar.__reactRouterDataRouter._updateRoutesForHMR = (routeUpdateByRouteId) => { const oldRoutes = window.__reactRouterDataRouter.routes; const newRoutes = []; function walkRoutes(routes, parentId) { return routes.map((route) => { const routeUpdate = routeUpdateByRouteId.get(route.id); if (routeUpdate) { const { routeModule, hasAction, hasComponent, hasLoader } = routeUpdate; const newRoute = createRouteFromServerManifest({ clientAction: routeModule.clientAction, clientLoader: routeModule.clientLoader, element: route.element, errorElement: route.errorElement, handle: route.handle, hasAction, hasComponent, hasLoader, hydrateFallbackElement: route.hydrateFallbackElement, id: route.id, index: route.index, links: routeModule.links, meta: routeModule.meta, parentId, path: route.path, shouldRevalidate: routeModule.shouldRevalidate }); if (route.children) newRoute.children = walkRoutes(route.children, route.id); return newRoute; } const updatedRoute = { ...route }; if (route.children) updatedRoute.children = walkRoutes(route.children, route.id); return updatedRoute; }); } newRoutes.push(...walkRoutes(oldRoutes, void 0)); window.__reactRouterDataRouter._internalSetRoutes(newRoutes); }; return { router: globalVar.__reactRouterDataRouter, routeModules: globalVar.__reactRouterRouteModules }; } const renderedRoutesContext = createContext(); function getRSCSingleFetchDataStrategy(getRouter, ssr, createFromReadableStream, fetchImplementation) { let dataStrategy = getSingleFetchDataStrategyImpl(getRouter, (match) => { let M = match; return { hasLoader: M.route.hasLoader, hasClientLoader: M.route.hasClientLoader, hasComponent: M.route.hasComponent, hasAction: M.route.hasAction, hasClientAction: M.route.hasClientAction }; }, getFetchAndDecodeViaRSC(createFromReadableStream, fetchImplementation), ssr, (match) => { let M = match; return M.route.hasComponent && !M.route.element; }); return async (args) => args.runClientMiddleware(async () => { let context = args.context; context.set(renderedRoutesContext, []); let results = await dataStrategy(args); const renderedRoutesById = /* @__PURE__ */ new Map(); for (const route of context.get(renderedRoutesContext)) { if (!renderedRoutesById.has(route.id)) renderedRoutesById.set(route.id, []); renderedRoutesById.get(route.id).push(route); } React$1.startTransition(() => { for (const match of args.matches) { const renderedRoutes = renderedRoutesById.get(match.route.id); if (renderedRoutes) for (const rendered of renderedRoutes) window.__reactRouterDataRouter.patchRoutes(rendered.parentId ?? null, [createRouteFromServerManifest(rendered)], true); } }); return results; }); } function getFetchAndDecodeViaRSC(createFromReadableStream, fetchImplementation) { return async (args, targetRoutes) => { let { request, context } = args; let url = singleFetchUrl(request.url, "rsc"); if (request.method === "GET") { url = stripIndexParam(url); if (targetRoutes) url.searchParams.set("_routes", targetRoutes.join(",")); } let res = await fetchImplementation(new Request(url, await createRequestInit(request))); if (res.status >= 400 && !res.headers.has("X-Remix-Response")) throw new ErrorResponseImpl(res.status, res.statusText, await res.text()); invariant(res.body, "No response body to decode"); try { const payload = await createFromReadableStream(res.body, { temporaryReferences: void 0 }); if (payload.type === "redirect") return { status: res.status, data: { redirect: { redirect: payload.location, reload: payload.reload, replace: payload.replace, revalidate: false, status: payload.status } } }; if (payload.type !== "render") throw new Error("Unexpected payload type"); context.get(renderedRoutesContext).push(...payload.matches); let results = { routes: {} }; const dataKey = isMutationMethod(request.method) ? "actionData" : "loaderData"; for (let [routeId, data] of Object.entries(payload[dataKey] || {})) results.routes[routeId] = { data }; if (payload.errors) for (let [routeId, error] of Object.entries(payload.errors)) results.routes[routeId] = { error }; return { status: res.status, data: results }; } catch (cause) { throw new Error("Unable to decode RSC response", { cause }); } }; } /** * Hydrates a server rendered {@link unstable_RSCPayload} in the browser. * * @example * import { startTransition, StrictMode } from "react"; * import { hydrateRoot } from "react-dom/client"; * import { * unstable_getRSCStream as getRSCStream, * unstable_RSCHydratedRouter as RSCHydratedRouter, * } from "react-router"; * import type { unstable_RSCPayload as RSCPayload } from "react-router"; * * createFromReadableStream(getRSCStream()).then((payload) => * startTransition(async () => { * hydrateRoot( * document, * <StrictMode> * <RSCHydratedRouter * createFromReadableStream={createFromReadableStream} * payload={payload} * /> * </StrictMode>, * { formState: await getFormState(payload) }, * ); * }), * ); * * @name unstable_RSCHydratedRouter * @public * @category RSC * @mode data * @param props Props * @param {unstable_RSCHydratedRouterProps.createFromReadableStream} props.createFromReadableStream n/a * @param {unstable_RSCHydratedRouterProps.fetch} props.fetch n/a * @param {unstable_RSCHydratedRouterProps.getContext} props.getContext n/a * @param {unstable_RSCHydratedRouterProps.payload} props.payload n/a * @returns A hydrated {@link DataRouter} that can be used to navigate and * render routes. */ function RSCHydratedRouter({ createFromReadableStream, fetch: fetchImplementation = fetch, payload, getContext }) { if (payload.type !== "render") throw new Error("Invalid payload type"); let { routeDiscovery } = payload; let { router, routeModules } = React$1.useMemo(() => createRouterFromPayload({ payload, fetchImplementation, getContext, createFromReadableStream }), [ createFromReadableStream, payload, fetchImplementation, getContext ]); React$1.useEffect(() => { setIsHydrated(); }, []); React$1.useLayoutEffect(() => { const globalVar = window; if (!globalVar.__routerInitialized) { globalVar.__routerInitialized = true; globalVar.__reactRouterDataRouter.initialize(); } }, []); let [{ routes, state }, setState] = React$1.useState(() => ({ routes: cloneRoutes(router.routes), state: router.state })); React$1.useLayoutEffect(() => router.subscribe((newState) => { if (diffRoutes(router.routes, routes)) React$1.startTransition(() => { setState({ routes: cloneRoutes(router.routes), state: newState }); }); }), [ router.subscribe, routes, router ]); const transitionEnabledRouter = React$1.useMemo(() => ({ ...router, state, routes }), [ router, routes, state ]); React$1.useEffect(() => { if (routeDiscovery.mode === "initial" || window.navigator?.connection?.saveData === true) return; function registerElement(el) { let path = el.tagName === "FORM" ? el.getAttribute("action") : el.getAttribute("href"); if (!path) return; let pathname = el.tagName === "A" ? el.pathname : new URL(path, window.location.origin).pathname; if (!discoveredPaths.has(pathname)) nextPaths.add(pathname); } async function fetchPatches() { document.querySelectorAll("a[data-discover], form[data-discover]").forEach(registerElement); let paths = Array.from(nextPaths.keys()).filter((path) => { if (discoveredPaths.has(path)) { nextPaths.delete(path); return false; } return true; }); if (paths.length === 0) return; try { await fetchAndApplyManifestPatches(paths, createFromReadableStream, fetchImplementation); } catch (e) { console.error("Failed to fetch manifest patches", e); } } let debouncedFetchPatches = debounce(fetchPatches, 100); fetchPatches(); new MutationObserver(() => debouncedFetchPatches()).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: [ "data-discover", "href", "action" ] }); }, [ routeDiscovery, createFromReadableStream, fetchImplementation ]); const frameworkContext = { future: {}, isSpaMode: false, ssr: true, criticalCss: "", manifest: { routes: {}, version: "1", url: "", entry: { module: "", imports: [] } }, routeDiscovery: payload.routeDiscovery.mode === "initial" ? { mode: "initial", manifestPath: defaultManifestPath } : { mode: "lazy", manifestPath: payload.routeDiscovery.manifestPath || defaultManifestPath }, routeModules }; return /* @__PURE__ */ React$1.createElement(RSCRouterContext.Provider, { value: true }, /* @__PURE__ */ React$1.createElement(RSCRouterGlobalErrorBoundary, { location: state.location }, /* @__PURE__ */ React$1.createElement(FrameworkContext.Provider, { value: frameworkContext }, /* @__PURE__ */ React$1.createElement(RouterProvider, { router: transitionEnabledRouter, flushSync: ReactDOM.flushSync })))); } function createRouteFromServerManifest(match, payload) { let hasInitialData = payload && match.id in payload.loaderData; let initialData = payload?.loaderData[match.id]; let hasInitialError = payload?.errors && match.id in payload.errors; let initialError = payload?.errors?.[match.id]; let isHydrationRequest = match.clientLoader?.hydrate === true || !match.hasLoader || match.hasComponent && !match.element; invariant(window.__reactRouterRouteModules); populateRSCRouteModules(window.__reactRouterRouteModules, match); let dataRoute = { id: match.id, element: match.element, errorElement: match.errorElement, handle: match.handle, hydrateFallbackElement: match.hydrateFallbackElement, index: match.index, loader: match.clientLoader ? async (args, singleFetch) => { let _isHydrationRequest = isHydrationRequest; isHydrationRequest = false; return await match.clientLoader({ ...args, serverLoader: () => { preventInvalidServerHandlerCall("loader", match.id, match.hasLoader); if (_isHydrationRequest) { if (hasInitialData) return initialData; if (hasInitialError) throw initialError; } return callSingleFetch(singleFetch); } }); } : (_, singleFetch) => callSingleFetch(singleFetch), action: match.clientAction ? (args, singleFetch) => match.clientAction({ ...args, serverAction: async () => { preventInvalidServerHandlerCall("action", match.id, match.hasLoader); return await callSingleFetch(singleFetch); } }) : match.hasAction ? (_, singleFetch) => callSingleFetch(singleFetch) : () => { throw noActionDefinedError("action", match.id); }, path: match.path, shouldRevalidate: match.shouldRevalidate, hasLoader: true, hasClientLoader: match.clientLoader != null, hasAction: match.hasAction, hasClientAction: match.clientAction != null }; if (typeof dataRoute.loader === "function") dataRoute.loader.hydrate = shouldHydrateRouteLoader(match.id, match.clientLoader, match.hasLoader, false); return dataRoute; } function callSingleFetch(singleFetch) { invariant(typeof singleFetch === "function", "Invalid singleFetch parameter"); return singleFetch(); } function preventInvalidServerHandlerCall(type, routeId, hasHandler) { if (!hasHandler) { let msg = `You are trying to call ${type === "action" ? "serverAction()" : "serverLoader()"} on a route that does not have a server ${type} (routeId: "${routeId}")`; console.error(msg); throw new ErrorResponseImpl(400, "Bad Request", new Error(msg), true); } } const nextPaths = /* @__PURE__ */ new Set(); const discoveredPathsMaxSize = 1e3; const discoveredPaths = /* @__PURE__ */ new Set(); function getManifestUrl(paths) { if (paths.length === 0) return null; if (paths.length === 1) return new URL(`${paths[0]}.manifest`, window.location.origin); let basename = (window.__reactRouterDataRouter.basename ?? "").replace(/^\/|\/$/g, ""); let url = new URL(`${basename}/.manifest`, window.location.origin); url.searchParams.set("paths", paths.sort().join(",")); return url; } async function fetchAndApplyManifestPatches(paths, createFromReadableStream, fetchImplementation, signal) { paths = getPathsWithAncestors(paths); let url = getManifestUrl(paths); if (url == null) return; if (url.toString().length > 7680) { nextPaths.clear(); return; } let response = await fetchImplementation(new Request(url, { signal })); if (!response.body || response.status < 200 || response.status >= 300) throw new Error("Unable to fetch new route matches from the server"); let payload = await createFromReadableStream(response.body, { temporaryReferences: void 0 }); if (payload.type !== "manifest") throw new Error("Failed to patch routes"); paths.forEach((p) => addToFifoQueue(p, discoveredPaths)); let patches = await payload.patches; React$1.startTransition(() => { patches.forEach((p) => { window.__reactRouterDataRouter.patchRoutes(p.parentId ?? null, [createRouteFromServerManifest(p)]); }); }); } function addToFifoQueue(path, queue) { if (queue.size >= discoveredPathsMaxSize) { let first = queue.values().next().value; if (typeof first === "string") queue.delete(first); } queue.add(path); } function debounce(callback, wait) { let timeoutId; return (...args) => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(() => callback(...args), wait); }; } function isExternalLocation(location) { return new URL(location, window.location.href).origin !== window.location.origin; } function normalizeRedirectLocation(location) { if (PROTOCOL_RELATIVE_URL_REGEX.test(location)) { let path = resolvePath(location); return path.pathname + path.search + path.hash; } return location; } function cloneRoutes(routes) { if (!routes) return void 0; return routes.map((route) => ({ ...route, children: cloneRoutes(route.children) })); } function diffRoutes(a, b) { if (a.length !== b.length) return true; return a.some((route, index) => { if (route.element !== b[index].element) return true; if (route.errorElement !== b[index].errorElement) return true; if (route.hydrateFallbackElement !== b[index].hydrateFallbackElement) return true; if (route.hasLoader !== b[index].hasLoader) return true; if (route.hasClientLoader !== b[index].hasClientLoader) return true; if (route.hasAction !== b[index].hasAction) return true; if (route.hasClientAction !== b[index].hasClientAction) return true; return diffRoutes(route.children || [], b[index].children || []); }); } //#endregion export { RSCHydratedRouter, createCallServer };