From 45333f0c93fec7a232df47ba87afba15bda58f60 Mon Sep 17 00:00:00 2001 From: Liran Sharir Date: Fri, 7 Feb 2025 20:47:52 -0500 Subject: [PATCH 1/2] feat: add viewTransitionTypes support for selective view transitions --- contributors.yml | 1 + docs/how-to/view-transitions.md | 18 ++ .../__tests__/data-router-no-dom-test.tsx | 79 +++++++ .../dom/data-browser-router-test.tsx | 51 +++++ .../router/router-session-storage-test.ts | 213 ++++++++++++++++++ .../__tests__/router/view-transition-test.ts | 7 + packages/react-router/lib/components.tsx | 52 ++++- packages/react-router/lib/context.ts | 9 +- packages/react-router/lib/dom/dom.ts | 8 +- packages/react-router/lib/dom/global.ts | 14 ++ packages/react-router/lib/dom/lib.tsx | 38 ++-- packages/react-router/lib/router/router.ts | 130 ++++++----- 12 files changed, 541 insertions(+), 79 deletions(-) create mode 100644 packages/react-router/__tests__/router/router-session-storage-test.ts diff --git a/contributors.yml b/contributors.yml index d6bb33186f..1f0962ad45 100644 --- a/contributors.yml +++ b/contributors.yml @@ -190,6 +190,7 @@ - lounsbrough - lpaube - lqze +- lsharir - lukerSpringTree - m-dad - m-shojaei diff --git a/docs/how-to/view-transitions.md b/docs/how-to/view-transitions.md index 8d87515fb2..c722d4b5bb 100644 --- a/docs/how-to/view-transitions.md +++ b/docs/how-to/view-transitions.md @@ -204,5 +204,23 @@ function NavImage(props: { src: string; idx: number }) { } ``` +### 3. Using `viewTransition` for Custom Transition Styles + +You can further customize the transition by specifying an array of view transition types. These types are passed to document.startViewTransition() and allow you to apply targeted CSS animations. + +For example, you can set different animation styles like so: + +```tsx + + About + +``` + +When using this custom variation of the prop, React Router will pass the specified types to the underlying View Transitions API call, enabling your CSS to target these transition types and define custom animations. +[Read more about view transition types](https://developer.chrome.com/blog/view-transitions-update-io24#view-transition-types) + [view-transitions-api]: https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition [view-transitions-guide]: https://developer.chrome.com/docs/web-platform/view-transitions diff --git a/packages/react-router/__tests__/data-router-no-dom-test.tsx b/packages/react-router/__tests__/data-router-no-dom-test.tsx index b8125c7cea..a9168a7346 100644 --- a/packages/react-router/__tests__/data-router-no-dom-test.tsx +++ b/packages/react-router/__tests__/data-router-no-dom-test.tsx @@ -121,6 +121,7 @@ describe("RouterProvider works when no DOM APIs are available", () => { search: "", state: null, }, + opts: true, }); expect(warnSpy).toHaveBeenCalledTimes(1); @@ -298,4 +299,82 @@ describe("RouterProvider works when no DOM APIs are available", () => { `); }); + + it("supports viewTransitionTypes navigation", async () => { + let router = createMemoryRouter([ + { + path: "/", + Component: () => { + let navigate = useNavigate(); + return ; + }, + }, + { + path: "/foo", + loader: () => "FOO", + Component: () => { + let data = useLoaderData() as string; + return

{data}

; + }, + }, + ]); + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchInlineSnapshot(` + + `); + + let spy = jest.fn(); + let unsubscribe = router.subscribe(spy); + + await renderer.act(async () => { + router.navigate("/foo", { + viewTransition: { types: ["fade", "slide"] }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + tree = component.toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ FOO +

+ `); + + // First subscription call reflects the loading state without viewTransitionOpts + expect(spy.mock.calls[0][0].location.pathname).toBe("/"); + expect(spy.mock.calls[0][0].navigation.state).toBe("loading"); + expect(spy.mock.calls[0][0].navigation.location.pathname).toBe("/foo"); + expect(spy.mock.calls[0][1].viewTransitionOpts).toBeUndefined(); + + // Second subscription call reflects the idle state. + // Note: In a non-DOM environment, viewTransitionTypes are not included in viewTransitionOpts. + expect(spy.mock.calls[1][0].location.pathname).toBe("/foo"); + expect(spy.mock.calls[1][0].navigation.state).toBe("idle"); + expect(spy.mock.calls[1][1].viewTransitionOpts).toEqual({ + currentLocation: { + hash: "", + key: "default", + pathname: "/", + search: "", + state: null, + }, + nextLocation: { + hash: "", + key: expect.any(String), + pathname: "/foo", + search: "", + state: null, + }, + opts: { + types: ["fade", "slide"], + }, + }); + + unsubscribe(); + }); }); diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index abe532a8f2..7d978b5313 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -7861,6 +7861,57 @@ function testDomRouter( { state: "idle" }, ]); }); + + it("applies viewTransitionTypes when specified", async () => { + // Create a custom window with a spy on document.startViewTransition + let testWindow = getWindow("/"); + const startViewTransitionSpy = jest.fn((arg: any) => { + if (typeof arg === "function") { + throw new Error( + "Expected an options object, but received a function." + ); + } + // Assert that the options include the correct viewTransitionTypes. + expect(arg.types).toEqual(["fade", "slide"]); + // Execute the update callback to trigger the transition update. + arg.update(); + return { + ready: Promise.resolve(undefined), + finished: Promise.resolve(undefined), + updateCallbackDone: Promise.resolve(undefined), + skipTransition: () => {}, + }; + }); + testWindow.document.startViewTransition = startViewTransitionSpy; + + // Create a router with a Link that opts into view transitions and specifies viewTransitionTypes. + let router = createTestRouter( + [ + { + path: "/", + Component() { + return ( +
+ + /a + + +
+ ); + }, + children: [{ path: "a", Component: () =>

A

}], + }, + ], + { window: testWindow } + ); + + render(); + fireEvent.click(screen.getByText("/a")); + await waitFor(() => screen.getByText("A")); + + // Assert that document.startViewTransition was called once. + expect(startViewTransitionSpy).toHaveBeenCalledTimes(1); + }); }); }); } diff --git a/packages/react-router/__tests__/router/router-session-storage-test.ts b/packages/react-router/__tests__/router/router-session-storage-test.ts new file mode 100644 index 0000000000..85e433c706 --- /dev/null +++ b/packages/react-router/__tests__/router/router-session-storage-test.ts @@ -0,0 +1,213 @@ +// viewTransitionRegistry.test.ts +import { ViewTransitionOptions } from "../../lib/dom/global"; +import type { AppliedViewTransitionMap } from "../../lib/router/router"; +import { + restoreAppliedTransitions, + persistAppliedTransitions, + ROUTER_TRANSITIONS_STORAGE_KEY, +} from "../../lib/router/router"; + +describe("View Transition Registry persistence", () => { + let fakeStorage: Record; + let localFakeWindow: Window; + + // Create a fresh fakeStorage and fakeWindow before each test. + beforeEach(() => { + fakeStorage = {}; + localFakeWindow = { + sessionStorage: { + getItem: jest.fn((key: string) => fakeStorage[key] || null), + setItem: jest.fn((key: string, value: string) => { + fakeStorage[key] = value; + }), + clear: jest.fn(() => { + fakeStorage = {}; + }), + }, + } as unknown as Window; + jest.clearAllMocks(); + }); + + it("persists applied view transitions to sessionStorage", () => { + const transitions: AppliedViewTransitionMap = new Map(); + const innerMap = new Map(); + // Use a sample option that matches the expected type. + innerMap.set("/to", { types: ["fade"] }); + transitions.set("/from", innerMap); + + persistAppliedTransitions(localFakeWindow, transitions); + + // Verify that setItem was called using our expected key. + const setItemCalls = (localFakeWindow.sessionStorage.setItem as jest.Mock) + .mock.calls; + expect(setItemCalls.length).toBeGreaterThan(0); + const [keyUsed, valueUsed] = setItemCalls[0]; + const expected = JSON.stringify({ + "/from": { "/to": { types: ["fade"] } }, + }); + expect(keyUsed).toEqual(ROUTER_TRANSITIONS_STORAGE_KEY); + expect(valueUsed).toEqual(expected); + // Verify our fake storage was updated. + expect(fakeStorage[keyUsed]).toEqual(expected); + }); + + it("restores applied view transitions from sessionStorage", () => { + // Prepopulate fakeStorage using the module's key. + const jsonData = { "/from": { "/to": { types: ["fade"] } } }; + fakeStorage[ROUTER_TRANSITIONS_STORAGE_KEY] = JSON.stringify(jsonData); + + const transitions: AppliedViewTransitionMap = new Map(); + restoreAppliedTransitions(localFakeWindow, transitions); + + expect(transitions.size).toBe(1); + const inner = transitions.get("/from"); + expect(inner).toBeDefined(); + expect(inner?.size).toBe(1); + expect(inner?.get("/to")).toEqual({ types: ["fade"] }); + }); + + it("does nothing if sessionStorage is empty", () => { + (localFakeWindow.sessionStorage.getItem as jest.Mock).mockReturnValue(null); + const transitions: AppliedViewTransitionMap = new Map(); + restoreAppliedTransitions(localFakeWindow, transitions); + expect(transitions.size).toBe(0); + }); + + it("logs an error when sessionStorage.setItem fails", () => { + const error = new Error("Failed to set"); + (localFakeWindow.sessionStorage.setItem as jest.Mock).mockImplementation( + () => { + throw error; + } + ); + + const transitions: AppliedViewTransitionMap = new Map(); + const innerMap = new Map(); + innerMap.set("/to", { types: ["fade"] }); + transitions.set("/from", innerMap); + + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + persistAppliedTransitions(localFakeWindow, transitions); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Failed to save applied view transitions in sessionStorage" + ) + ); + consoleWarnSpy.mockRestore(); + }); + + describe("complex cases", () => { + // Persist test cases: an array where each item is [description, transitions, expected JSON string]. + const persistCases: [string, AppliedViewTransitionMap, string][] = [ + [ + "Single mapping", + new Map([["/from", new Map([["/to", { types: ["fade"] }]])]]), + JSON.stringify({ "/from": { "/to": { types: ["fade"] } } }), + ], + [ + "Multiple mappings for one 'from' key", + new Map([ + [ + "/from", + new Map([ + ["/to1", { types: ["slide"] }], + ["/to2", { types: ["fade"] }], + ]), + ], + ]), + JSON.stringify({ + "/from": { + "/to1": { types: ["slide"] }, + "/to2": { types: ["fade"] }, + }, + }), + ], + [ + "Multiple 'from' keys", + new Map([ + ["/from1", new Map([["/to", { types: ["fade"] }]])], + ["/from2", new Map([["/to", { types: ["slide"] }]])], + ]), + JSON.stringify({ + "/from1": { "/to": { types: ["fade"] } }, + "/from2": { "/to": { types: ["slide"] } }, + }), + ], + ]; + + test.each(persistCases)( + "persists applied view transitions correctly: %s", + (description, transitions, expected) => { + fakeStorage = {}; + jest.clearAllMocks(); + persistAppliedTransitions(localFakeWindow, transitions); + const stored = localFakeWindow.sessionStorage.getItem( + ROUTER_TRANSITIONS_STORAGE_KEY + ); + expect(stored).toEqual(expected); + } + ); + + // Restore test cases: an array where each item is [description, jsonData, expected transitions map]. + const restoreCases: [string, any, AppliedViewTransitionMap][] = [ + [ + "Single mapping", + { "/from": { "/to": { types: ["fade"] } } }, + new Map([["/from", new Map([["/to", { types: ["fade"] }]])]]), + ], + [ + "Multiple mappings for one 'from' key", + { + "/from": { + "/to1": { types: ["slide"] }, + "/to2": { types: ["fade"] }, + }, + }, + new Map([ + [ + "/from", + new Map([ + ["/to1", { types: ["slide"] }], + ["/to2", { types: ["fade"] }], + ]), + ], + ]), + ], + [ + "Multiple 'from' keys", + { + "/from1": { "/to": { types: ["fade"] } }, + "/from2": { "/to": { types: ["slide"] } }, + }, + new Map([ + ["/from1", new Map([["/to", { types: ["fade"] }]])], + ["/from2", new Map([["/to", { types: ["slide"] }]])], + ]), + ], + ]; + + test.each(restoreCases)( + "restores applied view transitions correctly: %s", + (description, jsonData, expected) => { + fakeStorage = {}; + // Prepopulate fakeStorage using the module's key. + fakeStorage[ROUTER_TRANSITIONS_STORAGE_KEY] = JSON.stringify(jsonData); + + const transitions: AppliedViewTransitionMap = new Map(); + restoreAppliedTransitions(localFakeWindow, transitions); + + expect(transitions.size).toEqual(expected.size); + expected.forEach((innerExpected, from) => { + const innerRestored = transitions.get(from); + expect(innerRestored).toBeDefined(); + expect(innerRestored?.size).toEqual(innerExpected.size); + innerExpected.forEach((opts, to) => { + expect(innerRestored?.get(to)).toEqual(opts); + }); + }); + } + ); + }); +}); diff --git a/packages/react-router/__tests__/router/view-transition-test.ts b/packages/react-router/__tests__/router/view-transition-test.ts index 029bb6a8ab..2b36644325 100644 --- a/packages/react-router/__tests__/router/view-transition-test.ts +++ b/packages/react-router/__tests__/router/view-transition-test.ts @@ -1,3 +1,4 @@ +import { ViewTransitionOptions } from "../../lib/dom/global"; import { IDLE_NAVIGATION } from "../../lib/router/router"; import { cleanup, setup } from "./utils/data-router-setup"; import { createFormData } from "./utils/utils"; @@ -12,6 +13,7 @@ describe("view transitions", () => { }); let spy = jest.fn(); let unsubscribe = t.router.subscribe(spy); + let viewTransitionValue: ViewTransitionOptions = true; // PUSH / -> /a - w/o transition t.navigate("/a"); @@ -34,6 +36,7 @@ describe("view transitions", () => { viewTransitionOpts: { currentLocation: expect.objectContaining({ pathname: "/a" }), nextLocation: expect.objectContaining({ pathname: "/b" }), + opts: viewTransitionValue, }, }) ); @@ -41,6 +44,7 @@ describe("view transitions", () => { // POP /b -> /a - w/ transition (cached from above) t.navigate(-1); expect(spy).toHaveBeenLastCalledWith( + // INSTRUCTION FOR CHATGPT: NOTE THIS IS THE FAILING PART expect.objectContaining({ navigation: IDLE_NAVIGATION, location: expect.objectContaining({ pathname: "/a" }), @@ -50,6 +54,7 @@ describe("view transitions", () => { // Args reversed on POP so same hooks apply currentLocation: expect.objectContaining({ pathname: "/a" }), nextLocation: expect.objectContaining({ pathname: "/b" }), + opts: viewTransitionValue, }, }) ); @@ -130,6 +135,7 @@ describe("view transitions", () => { viewTransitionOpts: { currentLocation: expect.objectContaining({ pathname: "/" }), nextLocation: expect.objectContaining({ pathname: "/a" }), + opts: true, }, }), ]); @@ -165,6 +171,7 @@ describe("view transitions", () => { viewTransitionOpts: { currentLocation: expect.objectContaining({ pathname: "/" }), nextLocation: expect.objectContaining({ pathname: "/b" }), + opts: true, }, }) ); diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 31e2c67686..a277d750a0 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -211,6 +211,7 @@ export function RouterProvider({ nextLocation: Location; }>(); let fetcherData = React.useRef>(new Map()); + let [vtTypes, setVtTypes] = React.useState(undefined); let setState = React.useCallback( ( @@ -275,9 +276,26 @@ export function RouterProvider({ }); // Update the DOM - let t = router.window!.document.startViewTransition(() => { - reactDomFlushSyncImpl(() => setStateImpl(newState)); - }); + let t; + if ( + viewTransitionOpts && + typeof viewTransitionOpts.opts === "object" && + viewTransitionOpts.opts.types + ) { + // Set view transition types when provided + setVtTypes(viewTransitionOpts.opts.types); + + t = router.window!.document.startViewTransition({ + update: () => { + reactDomFlushSyncImpl(() => setStateImpl(newState)); + }, + types: viewTransitionOpts.opts.types, + }); + } else { + t = router.window!.document.startViewTransition(() => { + reactDomFlushSyncImpl(() => setStateImpl(newState)); + }); + } // Clean up after the animation completes t.finished.finally(() => { @@ -306,6 +324,13 @@ export function RouterProvider({ }); } else { // Completed navigation update with opted-in view transitions, let 'er rip + if ( + viewTransitionOpts && + typeof viewTransitionOpts.opts === "object" && + viewTransitionOpts.opts.types + ) { + setVtTypes(viewTransitionOpts.opts.types); + } setPendingState(newState); setVtContext({ isTransitioning: true, @@ -337,10 +362,21 @@ export function RouterProvider({ if (renderDfd && pendingState && router.window) { let newState = pendingState; let renderPromise = renderDfd.promise; - let transition = router.window.document.startViewTransition(async () => { - React.startTransition(() => setStateImpl(newState)); - await renderPromise; - }); + let transition; + if (vtTypes) { + transition = router.window.document.startViewTransition({ + update: async () => { + React.startTransition(() => setStateImpl(newState)); + await renderPromise; + }, + types: vtTypes, + }); + } else { + transition = router.window.document.startViewTransition(async () => { + React.startTransition(() => setStateImpl(newState)); + await renderPromise; + }); + } transition.finished.finally(() => { setRenderDfd(undefined); setTransition(undefined); @@ -349,7 +385,7 @@ export function RouterProvider({ }); setTransition(transition); } - }, [pendingState, renderDfd, router.window]); + }, [pendingState, renderDfd, router.window, vtTypes]); // When the new location finally renders and is committed to the DOM, this // effect will run to resolve the transition diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index facfe7b78b..232f54f606 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -19,6 +19,7 @@ import type { LazyRouteFunction, TrackedPromise, } from "./router/utils"; +import { ViewTransitionOptions } from "./dom/global"; // Create react-specific types from the agnostic types in @remix-run/router to // export from react-router @@ -108,6 +109,7 @@ export type ViewTransitionContextObject = flushSync: boolean; currentLocation: Location; nextLocation: Location; + viewTransitionTypes?: string[]; }; export const ViewTransitionContext = @@ -138,8 +140,11 @@ export interface NavigateOptions { relative?: RelativeRoutingType; /** Wraps the initial state update for this navigation in a {@link https://react.dev/reference/react-dom/flushSync ReactDOM.flushSync} call instead of the default {@link https://react.dev/reference/react/startTransition React.startTransition} */ flushSync?: boolean; - /** Enables a {@link https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API View Transition} for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the {@link https://api.reactrouter.com/v7/functions/react_router.useViewTransitionState.html useViewTransitionState()} hook. */ - viewTransition?: boolean; + /** + * Enables a {@link https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API View Transition} for this navigation by wrapping the final state update in `document.startViewTransition()`. + * If you need to apply specific styles for this view transition, you will also need to leverage the {@link https://api.reactrouter.com/v7/functions/react_router.useViewTransitionState.html useViewTransitionState()} hook. + */ + viewTransition?: ViewTransitionOptions; } /** diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 24ec0a6945..1b3d69fbbf 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -2,6 +2,7 @@ import { warning } from "../router/history"; import type { RelativeRoutingType } from "../router/router"; import type { FormEncType, HTMLFormMethod } from "../router/utils"; import { stripBasename } from "../router/utils"; +import { ViewTransitionOptions } from "./global"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; @@ -226,9 +227,12 @@ export interface SubmitOptions extends FetcherSubmitOptions { navigate?: boolean; /** - * Enable view transitions on this submission navigation + * Enable view transitions on this submission navigation. + * When set to true, the default transition is applied. + * Alternatively, an object of type ViewTransitionOptions can be provided + * to configure additional options. */ - viewTransition?: boolean; + viewTransition?: ViewTransitionOptions; } const supportedFormEncTypes: Set = new Set([ diff --git a/packages/react-router/lib/dom/global.ts b/packages/react-router/lib/dom/global.ts index 58ec4ef4cf..2136b8ac83 100644 --- a/packages/react-router/lib/dom/global.ts +++ b/packages/react-router/lib/dom/global.ts @@ -26,6 +26,16 @@ export interface ViewTransition { skipTransition(): void; } +export type ViewTransitionOptions = + | boolean + | { + /** + * An array of transition type strings (e.g. "slide", "forwards", "backwards") + * that will be applied to the navigation. + */ + types?: string[]; + }; + declare global { // TODO: v7 - Can this go away in favor of "just use remix"? var __staticRouterHydrationData: HydrationState | undefined; @@ -33,6 +43,10 @@ declare global { var __reactRouterVersion: string; interface Document { startViewTransition(cb: () => Promise | void): ViewTransition; + startViewTransition(options: { + update: () => Promise | void; + types: string[]; + }): ViewTransition; } var __reactRouterContext: WindowReactRouterContext | undefined; var __reactRouterManifest: AssetsManifest | undefined; diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 821b6a6457..c8c3070253 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -90,6 +90,7 @@ import { useRouteId, } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; +import { ViewTransitionOptions } from "./global"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff @@ -521,17 +522,22 @@ export interface LinkProps to: To; /** - Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for this navigation. - - ```jsx - - Click me - - ``` - - To apply specific styles for the transition, see {@link useViewTransitionState} + * Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for this navigation. + * + * When specified as a boolean, the default transition is applied. + * Alternatively, you can pass an object to configure additional options (e.g. transition types). + * + * Example: + * + * + * Click me + * + * + * + * Click me + * */ - viewTransition?: boolean; + viewTransition?: ViewTransitionOptions; } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; @@ -1008,12 +1014,12 @@ export interface FormProps extends SharedFormProps { state?: any; /** - * Enables a [View - * Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) - * for this navigation. To apply specific styles during the transition see - * {@link useViewTransitionState}. + * Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) + * for this navigation. When specified as a boolean, the default transition is applied. + * Alternatively, you can pass an object to configure additional options (e.g. transition types). + * To apply specific styles during the transition, see {@link useViewTransitionState}. */ - viewTransition?: boolean; + viewTransition?: ViewTransitionOptions; } type HTMLSubmitEvent = React.BaseSyntheticEvent< @@ -1290,7 +1296,7 @@ export function useLinkClickHandler( state?: any; preventScrollReset?: boolean; relative?: RelativeRoutingType; - viewTransition?: boolean; + viewTransition?: ViewTransitionOptions; } = {} ): (event: React.MouseEvent) => void { let navigate = useNavigate(); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 84f747578e..597d534e7e 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,3 +1,4 @@ +import { ViewTransitionOptions } from "../dom/global"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -415,6 +416,7 @@ export interface StaticHandler { type ViewTransitionOpts = { currentLocation: Location; nextLocation: Location; + opts?: ViewTransitionOptions; }; /** @@ -464,7 +466,7 @@ type BaseNavigateOptions = BaseNavigateOrFetchOptions & { replace?: boolean; state?: any; fromRouteId?: string; - viewTransition?: boolean; + viewTransition?: ViewTransitionOptions; }; // Only allowed for submission navigations @@ -768,12 +770,19 @@ const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({ hasErrorBoundary: Boolean(route.hasErrorBoundary), }); -const TRANSITIONS_STORAGE_KEY = "remix-router-transitions"; +export const ROUTER_TRANSITIONS_STORAGE_KEY = "remix-router-transitions"; // Flag used on new `loaderData` to indicate that we do not want to preserve // any prior loader data from the throwing route in `mergeLoaderData` const ResetLoaderDataSymbol = Symbol("ResetLoaderData"); +// The applied view transitions map stores, for each source pathname (string), +// a mapping from destination pathnames (string) to the view transition option that was used. +export type AppliedViewTransitionMap = Map< + string, + Map +>; + //#endregion //////////////////////////////////////////////////////////////////////////////// @@ -943,13 +952,14 @@ 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; + // Should the current navigation enable document.startViewTransition? (includes custom opts when provided) + let pendingViewTransition: ViewTransitionOptions = false; - // Store applied view transitions so we can apply them on POP - let appliedViewTransitions: Map> = new Map< + // Store, for each "from" pathname, a mapping of "to" pathnames to the viewTransition option. + // This registry enables us to reapply the appropriate view transition when handling a POP navigation. + let appliedViewTransitions: AppliedViewTransitionMap = new Map< string, - Set + Map >(); // Cleanup function for persisting applied transitions to sessionStorage @@ -1261,33 +1271,44 @@ export function createRouter(init: RouterInit): Router { // On POP, enable transitions if they were enabled on the original navigation if (pendingAction === NavigationType.Pop) { - // Forward takes precedence so they behave like the original navigation - let priorPaths = appliedViewTransitions.get(state.location.pathname); - if (priorPaths && priorPaths.has(location.pathname)) { + // Try to get the transition mapping from the current (source) location. + let vTRegistry = appliedViewTransitions.get(state.location.pathname); + if (vTRegistry && vTRegistry.has(location.pathname)) { + const opts = vTRegistry.get(location.pathname); viewTransitionOpts = { currentLocation: state.location, nextLocation: location, + opts, }; } 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, - }; + // Otherwise, check the reverse mapping from the destination side. + let vTRegistry = appliedViewTransitions.get(location.pathname)!; + if (vTRegistry.has(state.location.pathname)) { + const opts = vTRegistry.get(state.location.pathname); + viewTransitionOpts = { + currentLocation: location, + nextLocation: state.location, + opts, + }; + } } - } 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); + } else if (pendingViewTransition) { + // For non-POP navigations (PUSH/REPLACE) when viewTransition is enabled: + // Retrieve the existing transition mapping for the source pathname. + let vTRegistry = appliedViewTransitions.get(state.location.pathname); + if (!vTRegistry) { + // If no mapping exists, create one. + vTRegistry = new Map(); + appliedViewTransitions.set(state.location.pathname, vTRegistry); } + // Record that navigating from the current pathname to the next uses the pending view transition option. + vTRegistry.set(location.pathname, pendingViewTransition); + + // Set the view transition options for the current navigation. viewTransitionOpts = { currentLocation: state.location, nextLocation: location, + opts: pendingViewTransition, // Retains the full option (boolean or object) }; } @@ -1317,7 +1338,7 @@ export function createRouter(init: RouterInit): Router { // Reset stateful navigation vars pendingAction = NavigationType.Pop; pendingPreventScrollReset = false; - pendingViewTransitionEnabled = false; + pendingViewTransition = false; isUninterruptedRevalidation = false; isRevalidationRequired = false; pendingRevalidationDfd?.resolve(); @@ -1426,7 +1447,7 @@ export function createRouter(init: RouterInit): Router { pendingError: error, preventScrollReset, replace: opts && opts.replace, - enableViewTransition: opts && opts.viewTransition, + viewTransition: opts && opts.viewTransition, flushSync, }); } @@ -1480,7 +1501,7 @@ export function createRouter(init: RouterInit): Router { { overrideNavigation: state.navigation, // Proxy through any rending view transition - enableViewTransition: pendingViewTransitionEnabled === true, + viewTransition: pendingViewTransition, } ); return promise; @@ -1501,7 +1522,7 @@ export function createRouter(init: RouterInit): Router { startUninterruptedRevalidation?: boolean; preventScrollReset?: boolean; replace?: boolean; - enableViewTransition?: boolean; + viewTransition?: ViewTransitionOptions; flushSync?: boolean; } ): Promise { @@ -1519,7 +1540,8 @@ export function createRouter(init: RouterInit): Router { saveScrollPosition(state.location, state.matches); pendingPreventScrollReset = (opts && opts.preventScrollReset) === true; - pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true; + pendingViewTransition = + opts && opts.viewTransition ? opts.viewTransition : false; let routesToUse = inFlightDataRoutes || dataRoutes; let loadingNavigation = opts && opts.overrideNavigation; @@ -2701,9 +2723,7 @@ export function createRouter(init: RouterInit): Router { }, // Preserve these flags across redirects preventScrollReset: preventScrollReset || pendingPreventScrollReset, - enableViewTransition: isNavigation - ? pendingViewTransitionEnabled - : undefined, + viewTransition: isNavigation ? pendingViewTransition : undefined, }); } else { // If we have a navigation submission, we will preserve it through the @@ -2718,9 +2738,7 @@ export function createRouter(init: RouterInit): Router { fetcherSubmission, // Preserve these flags across redirects preventScrollReset: preventScrollReset || pendingPreventScrollReset, - enableViewTransition: isNavigation - ? pendingViewTransitionEnabled - : undefined, + viewTransition: isNavigation ? pendingViewTransition : undefined, }); } } @@ -5608,39 +5626,49 @@ function getDoneFetcher(data: Fetcher["data"]): FetcherStates["Idle"] { return fetcher; } -function restoreAppliedTransitions( +export function restoreAppliedTransitions( _window: Window, - transitions: Map> + transitions: AppliedViewTransitionMap ) { try { - let sessionPositions = _window.sessionStorage.getItem( - TRANSITIONS_STORAGE_KEY + const sessionData = _window.sessionStorage.getItem( + ROUTER_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 || [])); + if (sessionData) { + // Parse the JSON object into the expected nested structure. + const json: Record< + string, + Record + > = JSON.parse(sessionData); + for (const [from, toOptsObj] of Object.entries(json)) { + const toOptsMap = new Map(); + for (const [to, opts] of Object.entries(toOptsObj)) { + toOptsMap.set(to, opts); } + transitions.set(from, toOptsMap); } } } catch (e) { - // no-op, use default empty object + // On error, simply do nothing. } } -function persistAppliedTransitions( +export function persistAppliedTransitions( _window: Window, - transitions: Map> + transitions: AppliedViewTransitionMap ) { if (transitions.size > 0) { - let json: Record = {}; - for (let [k, v] of transitions) { - json[k] = [...v]; + // Convert the nested Map structure into a plain object. + const json: Record> = {}; + for (const [from, toOptsMap] of transitions.entries()) { + json[from] = {}; + for (const [to, opts] of toOptsMap.entries()) { + json[from][to] = opts; + } } try { _window.sessionStorage.setItem( - TRANSITIONS_STORAGE_KEY, + ROUTER_TRANSITIONS_STORAGE_KEY, JSON.stringify(json) ); } catch (error) { From cd9c6c719084c17dbac329cbb3367a0d0f34ab0f Mon Sep 17 00:00:00 2001 From: Liran Sharir Date: Thu, 13 Feb 2025 10:47:33 -0500 Subject: [PATCH 2/2] pnpm lint --- .../__tests__/router/router-session-storage-test.ts | 2 +- packages/react-router/__tests__/router/view-transition-test.ts | 2 +- packages/react-router/lib/context.ts | 2 +- packages/react-router/lib/dom/dom.ts | 2 +- packages/react-router/lib/dom/lib.tsx | 2 +- packages/react-router/lib/router/router.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-router/__tests__/router/router-session-storage-test.ts b/packages/react-router/__tests__/router/router-session-storage-test.ts index 85e433c706..f075ce92d6 100644 --- a/packages/react-router/__tests__/router/router-session-storage-test.ts +++ b/packages/react-router/__tests__/router/router-session-storage-test.ts @@ -1,5 +1,5 @@ // viewTransitionRegistry.test.ts -import { ViewTransitionOptions } from "../../lib/dom/global"; +import type { ViewTransitionOptions } from "../../lib/dom/global"; import type { AppliedViewTransitionMap } from "../../lib/router/router"; import { restoreAppliedTransitions, diff --git a/packages/react-router/__tests__/router/view-transition-test.ts b/packages/react-router/__tests__/router/view-transition-test.ts index 2b36644325..0215e1c7db 100644 --- a/packages/react-router/__tests__/router/view-transition-test.ts +++ b/packages/react-router/__tests__/router/view-transition-test.ts @@ -1,4 +1,4 @@ -import { ViewTransitionOptions } from "../../lib/dom/global"; +import type { ViewTransitionOptions } from "../../lib/dom/global"; import { IDLE_NAVIGATION } from "../../lib/router/router"; import { cleanup, setup } from "./utils/data-router-setup"; import { createFormData } from "./utils/utils"; diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 232f54f606..b8e0f9a9a2 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -19,7 +19,7 @@ import type { LazyRouteFunction, TrackedPromise, } from "./router/utils"; -import { ViewTransitionOptions } from "./dom/global"; +import type { ViewTransitionOptions } from "./dom/global"; // Create react-specific types from the agnostic types in @remix-run/router to // export from react-router diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 1b3d69fbbf..d5b35be7ef 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -2,7 +2,7 @@ import { warning } from "../router/history"; import type { RelativeRoutingType } from "../router/router"; import type { FormEncType, HTMLFormMethod } from "../router/utils"; import { stripBasename } from "../router/utils"; -import { ViewTransitionOptions } from "./global"; +import type { ViewTransitionOptions } from "./global"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index c8c3070253..12e0dd7fb3 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -90,7 +90,7 @@ import { useRouteId, } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; -import { ViewTransitionOptions } from "./global"; +import type { ViewTransitionOptions } from "./global"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 597d534e7e..5da69a8d81 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,4 +1,4 @@ -import { ViewTransitionOptions } from "../dom/global"; +import type { ViewTransitionOptions } from "../dom/global"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType,