diff --git a/.changeset/lift-start-view-transition.md b/.changeset/lift-start-view-transition.md new file mode 100644 index 0000000000..f469f68be4 --- /dev/null +++ b/.changeset/lift-start-view-transition.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": patch +"react-router": patch +--- + +Export a separate `RouterProvider` from `react-router-dom` with `startViewTransition` support diff --git a/.changeset/start-view-transition.md b/.changeset/start-view-transition.md index 55a09e5f05..fe65a08e45 100644 --- a/.changeset/start-view-transition.md +++ b/.changeset/start-view-transition.md @@ -1,6 +1,5 @@ --- "react-router-dom": minor -"react-router": minor "@remix-run/router": minor --- diff --git a/package.json b/package.json index 9e6cc7a28f..f1746f97c7 100644 --- a/package.json +++ b/package.json @@ -113,16 +113,16 @@ "none": "48.3 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "15.2 kB" + "none": "13.9 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.61 kB" + "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "13.61 kB" + "none": "15.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "19.91 kB" + "none": "22.1 kB" } } } diff --git a/packages/react-router-dom/__tests__/exports-test.tsx b/packages/react-router-dom/__tests__/exports-test.tsx index e3b479d6f1..dcf2e22ba7 100644 --- a/packages/react-router-dom/__tests__/exports-test.tsx +++ b/packages/react-router-dom/__tests__/exports-test.tsx @@ -6,16 +6,23 @@ let nonReExportedKeys = new Set([ "UNSAFE_useRoutesImpl", ]); +let modifiedExports = new Set(["RouterProvider"]); + describe("react-router-dom", () => { for (let key in ReactRouter) { - if (!nonReExportedKeys.has(key)) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); - }); - } else { + if (nonReExportedKeys.has(key)) { it(`does not re-export ${key} from react-router`, () => { expect(ReactRouterDOM[key]).toBe(undefined); }); + } else if (modifiedExports.has(key)) { + it(`re-exports a different version of ${key}`, () => { + expect(ReactRouterDOM[key]).toBeDefined(); + expect(ReactRouterDOM[key]).not.toBe(ReactRouter[key]); + }); + } else { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); + }); } } }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 3f76a09afd..8c62c56893 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -4,12 +4,15 @@ */ import * as React from "react"; import type { + DataRouteObject, FutureConfig, Location, NavigateOptions, NavigationType, + Navigator, RelativeRoutingType, RouteObject, + RouterProviderProps, To, } from "react-router"; import { @@ -26,9 +29,9 @@ import { UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, UNSAFE_RouteContext as RouteContext, - UNSAFE_ViewTransitionContext as ViewTransitionContext, UNSAFE_mapRouteProperties as mapRouteProperties, UNSAFE_useRouteId as useRouteId, + UNSAFE_useRoutesImpl as useRoutesImpl, } from "react-router"; import type { BrowserHistory, @@ -43,6 +46,8 @@ import type { HydrationState, Router as RemixRouter, V7_FormMethod, + RouterState, + RouterSubscriber, } from "@remix-run/router"; import { createRouter, @@ -143,7 +148,6 @@ export { Outlet, Route, Router, - RouterProvider, Routes, createMemoryRouter, createPath, @@ -203,13 +207,15 @@ export { UNSAFE_NavigationContext, UNSAFE_LocationContext, UNSAFE_RouteContext, - UNSAFE_ViewTransitionContext, UNSAFE_useRouteId, } from "react-router"; //#endregion declare global { var __staticRouterHydrationData: HydrationState | undefined; + interface Document { + startViewTransition(cb: () => Promise | void): ViewTransition; + } } //////////////////////////////////////////////////////////////////////////////// @@ -320,6 +326,31 @@ function deserializeErrors( //#endregion +//////////////////////////////////////////////////////////////////////////////// +//#region Contexts +//////////////////////////////////////////////////////////////////////////////// + +type ViewTransitionContextObject = + | { + isTransitioning: false; + } + | { + isTransitioning: true; + currentLocation: Location; + nextLocation: Location; + }; + +const ViewTransitionContext = React.createContext({ + isTransitioning: false, +}); +if (__DEV__) { + ViewTransitionContext.displayName = "ViewTransition"; +} + +export { ViewTransitionContext as UNSAFE_ViewTransitionContext }; + +//#endregion + //////////////////////////////////////////////////////////////////////////////// //#region Components //////////////////////////////////////////////////////////////////////////////// @@ -348,6 +379,245 @@ function deserializeErrors( const START_TRANSITION = "startTransition"; const startTransitionImpl = React[START_TRANSITION]; +function startTransitionSafe(cb: () => void) { + if (startTransitionImpl) { + startTransitionImpl(cb); + } else { + cb(); + } +} + +interface ViewTransition { + finished: Promise; + ready: Promise; + updateCallbackDone: Promise; + skipTransition(): void; +} + +class Deferred { + status: "pending" | "resolved" | "rejected" = "pending"; + promise: Promise; + // @ts-expect-error - no initializer + resolve: (value: T) => void; + // @ts-expect-error - no initializer + reject: (reason?: unknown) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + if (this.status === "pending") { + this.status = "resolved"; + resolve(value); + } + }; + this.reject = (reason) => { + if (this.status === "pending") { + this.status = "rejected"; + reject(reason); + } + }; + }); + } +} + +/** + * Given a Remix Router instance, render the appropriate UI + */ +export function RouterProvider({ + fallbackElement, + router, + future, +}: RouterProviderProps): React.ReactElement { + let [state, setStateImpl] = React.useState(router.state); + let [pendingState, setPendingState] = React.useState(); + let [vtContext, setVtContext] = React.useState({ + isTransitioning: false, + }); + let [renderDfd, setRenderDfd] = React.useState>(); + let [transition, setTransition] = React.useState(); + let [interruption, setInterruption] = React.useState<{ + state: RouterState; + currentLocation: Location; + nextLocation: Location; + }>(); + let { v7_startTransition } = future || {}; + + let optInStartTransition = React.useCallback( + (cb: () => void) => { + if (v7_startTransition) { + startTransitionSafe(cb); + } else { + cb(); + } + }, + [v7_startTransition] + ); + + let setState = React.useCallback( + ( + newState: RouterState, + { unstable_viewTransitionOpts: viewTransitionOpts } + ) => { + if ( + !viewTransitionOpts || + router.window == null || + typeof router.window.document.startViewTransition !== "function" + ) { + // Mid-navigation state update, or startViewTransition isn't available + optInStartTransition(() => setStateImpl(newState)); + } else if (transition && renderDfd) { + // Interrupting an in-progress transition, cancel and let everything flush + // out, and then kick off a new transition from the interruption state + renderDfd.resolve(); + transition.skipTransition(); + setInterruption({ + state: newState, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); + } else { + // Completed navigation update with opted-in view transitions, let 'er rip + setPendingState(newState); + setVtContext({ + isTransitioning: true, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); + } + }, + [optInStartTransition, transition, renderDfd, router.window] + ); + + // Need to use a layout effect here so we are subscribed early enough to + // pick up on any render-driven redirects/navigations (useEffect/) + React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); + + // When we start a view transition, create a Deferred we can use for the + // eventual "completed" render + React.useEffect(() => { + if (vtContext.isTransitioning) { + setRenderDfd(new Deferred()); + } + }, [vtContext.isTransitioning]); + + // Once the deferred is created, kick off startViewTransition() to update the + // DOM and then wait on the Deferred to resolve (indicating the DOM update has + // happened) + React.useEffect(() => { + if (renderDfd && pendingState && router.window) { + let newState = pendingState; + let renderPromise = renderDfd.promise; + let transition = router.window.document.startViewTransition(async () => { + optInStartTransition(() => setStateImpl(newState)); + await renderPromise; + }); + transition.finished.finally(() => { + setRenderDfd(undefined); + setTransition(undefined); + setPendingState(undefined); + setVtContext({ isTransitioning: false }); + }); + setTransition(transition); + } + }, [optInStartTransition, pendingState, renderDfd, router.window]); + + // When the new location finally renders and is committed to the DOM, this + // effect will run to resolve the transition + React.useEffect(() => { + if ( + renderDfd && + pendingState && + state.location.key === pendingState.location.key + ) { + renderDfd.resolve(); + } + }, [renderDfd, transition, state.location, pendingState]); + + // If we get interrupted with a new navigation during a transition, we skip + // the active transition, let it cleanup, then kick it off again here + React.useEffect(() => { + if (!vtContext.isTransitioning && interruption) { + setPendingState(interruption.state); + setVtContext({ + isTransitioning: true, + currentLocation: interruption.currentLocation, + nextLocation: interruption.nextLocation, + }); + setInterruption(undefined); + } + }, [vtContext.isTransitioning, interruption]); + + let navigator = React.useMemo((): Navigator => { + return { + createHref: router.createHref, + encodeLocation: router.encodeLocation, + go: (n) => router.navigate(n), + push: (to, state, opts) => + router.navigate(to, { + state, + preventScrollReset: opts?.preventScrollReset, + }), + replace: (to, state, opts) => + router.navigate(to, { + replace: true, + state, + preventScrollReset: opts?.preventScrollReset, + }), + }; + }, [router]); + + let basename = router.basename || "/"; + + let dataRouterContext = React.useMemo( + () => ({ + router, + navigator, + static: false, + basename, + }), + [router, navigator, basename] + ); + + // The fragment and {null} here are important! We need them to keep React 18's + // useId happy when we are server-rendering since we may have a