diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index a557edd343e..dc4b578e565 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1,33 +1,42 @@ import type { FocusEventHandler, - FormHTMLAttributes, MouseEventHandler, TouchEventHandler, } from "react"; import * as React from "react"; import type { - NavigationType as Action, - Location, + AgnosticDataRouteMatch, + AgnosticDataRouteObject, +} from "@remix-run/router"; +import type { + LinkProps, + NavigationType, Navigator, Params, + NavLinkProps, + Location, + FormProps, + SubmitFunction, } from "react-router-dom"; import { - Router, Link as RouterLink, NavLink as RouterNavLink, - createPath, + UNSAFE_DataRouterContext as DataRouterContext, + UNSAFE_DataRouterStateContext as DataRouterStateContext, + isRouteErrorResponse, + matchRoutes, + useFetcher as useFetcherRR, + useActionData as useActionDataRR, + useLoaderData as useLoaderDataRR, useLocation, - useRoutes, - useNavigate, + useNavigation, useHref, - useResolvedPath, + useRouteError, } from "react-router-dom"; -import type { LinkProps, NavLinkProps } from "react-router-dom"; import type { SerializeFrom } from "@remix-run/server-runtime"; -import type { AppData, FormEncType, FormMethod } from "./data"; -import type { AssetsManifest, EntryContext, FutureConfig } from "./entry"; -import type { AppState, SerializedError } from "./errors"; +import type { AppData } from "./data"; +import type { EntryContext, RemixContextObject } from "./entry"; import { RemixRootDefaultErrorBoundary, RemixErrorBoundary, @@ -45,356 +54,130 @@ import { } from "./links"; import type { HtmlLinkDescriptor, PrefetchPageDescriptor } from "./links"; import { createHtml } from "./markup"; -import type { ClientRoute } from "./routes"; -import { createClientRoutes } from "./routes"; import type { RouteData } from "./routeData"; -import type { RouteMatch as BaseRouteMatch } from "./routeMatching"; -import { matchClientRoutes } from "./routeMatching"; import type { - RouteModules, RouteMatchWithMeta, V1_HtmlMetaDescriptor, V2_HtmlMetaDescriptor, } from "./routeModules"; -import { createTransitionManager } from "./transition"; -import type { - Transition, - TransitionManagerState, - Fetcher, - Submission, -} from "./transition"; +import type { Transition, Fetcher } from "./transition"; +import { IDLE_TRANSITION, IDLE_FETCHER } from "./transition"; -//////////////////////////////////////////////////////////////////////////////// -// RemixEntry +function useDataRouterContext() { + let context = React.useContext(DataRouterContext); + invariant( + context, + "You must render this element inside a element" + ); + return context; +} -interface RemixEntryContextType { - manifest: AssetsManifest; - matches: BaseRouteMatch[]; - routeData: RouteData; - actionData?: RouteData; - pendingLocation?: Location; - appState: AppState; - routeModules: RouteModules; - serverHandoffString?: string; - clientRoutes: ClientRoute[]; - transitionManager: ReturnType; - future: FutureConfig; +function useDataRouterStateContext() { + let context = React.useContext(DataRouterStateContext); + invariant( + context, + "You must render this element inside a element" + ); + return context; } -export const RemixEntryContext = React.createContext< - RemixEntryContextType | undefined ->(undefined); +//////////////////////////////////////////////////////////////////////////////// +// RemixContext -function useRemixEntryContext(): RemixEntryContextType { - let context = React.useContext(RemixEntryContext); +export const RemixContext = React.createContext( + undefined +); +RemixContext.displayName = "Remix"; + +function useRemixContext(): RemixContextObject { + let context = React.useContext(RemixContext); invariant(context, "You must render this element inside a element"); return context; } -export function RemixEntry({ - context: entryContext, - action, - location: historyLocation, - navigator: _navigator, - static: staticProp = false, -}: { +//////////////////////////////////////////////////////////////////////////////// +// RemixEntry + +export function RemixEntry(props: { context: EntryContext; - action: Action; + action: NavigationType; location: Location; navigator: Navigator; static?: boolean; }) { - let { - manifest, - routeData: documentLoaderData, - actionData: documentActionData, - routeModules, - serverHandoffString, - appState: entryComponentDidCatchEmulator, - } = entryContext; - - let clientRoutes = React.useMemo( - () => createClientRoutes(manifest.routes, routeModules, RemixRoute), - [manifest, routeModules] - ); - - let [clientState, setClientState] = React.useState( - entryComponentDidCatchEmulator - ); - - let [transitionManager] = React.useState(() => { - return createTransitionManager({ - routes: clientRoutes, - actionData: documentActionData, - loaderData: documentLoaderData, - location: historyLocation, - catch: entryComponentDidCatchEmulator.catch, - catchBoundaryId: entryComponentDidCatchEmulator.catchBoundaryRouteId, - onRedirect: _navigator.replace, - }); - }); - - React.useEffect(() => { - let subscriber = (state: TransitionManagerState) => { - setClientState({ - catch: state.catch, - error: state.error, - catchBoundaryRouteId: state.catchBoundaryId, - loaderBoundaryRouteId: state.errorBoundaryId, - renderBoundaryRouteId: null, - trackBoundaries: false, - trackCatchBoundaries: false, - }); - }; - - return transitionManager.subscribe(subscriber); - }, [transitionManager]); - - // Ensures pushes interrupting pending navigations use replace - // TODO: Move this to React Router - let navigator: Navigator = React.useMemo(() => { - let push: Navigator["push"] = (to, state) => { - return transitionManager.getState().transition.state !== "idle" - ? _navigator.replace(to, state) - : _navigator.push(to, state); - }; - return { ..._navigator, push }; - }, [_navigator, transitionManager]); - - let { location, matches, loaderData, actionData } = - transitionManager.getState(); - - // Send new location to the transition manager - React.useEffect(() => { - let { location } = transitionManager.getState(); - if (historyLocation === location) return; - transitionManager.send({ - type: "navigation", - location: historyLocation, - submission: consumeNextNavigationSubmission(), - action, - }); - }, [transitionManager, historyLocation, action]); - - // If we tried to render and failed, and the app threw before rendering any - // routes, get the error and pass it to the ErrorBoundary to emulate - // `componentDidCatch` - let ssrErrorBeforeRoutesRendered = - clientState.error && - clientState.renderBoundaryRouteId === null && - clientState.loaderBoundaryRouteId === null - ? deserializeError(clientState.error) - : undefined; - - let ssrCatchBeforeRoutesRendered = - clientState.catch && clientState.catchBoundaryRouteId === null - ? clientState.catch - : undefined; - - return ( - - - - - - - - - - ); -} - -function deserializeError(data: SerializedError): Error { - let error = new Error(data.message); - error.stack = data.stack; - return error; -} - -function Routes() { - // TODO: Add `renderMatches` function to RR that we can use and then we don't - // need this component, we can just `renderMatches` from RemixEntry - let { clientRoutes } = useRemixEntryContext(); - // fallback to the root if we don't have a match - - // TODO: clientRoutes currently errors here since RR 6.4 dropped `signal` as a - // loader argument. But since we're just using we aren't using any - // loaders in RR so this isn't an issue. We'll get these typings straightened - // out as part of the rendering work. - // @ts-expect-error - let element = useRoutes(clientRoutes) || (clientRoutes[0].element as any); - return element; + return

Not Implemented!

; } //////////////////////////////////////////////////////////////////////////////// // RemixRoute -interface RemixRouteContextType { - data: AppData; - id: string; -} +export function RemixRoute({ id }: { id: string }) { + let { routeModules } = useRemixContext(); -const RemixRouteContext = React.createContext< - RemixRouteContextType | undefined ->(undefined); + invariant( + routeModules, + "Cannot initialize 'routeModules'. This normally occurs when you have server code in your client modules.\n" + + "Check this link for more details:\nhttps://remix.run/pages/gotchas#server-code-in-client-bundles" + ); -function useRemixRouteContext(): RemixRouteContextType { - let context = React.useContext(RemixRouteContext); - invariant(context, "You must render this element in a remix route element"); - return context; -} + let { default: Component } = routeModules[id]; -function DefaultRouteComponent({ id }: { id: string }): React.ReactElement { - throw new Error( + invariant( + Component, `Route "${id}" has no component! Please go add a \`default\` export in the route module file.\n` + "If you were trying to navigate or submit to a resource route, use `` instead of `` or `
`." ); + + return ; } -export function RemixRoute({ id }: { id: string }) { - let location = useLocation(); - let { routeData, routeModules, appState } = useRemixEntryContext(); +export function RemixRouteError({ id }: { id: string }) { + let { routeModules } = useRemixContext(); // This checks prevent cryptic error messages such as: 'Cannot read properties of undefined (reading 'root')' - invariant( - routeData, - "Cannot initialize 'routeData'. This normally occurs when you have server code in your client modules.\n" + - "Check this link for more details:\nhttps://remix.run/pages/gotchas#server-code-in-client-bundles" - ); invariant( routeModules, "Cannot initialize 'routeModules'. This normally occurs when you have server code in your client modules.\n" + "Check this link for more details:\nhttps://remix.run/pages/gotchas#server-code-in-client-bundles" ); - let data = routeData[id]; - let { default: Component, CatchBoundary, ErrorBoundary } = routeModules[id]; - let element = Component ? : ; - - let context: RemixRouteContextType = { data, id }; - - if (CatchBoundary) { - // If we tried to render and failed, and this route threw the error, find it - // and pass it to the ErrorBoundary to emulate `componentDidCatch` - let maybeServerCaught = - appState.catch && appState.catchBoundaryRouteId === id - ? appState.catch - : undefined; - - // This needs to run after we check for the error from a previous render, - // otherwise we will incorrectly render this boundary for a loader error - // deeper in the tree. - if (appState.trackCatchBoundaries) { - appState.catchBoundaryRouteId = id; - } - - context = maybeServerCaught - ? { - id, - get data() { - console.error("You cannot `useLoaderData` in a catch boundary."); - return undefined; - }, - } - : { id, data }; + let error = useRouteError(); + let location = useLocation(); + let { CatchBoundary, ErrorBoundary } = routeModules[id]; - element = ( - - {element} - - ); + // Provide defaults for the root route if they are not present + if (id === "root") { + CatchBoundary ||= RemixRootDefaultCatchBoundary; + ErrorBoundary ||= RemixRootDefaultErrorBoundary; } - // Only wrap in error boundary if the route defined one, otherwise let the - // error bubble to the parent boundary. We could default to using error - // boundaries around every route, but now if the app doesn't want users - // seeing the default Remix ErrorBoundary component, they *must* define an - // error boundary for *every* route and that would be annoying. Might as - // well make it required at that point. - // - // By conditionally wrapping like this, we allow apps to define a top level - // ErrorBoundary component and be done with it. Then, if they want to, they - // can add more specific boundaries by exporting ErrorBoundary components - // for whichever routes they please. - // - // NOTE: this kind of logic will move into React Router - - if (ErrorBoundary) { - // If we tried to render and failed, and this route threw the error, find it - // and pass it to the ErrorBoundary to emulate `componentDidCatch` - let maybeServerRenderError = - appState.error && - (appState.renderBoundaryRouteId === id || - appState.loaderBoundaryRouteId === id) - ? deserializeError(appState.error) - : undefined; - - // This needs to run after we check for the error from a previous render, - // otherwise we will incorrectly render this boundary for a loader error - // deeper in the tree. - if (appState.trackBoundaries) { - appState.renderBoundaryRouteId = id; + if (isRouteErrorResponse(error)) { + if (error?.error && error.status !== 404) { + return ( + // TODO: Handle error type? + + ); } + return ; + } - context = maybeServerRenderError - ? { - id, - get data() { - console.error("You cannot `useLoaderData` in an error boundary."); - return undefined; - }, - } - : { id, data }; - - element = ( + if (!isRouteErrorResponse(error) && ErrorBoundary) { + return ( + // TODO: Handle error type? - {element} - + error={error} + /> ); } - // It's important for the route context to be above the error boundary so that - // a call to `useLoaderData` doesn't accidentally get the parents route's data. - return ( - - {element} - - ); + throw error; } - //////////////////////////////////////////////////////////////////////////////// // Public API @@ -550,7 +333,8 @@ export function composeEventHandlers< * @see https://remix.run/api/remix#meta-links-scripts */ export function Links() { - let { matches, routeModules, manifest } = useRemixEntryContext(); + let { manifest, routeModules } = useRemixContext(); + let { matches } = useDataRouterStateContext(); let links = React.useMemo( () => getLinksForMatches(matches, routeModules, manifest), @@ -616,10 +400,10 @@ export function PrefetchPageLinks({ page, ...dataLinkProps }: PrefetchPageDescriptor) { - let { clientRoutes } = useRemixEntryContext(); + let { router } = useDataRouterContext(); let matches = React.useMemo( - () => matchClientRoutes(clientRoutes, page), - [clientRoutes, page] + () => matchRoutes(router.routes, page), + [router.routes, page] ); if (!matches) { @@ -632,22 +416,24 @@ export function PrefetchPageLinks({ ); } -function usePrefetchedStylesheets(matches: BaseRouteMatch[]) { - let { routeModules } = useRemixEntryContext(); +function usePrefetchedStylesheets(matches: AgnosticDataRouteMatch[]) { + let { manifest, routeModules } = useRemixContext(); let [styleLinks, setStyleLinks] = React.useState([]); React.useEffect(() => { let interrupted: boolean = false; - getStylesheetPrefetchLinks(matches, routeModules).then((links) => { - if (!interrupted) setStyleLinks(links); - }); + getStylesheetPrefetchLinks(matches, manifest, routeModules).then( + (links) => { + if (!interrupted) setStyleLinks(links); + } + ); return () => { interrupted = true; }; - }, [matches, routeModules]); + }, [matches, manifest, routeModules]); return styleLinks; } @@ -657,18 +443,35 @@ function PrefetchPageLinksImpl({ matches: nextMatches, ...linkProps }: PrefetchPageDescriptor & { - matches: BaseRouteMatch[]; + matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { matches, manifest } = useRemixEntryContext(); + let { manifest } = useRemixContext(); + let { matches } = useDataRouterStateContext(); let newMatchesForData = React.useMemo( - () => getNewMatchesForLinks(page, nextMatches, matches, location, "data"), + () => + getNewMatchesForLinks( + page, + nextMatches, + matches, + manifest, + location, + "data" + ), [page, nextMatches, matches, location] ); let newMatchesForAssets = React.useMemo( - () => getNewMatchesForLinks(page, nextMatches, matches, location, "assets"), + () => + getNewMatchesForLinks( + page, + nextMatches, + matches, + manifest, + location, + "assets" + ), [page, nextMatches, matches, location] ); @@ -709,7 +512,8 @@ function PrefetchPageLinksImpl({ * @see https://remix.run/api/remix#meta-links-scripts */ function V1Meta() { - let { matches, routeData, routeModules } = useRemixEntryContext(); + let { routeModules } = useRemixContext(); + let { matches, loaderData } = useDataRouterStateContext(); let location = useLocation(); let meta: V1_HtmlMetaDescriptor = {}; @@ -717,7 +521,7 @@ function V1Meta() { for (let match of matches) { let routeId = match.route.id; - let data = routeData[routeId]; + let data = loaderData[routeId]; let params = match.params; let routeModule = routeModules[routeId]; @@ -807,21 +611,21 @@ function V1Meta() { } function V2Meta() { - let { matches, routeData, routeModules } = useRemixEntryContext(); + let { routeModules } = useRemixContext(); + let { matches, loaderData } = useDataRouterStateContext(); let location = useLocation(); let meta: V2_HtmlMetaDescriptor[] = []; let parentsData: { [routeId: string]: AppData } = {}; - let matchesWithMeta: RouteMatchWithMeta[] = matches.map( - (match) => ({ ...match, meta: [] }) - ); + let matchesWithMeta: RouteMatchWithMeta[] = + matches.map((match) => ({ ...match, meta: [] })); let index = -1; for (let match of matches) { index++; let routeId = match.route.id; - let data = routeData[routeId]; + let data = loaderData[routeId]; let params = match.params; let routeModule = routeModules[routeId]; @@ -888,7 +692,7 @@ function V2Meta() { } export function Meta() { - let { future } = useRemixEntryContext(); + let { future } = useRemixContext(); return future.v2_meta ? : ; } @@ -921,13 +725,10 @@ type ScriptProps = Omit< * @see https://remix.run/api/remix#meta-links-scripts */ export function Scripts(props: ScriptProps) { - let { - manifest, - matches, - pendingLocation, - clientRoutes, - serverHandoffString, - } = useRemixEntryContext(); + let { manifest, serverHandoffString } = useRemixContext(); + let { router } = useDataRouterContext(); + let { matches } = useDataRouterStateContext(); + let navigation = useNavigation(); React.useEffect(() => { isHydrated = true; @@ -977,15 +778,18 @@ import(${JSON.stringify(manifest.entry.module)});`; // avoid waterfall when importing the next route module let nextMatches = React.useMemo(() => { - if (pendingLocation) { + if (navigation.location) { // FIXME: can probably use transitionManager `nextMatches` - let matches = matchClientRoutes(clientRoutes, pendingLocation); - invariant(matches, `No routes match path "${pendingLocation.pathname}"`); + let matches = matchRoutes(router.routes, navigation.location); + invariant( + matches, + `No routes match path "${navigation.location.pathname}"` + ); return matches; } return []; - }, [pendingLocation, clientRoutes]); + }, [navigation.location, router.routes]); let routePreloads = matches .concat(nextMatches) @@ -1026,423 +830,6 @@ function dedupe(array: any[]) { return [...new Set(array)]; } -export interface FormProps extends FormHTMLAttributes { - /** - * The HTTP verb to use when the form is submit. Supports "get", "post", - * "put", "delete", "patch". - * - * Note: If JavaScript is disabled, you'll need to implement your own "method - * override" to support more than just GET and POST. - */ - method?: FormMethod; - - /** - * Normal `` but supports React Router's relative paths. - */ - action?: string; - - /** - * Normal ``. - * - * Note: Remix defaults to `application/x-www-form-urlencoded` and also - * supports `multipart/form-data`. - */ - encType?: FormEncType; - - /** - * Forces a full document navigation instead of a fetch. - */ - reloadDocument?: boolean; - - /** - * Replaces the current entry in the browser history stack when the form - * navigates. Use this if you don't want the user to be able to click "back" - * to the page with the form on it. - */ - replace?: boolean; - - /** - * A function to call when the form is submitted. If you call - * `event.preventDefault()` then this form will not do anything. - */ - onSubmit?: React.FormEventHandler; -} - -/** - * A Remix-aware ``. It behaves like a normal form except that the - * interaction with the server is with `fetch` instead of new document - * requests, allowing components to add nicer UX to the page as the form is - * submitted and returns with data. - * - * @see https://remix.run/api/remix#form - */ -let Form = React.forwardRef((props, ref) => { - return ; -}); -Form.displayName = "Form"; -export { Form }; - -interface FormImplProps extends FormProps { - fetchKey?: string; -} - -let FormImpl = React.forwardRef( - ( - { - reloadDocument = false, - replace = false, - method = "get", - action, - encType = "application/x-www-form-urlencoded", - fetchKey, - onSubmit, - ...props - }, - forwardedRef - ) => { - let submit = useSubmitImpl(fetchKey); - let formMethod: FormMethod = - method.toLowerCase() === "get" ? "get" : "post"; - let formAction = useFormAction(action); - - return ( - { - onSubmit && onSubmit(event); - if (event.defaultPrevented) return; - event.preventDefault(); - - let submitter = (event as unknown as HTMLSubmitEvent) - .nativeEvent.submitter as HTMLFormSubmitter | null; - - let submitMethod = - (submitter?.formMethod as FormMethod | undefined) || method; - - submit(submitter || event.currentTarget, { - method: submitMethod, - replace, - }); - } - } - {...props} - /> - ); - } -); -FormImpl.displayName = "FormImpl"; -export { FormImpl }; - -type HTMLSubmitEvent = React.BaseSyntheticEvent< - SubmitEvent, - Event, - HTMLFormElement ->; - -type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement; - -/** - * Resolves a `` path relative to the current route. - * - * @see https://remix.run/api/remix#useformaction - */ -export function useFormAction( - action?: string, - // TODO: Remove method param in v2 as it's no longer needed and is a breaking change - method: FormMethod = "get" -): string { - let { id } = useRemixRouteContext(); - let resolvedPath = useResolvedPath(action ? action : "."); - - // Previously we set the default action to ".". The problem with this is that - // `useResolvedPath(".")` excludes search params and the hash of the resolved - // URL. This is the intended behavior of when "." is specifically provided as - // the form action, but inconsistent w/ browsers when the action is omitted. - // https://github.com/remix-run/remix/issues/927 - let location = useLocation(); - let { search, hash } = resolvedPath; - let isIndexRoute = id.endsWith("/index"); - - if (action == null) { - search = location.search; - hash = location.hash; - - // When grabbing search params from the URL, remove the automatically - // inserted ?index param so we match the useResolvedPath search behavior - // which would not include ?index - if (isIndexRoute) { - let params = new URLSearchParams(search); - params.delete("index"); - search = params.toString() ? `?${params.toString()}` : ""; - } - } - - if ((action == null || action === ".") && isIndexRoute) { - search = search ? search.replace(/^\?/, "?index&") : "?index"; - } - - return createPath({ pathname: resolvedPath.pathname, search, hash }); -} - -export interface SubmitOptions { - /** - * The HTTP method used to submit the form. Overrides ``. - * Defaults to "GET". - */ - method?: FormMethod; - - /** - * The action URL path used to submit the form. Overrides ``. - * Defaults to the path of the current route. - * - * Note: It is assumed the path is already resolved. If you need to resolve a - * relative path, use `useFormAction`. - */ - action?: string; - - /** - * The action URL used to submit the form. Overrides ``. - * Defaults to "application/x-www-form-urlencoded". - */ - encType?: FormEncType; - - /** - * Set `true` to replace the current entry in the browser's history stack - * instead of creating a new one (i.e. stay on "the same page"). Defaults - * to `false`. - */ - replace?: boolean; -} - -/** - * Submits a HTML `` to the server without reloading the page. - */ -export interface SubmitFunction { - ( - /** - * Specifies the `` to be submitted to the server, a specific - * `