;
+ };
+ React.useEffect(() => {
+ document.title = "Defer (No Boundary)";
+ }, []);
+ return (
+ <>
+ Defer No Boundary {data.value}
+ Critical Data: {data.critical}
+
+
+ {(value) => Lazy Data: {value}
}
+
+
+ >
+ );
+ },
+ },
+ {
+ path: "images",
+ Component() {
+ React.useEffect(() => {
+ document.title = "Images";
+ }, []);
+ return (
+
+ );
+ },
+ },
+ {
+ path: "images/:id",
+ Component() {
+ let params = useParams();
+ React.useEffect(() => {
+ document.title = "Image " + params.id;
+ }, [params.id]);
+ return (
+
+
Image Number {params.id}
+
+
+ );
+ },
+ },
+ ],
+ },
+]);
+
+function NavImage({ src, idx }: { src: string; idx: number }) {
+ let href = `/images/${idx}`;
+ let vt = unstable_useViewTransitionState(href);
+ return (
+ <>
+
+
+ Image Number {idx}
+
+
+
+ >
+ );
+}
+
+const rootElement = document.getElementById("root") as HTMLElement;
+ReactDOMClient.createRoot(rootElement).render(
+
+
+
+);
+
+function Nav() {
+ let navigate = useNavigate();
+ let submit = useSubmit();
+ return (
+
+
+
+
+ Home
+
+
+
+ The / route has no loader is should be an immediate/synchronous
+ transition
+
+
+
+
+
+ Loader with delay
+ {" "}
+
+ navigate("/loader", { unstable_viewTransition: true })
+ }
+ >
+ via useNavigate
+
+
+
+ The /loader route has a 1 second loader delay, and updates the DOM
+ synchronously upon completion
+
+
+
+
+ {" "}
+
+ submit(
+ {},
+ {
+ method: "post",
+ action: "/action",
+ unstable_viewTransition: true,
+ }
+ )
+ }
+ >
+ via useSubmit
+
+
+
+ The /action route has a 1 second action delay, and updates the DOM
+ synchronously upon completion
+
+
+
+
+
+ Image Gallery Example
+
+
+
+
+ Deferred Data
+
+
+
+ The /defer route has 1s defer call that suspends and has it's own
+ Suspense boundary
+
+
+
+
+
+ Deferred Data (without boundary)
+
+
+
+ The /defer-no-boundary route has a 1s defer that suspends without
+ a Suspense boundary in the destination route. This relies on
+ React.startTransition to "freeze" the current UI until the
+ deferred data resolves
+
+
+
+
+
+ );
+}
diff --git a/examples/view-transitions/src/vite-env.d.ts b/examples/view-transitions/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/view-transitions/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/view-transitions/tsconfig.json b/examples/view-transitions/tsconfig.json
new file mode 100644
index 0000000000..429c4c3629
--- /dev/null
+++ b/examples/view-transitions/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "target": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "importsNotUsedAsValues": "error"
+ },
+ "include": ["./src"]
+}
diff --git a/examples/view-transitions/vite.config.ts b/examples/view-transitions/vite.config.ts
new file mode 100644
index 0000000000..fbadfa5d9f
--- /dev/null
+++ b/examples/view-transitions/vite.config.ts
@@ -0,0 +1,39 @@
+import * as path from "path";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import rollupReplace from "@rollup/plugin-replace";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ server: {
+ port: 3000,
+ },
+ plugins: [
+ rollupReplace({
+ preventAssignment: true,
+ values: {
+ __DEV__: JSON.stringify(true),
+ "process.env.NODE_ENV": JSON.stringify("development"),
+ },
+ }),
+ react(),
+ ],
+ resolve: process.env.USE_SOURCE
+ ? {
+ alias: {
+ "@remix-run/router": path.resolve(
+ __dirname,
+ "../../packages/router/index.ts"
+ ),
+ "react-router": path.resolve(
+ __dirname,
+ "../../packages/react-router/index.ts"
+ ),
+ "react-router-dom": path.resolve(
+ __dirname,
+ "../../packages/react-router-dom/index.tsx"
+ ),
+ },
+ }
+ : {},
+});
diff --git a/package.json b/package.json
index 56e6f6eb10..1073d672b6 100644
--- a/package.json
+++ b/package.json
@@ -110,19 +110,19 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
- "none": "47.3 kB"
+ "none": "48.3 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
- "none": "13.9 kB"
+ "none": "15.2 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
- "none": "16.3 kB"
+ "none": "17.6 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
- "none": "12.8 kB"
+ "none": "13.6 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
- "none": "18.9 kB"
+ "none": "19.9 kB"
}
}
}
diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx
index 3ef5bc3ec2..aca4d565f9 100644
--- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx
+++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx
@@ -5942,6 +5942,90 @@ function testDomRouter(
`);
});
});
+
+ describe("view transitions", () => {
+ it("applies view transitions to navigations when opted in", async () => {
+ let testWindow = getWindow("/");
+ let spy = jest.fn((cb) => {
+ cb();
+ return {
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ updateCallbackDone: Promise.resolve(),
+ skipTransition: () => {},
+ };
+ });
+ testWindow.document.startViewTransition = spy;
+
+ let router = createTestRouter(
+ [
+ {
+ path: "/",
+ Component() {
+ return (
+
+ /a
+
+ /b
+
+
+
+
+
+ );
+ },
+ children: [
+ {
+ index: true,
+ Component: () => Home ,
+ },
+ {
+ path: "a",
+ Component: () => A ,
+ },
+ {
+ path: "b",
+ Component: () => B ,
+ },
+ {
+ path: "c",
+ action: () => null,
+ Component: () => C ,
+ },
+ {
+ path: "d",
+ action: () => null,
+ Component: () => D ,
+ },
+ ],
+ },
+ ],
+ { window: testWindow }
+ );
+ render( );
+
+ expect(screen.getByText("Home")).toBeDefined();
+ fireEvent.click(screen.getByText("/a"));
+ await waitFor(() => screen.getByText("A"));
+ expect(spy).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByText("/b"));
+ await waitFor(() => screen.getByText("B"));
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByText("/c"));
+ await waitFor(() => screen.getByText("C"));
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByText("/d"));
+ await waitFor(() => screen.getByText("D"));
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
+ });
});
}
diff --git a/packages/react-router-dom/__tests__/exports-test.tsx b/packages/react-router-dom/__tests__/exports-test.tsx
index 7e87c75a81..e3b479d6f1 100644
--- a/packages/react-router-dom/__tests__/exports-test.tsx
+++ b/packages/react-router-dom/__tests__/exports-test.tsx
@@ -4,7 +4,6 @@ import * as ReactRouterDOM from "react-router-dom";
let nonReExportedKeys = new Set([
"UNSAFE_mapRouteProperties",
"UNSAFE_useRoutesImpl",
- "UNSAFE_startTransitionImpl",
]);
describe("react-router-dom", () => {
diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts
index c81b910387..4aeeaa6957 100644
--- a/packages/react-router-dom/dom.ts
+++ b/packages/react-router-dom/dom.ts
@@ -193,6 +193,11 @@ export interface SubmitOptions {
* navigation when using the component
*/
preventScrollReset?: boolean;
+
+ /**
+ * Enable view transitions on this submission navigation
+ */
+ unstable_viewTransition?: boolean;
}
const supportedFormEncTypes: Set = new Set([
diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx
index 9f274dd4e7..3f76a09afd 100644
--- a/packages/react-router-dom/index.tsx
+++ b/packages/react-router-dom/index.tsx
@@ -26,6 +26,7 @@ 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,
} from "react-router";
@@ -52,6 +53,7 @@ import {
UNSAFE_ErrorResponseImpl as ErrorResponseImpl,
UNSAFE_invariant as invariant,
UNSAFE_warning as warning,
+ matchPath,
} from "@remix-run/router";
import type {
@@ -201,6 +203,7 @@ export {
UNSAFE_NavigationContext,
UNSAFE_LocationContext,
UNSAFE_RouteContext,
+ UNSAFE_ViewTransitionContext,
UNSAFE_useRouteId,
} from "react-router";
//#endregion
@@ -234,6 +237,7 @@ export function createBrowserRouter(
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
mapRouteProperties,
+ window: opts?.window,
}).initialize();
}
@@ -251,6 +255,7 @@ export function createHashRouter(
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
mapRouteProperties,
+ window: opts?.window,
}).initialize();
}
@@ -502,6 +507,7 @@ export interface LinkProps
preventScrollReset?: boolean;
relative?: RelativeRoutingType;
to: To;
+ unstable_viewTransition?: boolean;
}
const isBrowser =
@@ -525,6 +531,7 @@ export const Link = React.forwardRef(
target,
to,
preventScrollReset,
+ unstable_viewTransition,
...rest
},
ref
@@ -574,6 +581,7 @@ export const Link = React.forwardRef(
target,
preventScrollReset,
relative,
+ unstable_viewTransition,
});
function handleClick(
event: React.MouseEvent
@@ -601,25 +609,22 @@ if (__DEV__) {
Link.displayName = "Link";
}
+type NavLinkRenderProps = {
+ isActive: boolean;
+ isPending: boolean;
+ isTransitioning: boolean;
+};
+
export interface NavLinkProps
extends Omit {
- children?:
- | React.ReactNode
- | ((props: { isActive: boolean; isPending: boolean }) => React.ReactNode);
+ children?: React.ReactNode | ((props: NavLinkRenderProps) => React.ReactNode);
caseSensitive?: boolean;
- className?:
- | string
- | ((props: {
- isActive: boolean;
- isPending: boolean;
- }) => string | undefined);
+ className?: string | ((props: NavLinkRenderProps) => string | undefined);
end?: boolean;
style?:
| React.CSSProperties
- | ((props: {
- isActive: boolean;
- isPending: boolean;
- }) => React.CSSProperties | undefined);
+ | ((props: NavLinkRenderProps) => React.CSSProperties | undefined);
+ unstable_viewTransition?: boolean;
}
/**
@@ -634,6 +639,7 @@ export const NavLink = React.forwardRef(
end = false,
style: styleProp,
to,
+ unstable_viewTransition,
children,
...rest
},
@@ -643,6 +649,12 @@ export const NavLink = React.forwardRef(
let location = useLocation();
let routerState = React.useContext(DataRouterStateContext);
let { navigator } = React.useContext(NavigationContext);
+ let isTransitioning =
+ routerState != null &&
+ // Conditional usage is OK here because the usage of a data router is static
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useViewTransitionState(path) &&
+ unstable_viewTransition === true;
let toPathname = navigator.encodeLocation
? navigator.encodeLocation(path).pathname
@@ -674,11 +686,17 @@ export const NavLink = React.forwardRef(
nextLocationPathname.startsWith(toPathname) &&
nextLocationPathname.charAt(toPathname.length) === "/"));
+ let renderProps = {
+ isActive,
+ isPending,
+ isTransitioning,
+ };
+
let ariaCurrent = isActive ? ariaCurrentProp : undefined;
let className: string | undefined;
if (typeof classNameProp === "function") {
- className = classNameProp({ isActive, isPending });
+ className = classNameProp(renderProps);
} else {
// If the className prop is not a function, we use a default `active`
// class for s that are active. In v5 `active` was the default
@@ -689,15 +707,14 @@ export const NavLink = React.forwardRef(
classNameProp,
isActive ? "active" : null,
isPending ? "pending" : null,
+ isTransitioning ? "transitioning" : null,
]
.filter(Boolean)
.join(" ");
}
let style =
- typeof styleProp === "function"
- ? styleProp({ isActive, isPending })
- : styleProp;
+ typeof styleProp === "function" ? styleProp(renderProps) : styleProp;
return (
(
ref={ref}
style={style}
to={to}
+ unstable_viewTransition={unstable_viewTransition}
>
- {typeof children === "function"
- ? children({ isActive, isPending })
- : children}
+ {typeof children === "function" ? children(renderProps) : children}
);
}
@@ -779,6 +795,11 @@ export interface FormProps extends FetcherFormProps {
* State object to add to the history stack entry for this navigation
*/
state?: any;
+
+ /**
+ * Enable view transitions on this Form navigation
+ */
+ unstable_viewTransition?: boolean;
}
/**
@@ -822,6 +843,7 @@ const FormImpl = React.forwardRef(
submit,
relative,
preventScrollReset,
+ unstable_viewTransition,
...props
},
forwardedRef
@@ -847,6 +869,7 @@ const FormImpl = React.forwardRef(
state,
relative,
preventScrollReset,
+ unstable_viewTransition,
});
};
@@ -897,6 +920,8 @@ enum DataRouterHook {
UseSubmit = "useSubmit",
UseSubmitFetcher = "useSubmitFetcher",
UseFetcher = "useFetcher",
+ useViewTransitionStates = "useViewTransitionStates",
+ useViewTransitionState = "useViewTransitionState",
}
enum DataRouterStateHook {
@@ -935,12 +960,14 @@ export function useLinkClickHandler(
state,
preventScrollReset,
relative,
+ unstable_viewTransition,
}: {
target?: React.HTMLAttributeAnchorTarget;
replace?: boolean;
state?: any;
preventScrollReset?: boolean;
relative?: RelativeRoutingType;
+ unstable_viewTransition?: boolean;
} = {}
): (event: React.MouseEvent) => void {
let navigate = useNavigate();
@@ -959,7 +986,13 @@ export function useLinkClickHandler(
? replaceProp
: createPath(location) === createPath(path);
- navigate(to, { replace, state, preventScrollReset, relative });
+ navigate(to, {
+ replace,
+ state,
+ preventScrollReset,
+ relative,
+ unstable_viewTransition,
+ });
}
},
[
@@ -972,6 +1005,7 @@ export function useLinkClickHandler(
to,
preventScrollReset,
relative,
+ unstable_viewTransition,
]
);
}
@@ -1103,6 +1137,7 @@ export function useSubmit(): SubmitFunction {
replace: options.replace,
state: options.state,
fromRouteId: currentRouteId,
+ unstable_viewTransition: options.unstable_viewTransition,
});
},
[router, basename, currentRouteId]
@@ -1495,4 +1530,52 @@ function usePrompt({ when, message }: { when: boolean; message: string }) {
export { usePrompt as unstable_usePrompt };
+/**
+ * Return a boolean indicating if there is an active view transition to the
+ * given href. You can use this value to render CSS classes or viewTransitionName
+ * styles onto your elements
+ *
+ * @param href The destination href
+ * @param [opts.relative] Relative routing type ("route" | "path")
+ */
+function useViewTransitionState(
+ to: To,
+ opts: { relative?: RelativeRoutingType } = {}
+) {
+ let vtContext = React.useContext(ViewTransitionContext);
+ let { basename } = useDataRouterContext(
+ DataRouterHook.useViewTransitionState
+ );
+ let path = useResolvedPath(to, { relative: opts.relative });
+ if (vtContext.isTransitioning) {
+ let currentPath =
+ stripBasename(vtContext.currentLocation.pathname, basename) ||
+ vtContext.currentLocation.pathname;
+ let nextPath =
+ stripBasename(vtContext.nextLocation.pathname, basename) ||
+ vtContext.nextLocation.pathname;
+
+ // Transition is active if we're going to or coming from the indicated
+ // destination. This ensures that other PUSH navigations that reverse
+ // an indicated transition apply. I.e., on the list view you have:
+ //
+ //
+ //
+ // If you click the breadcrumb back to the list view:
+ //
+ //
+ //
+ // We should apply the transition because it's indicated as active going
+ // from /list -> /details/1 and therefore should be active on the reverse
+ // (even though this isn't strictly a POP reverse)
+ return (
+ matchPath(path.pathname, nextPath) != null ||
+ matchPath(path.pathname, currentPath) != null
+ );
+ }
+ return false;
+}
+
+export { useViewTransitionState as unstable_useViewTransitionState };
+
//#endregion
diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx
index 17a2483872..9622b961b6 100644
--- a/packages/react-router-dom/server.tsx
+++ b/packages/react-router-dom/server.tsx
@@ -300,6 +300,9 @@ export function createStaticRouter(
get routes() {
return dataRoutes;
},
+ get window() {
+ return undefined;
+ },
initialize() {
throw msg("initialize");
},
diff --git a/packages/react-router-native/__tests__/exports-test.tsx b/packages/react-router-native/__tests__/exports-test.tsx
index ab49f423d6..c13a20c8e1 100644
--- a/packages/react-router-native/__tests__/exports-test.tsx
+++ b/packages/react-router-native/__tests__/exports-test.tsx
@@ -4,7 +4,7 @@ import * as ReactRouterNative from "react-router-native";
let nonReExportedKeys = new Set([
"UNSAFE_mapRouteProperties",
"UNSAFE_useRoutesImpl",
- "UNSAFE_startTransitionImpl",
+ "UNSAFE_ViewTransitionContext",
]);
describe("react-router-native", () => {
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 4ac70a3422..0b960fa82f 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -89,6 +89,7 @@ import {
LocationContext,
NavigationContext,
RouteContext,
+ ViewTransitionContext,
} from "./lib/context";
import type { NavigateFunction } from "./lib/hooks";
import {
@@ -308,6 +309,7 @@ export {
LocationContext as UNSAFE_LocationContext,
NavigationContext as UNSAFE_NavigationContext,
RouteContext as UNSAFE_RouteContext,
+ ViewTransitionContext as UNSAFE_ViewTransitionContext,
mapRouteProperties as UNSAFE_mapRouteProperties,
useRouteId as UNSAFE_useRouteId,
useRoutesImpl as UNSAFE_useRoutesImpl,
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 17f2e0728c..0c79d97932 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -1,3 +1,4 @@
+import * as ReactDOM from "react-dom";
import type {
InitialEntry,
LazyRouteFunction,
@@ -6,6 +7,7 @@ import type {
RelativeRoutingType,
Router as RemixRouter,
RouterState,
+ RouterSubscriber,
To,
TrackedPromise,
} from "@remix-run/router";
@@ -29,6 +31,7 @@ import type {
NonIndexRouteObject,
RouteMatch,
RouteObject,
+ ViewTransitionContextObject,
} from "./context";
import {
AwaitContext,
@@ -37,6 +40,7 @@ import {
LocationContext,
NavigationContext,
RouteContext,
+ ViewTransitionContext,
} from "./context";
import {
_renderMatches,
@@ -49,6 +53,19 @@ import {
useRoutesImpl,
} from "./hooks";
+interface ViewTransition {
+ finished: Promise;
+ ready: Promise;
+ updateCallbackDone: Promise;
+ skipTransition(): void;
+}
+
+declare global {
+ interface Document {
+ startViewTransition(cb: () => Promise | void): ViewTransition;
+ }
+}
+
export interface FutureConfig {
v7_startTransition: boolean;
}
@@ -83,6 +100,39 @@ export interface RouterProviderProps {
const START_TRANSITION = "startTransition";
const startTransitionImpl = React[START_TRANSITION];
+function startTransitionSafe(cb: () => void) {
+ if (startTransitionImpl) {
+ startTransitionImpl(cb);
+ } else {
+ cb();
+ }
+}
+
+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
*/
@@ -91,20 +141,125 @@ export function RouterProvider({
router,
future,
}: RouterProviderProps): React.ReactElement {
- // Need to use a layout effect here so we are subscribed early enough to
- // pick up on any render-driven redirects/navigations (useEffect/)
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 setState = React.useCallback(
- (newState: RouterState) => {
- v7_startTransition && startTransitionImpl
- ? startTransitionImpl(() => setStateImpl(newState))
- : setStateImpl(newState);
+
+ let optInStartTransition = React.useCallback(
+ (cb: () => void) => {
+ if (v7_startTransition) {
+ startTransitionSafe(cb);
+ } else {
+ cb();
+ }
},
- [setStateImpl, v7_startTransition]
+ [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,
@@ -146,18 +301,20 @@ export function RouterProvider({
<>
-
- {state.initialized ? (
-
- ) : (
- fallbackElement
- )}
-
+
+
+ {state.initialized ? (
+
+ ) : (
+ fallbackElement
+ )}
+
+
{null}
diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts
index f513350051..0c9d796daf 100644
--- a/packages/react-router/lib/context.ts
+++ b/packages/react-router/lib/context.ts
@@ -84,6 +84,22 @@ if (__DEV__) {
DataRouterStateContext.displayName = "DataRouterState";
}
+export type ViewTransitionContextObject =
+ | {
+ isTransitioning: false;
+ }
+ | {
+ isTransitioning: true;
+ currentLocation: Location;
+ nextLocation: Location;
+ };
+
+export const ViewTransitionContext =
+ React.createContext({ isTransitioning: false });
+if (__DEV__) {
+ ViewTransitionContext.displayName = "ViewTransition";
+}
+
export const AwaitContext = React.createContext(null);
if (__DEV__) {
AwaitContext.displayName = "Await";
@@ -94,6 +110,7 @@ export interface NavigateOptions {
state?: any;
preventScrollReset?: boolean;
relative?: RelativeRoutingType;
+ unstable_viewTransition?: boolean;
}
/**
diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts
index edc7109df7..b4a91d110e 100644
--- a/packages/router/__tests__/router-test.ts
+++ b/packages/router/__tests__/router-test.ts
@@ -17762,6 +17762,70 @@ describe("a router", () => {
expect(router.state.matches[0].route.path).toBe("/path");
});
});
+
+ describe("view transitions", () => {
+ it("only enables view transitions when specified for the navigation", () => {
+ let t = setup({
+ routes: [{ path: "/" }, { path: "/a" }, { path: "/b" }],
+ });
+ let spy = jest.fn();
+ let unsubscribe = t.router.subscribe(spy);
+
+ // PUSH / -> /a - w/o transition
+ t.navigate("/a");
+ expect(spy).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ navigation: IDLE_NAVIGATION,
+ location: expect.objectContaining({ pathname: "/a" }),
+ }),
+ { unstable_viewTransitionOpts: undefined }
+ );
+
+ // PUSH /a -> /b - w/ transition
+ t.navigate("/b", { unstable_viewTransition: true });
+ expect(spy).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ navigation: IDLE_NAVIGATION,
+ location: expect.objectContaining({ pathname: "/b" }),
+ }),
+ {
+ unstable_viewTransitionOpts: {
+ currentLocation: expect.objectContaining({ pathname: "/a" }),
+ nextLocation: expect.objectContaining({ pathname: "/b" }),
+ },
+ }
+ );
+
+ // POP /b -> /a - w/ transition (cached from above)
+ t.navigate(-1);
+ expect(spy).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ navigation: IDLE_NAVIGATION,
+ location: expect.objectContaining({ pathname: "/a" }),
+ }),
+ {
+ unstable_viewTransitionOpts: {
+ // Args reversed on POP so same hooks apply
+ currentLocation: expect.objectContaining({ pathname: "/a" }),
+ nextLocation: expect.objectContaining({ pathname: "/b" }),
+ },
+ }
+ );
+
+ // POP /a -> / - No transition
+ t.navigate(-1);
+ expect(spy).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ navigation: IDLE_NAVIGATION,
+ location: expect.objectContaining({ pathname: "/" }),
+ }),
+ { unstable_viewTransitionOpts: undefined }
+ );
+
+ unsubscribe();
+ t.router.dispose();
+ });
+ });
});
// We use a slightly modified version of createDeferred here that incoudes the
diff --git a/packages/router/router.ts b/packages/router/router.ts
index 11855a65a7..2eb7bc8327 100644
--- a/packages/router/router.ts
+++ b/packages/router/router.ts
@@ -80,6 +80,14 @@ export interface Router {
*/
get routes(): AgnosticDataRouteObject[];
+ /**
+ * @internal
+ * PRIVATE - DO NOT USE
+ *
+ * Return the window associated with the router
+ */
+ get window(): RouterInit["window"];
+
/**
* @internal
* PRIVATE - DO NOT USE
@@ -388,11 +396,21 @@ export interface StaticHandler {
): Promise;
}
+type ViewTransitionOpts = {
+ currentLocation: Location;
+ nextLocation: Location;
+};
+
/**
* Subscriber function signature for changes to router state
*/
export interface RouterSubscriber {
- (state: RouterState): void;
+ (
+ state: RouterState,
+ opts: {
+ unstable_viewTransitionOpts?: ViewTransitionOpts;
+ }
+ ): void;
}
/**
@@ -423,6 +441,7 @@ type BaseNavigateOptions = BaseNavigateOrFetchOptions & {
replace?: boolean;
state?: any;
fromRouteId?: string;
+ unstable_viewTransition?: boolean;
};
// Only allowed for submission navigations
@@ -690,6 +709,8 @@ const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
hasErrorBoundary: Boolean(route.hasErrorBoundary),
});
+const TRANSITIONS_STORAGE_KEY = "remix-router-transitions";
+
//#endregion
////////////////////////////////////////////////////////////////////////////////
@@ -814,6 +835,18 @@ export function createRouter(init: RouterInit): Router {
// AbortController for the active navigation
let pendingNavigationController: AbortController | null;
+ // Should the current navigation enable document.startViewTransition?
+ let pendingViewTransitionEnabled = false;
+
+ // Store applied view transitions so we can apply them on POP
+ let appliedViewTransitions: Map> = new Map<
+ string,
+ Set
+ >();
+
+ // Cleanup function for persisting applied transitions to sessionStorage
+ let removePageHideEventListener: (() => void) | null = null;
+
// We use this to avoid touching history in completeNavigation if a
// revalidation is entirely uninterrupted
let isUninterruptedRevalidation = false;
@@ -929,6 +962,17 @@ export function createRouter(init: RouterInit): Router {
}
);
+ if (isBrowser) {
+ // FIXME: This feels gross. How can we cleanup the lines between
+ // scrollRestoration/appliedTransitions persistance?
+ restoreAppliedTransitions(routerWindow, appliedViewTransitions);
+ let _saveAppliedTransitions = () =>
+ persistAppliedTransitions(routerWindow, appliedViewTransitions);
+ routerWindow.addEventListener("pagehide", _saveAppliedTransitions);
+ removePageHideEventListener = () =>
+ routerWindow.removeEventListener("pagehide", _saveAppliedTransitions);
+ }
+
// Kick off initial data load if needed. Use Pop to avoid modifying history
// Note we don't do any handling of lazy here. For SPA's it'll get handled
// in the normal navigation flow. For SSR it's expected that lazy modules are
@@ -946,6 +990,9 @@ export function createRouter(init: RouterInit): Router {
if (unlistenHistory) {
unlistenHistory();
}
+ if (removePageHideEventListener) {
+ removePageHideEventListener();
+ }
subscribers.clear();
pendingNavigationController && pendingNavigationController.abort();
state.fetchers.forEach((_, key) => deleteFetcher(key));
@@ -959,12 +1006,17 @@ export function createRouter(init: RouterInit): Router {
}
// Update our state and notify the calling context of the change
- function updateState(newState: Partial): void {
+ function updateState(
+ newState: Partial,
+ viewTransitionOpts?: ViewTransitionOpts
+ ): void {
state = {
...state,
...newState,
};
- subscribers.forEach((subscriber) => subscriber(state));
+ subscribers.forEach((subscriber) =>
+ subscriber(state, { unstable_viewTransitionOpts: viewTransitionOpts })
+ );
}
// Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION
@@ -1045,26 +1097,64 @@ export function createRouter(init: RouterInit): Router {
init.history.replace(location, location.state);
}
- updateState({
- ...newState, // matches, errors, fetchers go through as-is
- actionData,
- loaderData,
- historyAction: pendingAction,
- location,
- initialized: true,
- navigation: IDLE_NAVIGATION,
- revalidation: "idle",
- restoreScrollPosition: getSavedScrollPosition(
+ let viewTransitionOpts: ViewTransitionOpts | undefined;
+
+ // On POP, enable transitions if they were enabled on the original navigation
+ if (pendingAction === HistoryAction.Pop) {
+ // Forward takes precedence so they behave like the original navigation
+ let priorPaths = appliedViewTransitions.get(state.location.pathname);
+ if (priorPaths && priorPaths.has(location.pathname)) {
+ viewTransitionOpts = {
+ currentLocation: state.location,
+ nextLocation: location,
+ };
+ } else if (appliedViewTransitions.has(location.pathname)) {
+ // If we don't have a previous forward nav, assume we're popping back to
+ // the new location and enable if that location previously enabled
+ viewTransitionOpts = {
+ currentLocation: location,
+ nextLocation: state.location,
+ };
+ }
+ } else if (pendingViewTransitionEnabled) {
+ // Store the applied transition on PUSH/REPLACE
+ let toPaths = appliedViewTransitions.get(state.location.pathname);
+ if (toPaths) {
+ toPaths.add(location.pathname);
+ } else {
+ toPaths = new Set([location.pathname]);
+ appliedViewTransitions.set(state.location.pathname, toPaths);
+ }
+ viewTransitionOpts = {
+ currentLocation: state.location,
+ nextLocation: location,
+ };
+ }
+
+ updateState(
+ {
+ ...newState, // matches, errors, fetchers go through as-is
+ actionData,
+ loaderData,
+ historyAction: pendingAction,
location,
- newState.matches || state.matches
- ),
- preventScrollReset,
- blockers,
- });
+ initialized: true,
+ navigation: IDLE_NAVIGATION,
+ revalidation: "idle",
+ restoreScrollPosition: getSavedScrollPosition(
+ location,
+ newState.matches || state.matches
+ ),
+ preventScrollReset,
+ blockers,
+ },
+ viewTransitionOpts
+ );
// Reset stateful navigation vars
pendingAction = HistoryAction.Pop;
pendingPreventScrollReset = false;
+ pendingViewTransitionEnabled = false;
isUninterruptedRevalidation = false;
isRevalidationRequired = false;
cancelledDeferredRoutes = [];
@@ -1173,6 +1263,7 @@ export function createRouter(init: RouterInit): Router {
pendingError: error,
preventScrollReset,
replace: opts && opts.replace,
+ enableViewTransition: opts && opts.unstable_viewTransition,
});
}
@@ -1223,6 +1314,7 @@ export function createRouter(init: RouterInit): Router {
startUninterruptedRevalidation?: boolean;
preventScrollReset?: boolean;
replace?: boolean;
+ enableViewTransition?: boolean;
}
): Promise {
// Abort any in-progress navigations and start a new one. Unset any ongoing
@@ -1239,6 +1331,8 @@ export function createRouter(init: RouterInit): Router {
saveScrollPosition(state.location, state.matches);
pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
+ pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true;
+
let routesToUse = inFlightDataRoutes || dataRoutes;
let loadingNavigation = opts && opts.overrideNavigation;
let matches = matchRoutes(routesToUse, location, basename);
@@ -2505,6 +2599,9 @@ export function createRouter(init: RouterInit): Router {
get routes() {
return dataRoutes;
},
+ get window() {
+ return routerWindow;
+ },
initialize,
subscribe,
enableScrollRestoration,
@@ -3074,7 +3171,7 @@ export function getStaticContextFromError(
}
function isSubmissionNavigation(
- opts: RouterNavigateOptions
+ opts: BaseNavigateOrFetchOptions
): opts is SubmissionNavigateOptions {
return (
opts != null &&
@@ -3158,7 +3255,7 @@ function normalizeNavigateOptions(
normalizeFormMethod: boolean,
isFetcher: boolean,
path: string,
- opts?: RouterNavigateOptions
+ opts?: BaseNavigateOrFetchOptions
): {
path: string;
submission?: Submission;
@@ -4495,4 +4592,49 @@ function getDoneFetcher(data: Fetcher["data"]): FetcherStates["Idle"] {
};
return fetcher;
}
+
+function restoreAppliedTransitions(
+ _window: Window,
+ transitions: Map>
+) {
+ try {
+ let sessionPositions = _window.sessionStorage.getItem(
+ TRANSITIONS_STORAGE_KEY
+ );
+ if (sessionPositions) {
+ let json = JSON.parse(sessionPositions);
+ for (let [k, v] of Object.entries(json || {})) {
+ if (v && Array.isArray(v)) {
+ transitions.set(k, new Set(v || []));
+ }
+ }
+ }
+ } catch (e) {
+ // no-op, use default empty object
+ }
+}
+
+function persistAppliedTransitions(
+ _window: Window,
+ transitions: Map>
+) {
+ if (transitions.size > 0) {
+ let json: Record = {};
+ for (let [k, v] of transitions) {
+ json[k] = [...v];
+ }
+ try {
+ _window.sessionStorage.setItem(
+ TRANSITIONS_STORAGE_KEY,
+ JSON.stringify(json)
+ );
+ } catch (error) {
+ warning(
+ false,
+ `Failed to save applied view transitions in sessionStorage (${error}).`
+ );
+ }
+ }
+}
+
//#endregion