Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ce18255

Browse files
committedFeb 10, 2025·
feat: add viewTransitionTypes support for selective view transitions
1 parent ac399b7 commit ce18255

File tree

11 files changed

+540
-79
lines changed

11 files changed

+540
-79
lines changed
 

‎docs/how-to/view-transitions.md

+18
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,23 @@ function NavImage(props: { src: string; idx: number }) {
204204
}
205205
```
206206

207+
### 3. Using `viewTransition` for Custom Transition Styles
208+
209+
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.
210+
211+
For example, you can set different animation styles like so:
212+
213+
```tsx
214+
<Link
215+
to="/about"
216+
viewTransition={{ types: ["fade", "slide"] }}
217+
>
218+
About
219+
</Link>
220+
```
221+
222+
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.
223+
[Read more about view transition types](https://developer.chrome.com/blog/view-transitions-update-io24#view-transition-types)
224+
207225
[view-transitions-api]: https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition
208226
[view-transitions-guide]: https://developer.chrome.com/docs/web-platform/view-transitions

‎packages/react-router/__tests__/data-router-no-dom-test.tsx

+79
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ describe("RouterProvider works when no DOM APIs are available", () => {
121121
search: "",
122122
state: null,
123123
},
124+
opts: true,
124125
});
125126

126127
expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -298,4 +299,82 @@ describe("RouterProvider works when no DOM APIs are available", () => {
298299
</button>
299300
`);
300301
});
302+
303+
it("supports viewTransitionTypes navigation", async () => {
304+
let router = createMemoryRouter([
305+
{
306+
path: "/",
307+
Component: () => {
308+
let navigate = useNavigate();
309+
return <button onClick={() => navigate("/foo")}>Go to /foo</button>;
310+
},
311+
},
312+
{
313+
path: "/foo",
314+
loader: () => "FOO",
315+
Component: () => {
316+
let data = useLoaderData() as string;
317+
return <h1>{data}</h1>;
318+
},
319+
},
320+
]);
321+
const component = renderer.create(<RouterProvider router={router} />);
322+
let tree = component.toJSON();
323+
expect(tree).toMatchInlineSnapshot(`
324+
<button
325+
onClick={[Function]}
326+
>
327+
Go to /foo
328+
</button>
329+
`);
330+
331+
let spy = jest.fn();
332+
let unsubscribe = router.subscribe(spy);
333+
334+
await renderer.act(async () => {
335+
router.navigate("/foo", {
336+
viewTransition: { types: ["fade", "slide"] },
337+
});
338+
await new Promise((resolve) => setTimeout(resolve, 0));
339+
});
340+
341+
tree = component.toJSON();
342+
expect(tree).toMatchInlineSnapshot(`
343+
<h1>
344+
FOO
345+
</h1>
346+
`);
347+
348+
// First subscription call reflects the loading state without viewTransitionOpts
349+
expect(spy.mock.calls[0][0].location.pathname).toBe("/");
350+
expect(spy.mock.calls[0][0].navigation.state).toBe("loading");
351+
expect(spy.mock.calls[0][0].navigation.location.pathname).toBe("/foo");
352+
expect(spy.mock.calls[0][1].viewTransitionOpts).toBeUndefined();
353+
354+
// Second subscription call reflects the idle state.
355+
// Note: In a non-DOM environment, viewTransitionTypes are not included in viewTransitionOpts.
356+
expect(spy.mock.calls[1][0].location.pathname).toBe("/foo");
357+
expect(spy.mock.calls[1][0].navigation.state).toBe("idle");
358+
expect(spy.mock.calls[1][1].viewTransitionOpts).toEqual({
359+
currentLocation: {
360+
hash: "",
361+
key: "default",
362+
pathname: "/",
363+
search: "",
364+
state: null,
365+
},
366+
nextLocation: {
367+
hash: "",
368+
key: expect.any(String),
369+
pathname: "/foo",
370+
search: "",
371+
state: null,
372+
},
373+
opts: {
374+
types: ["fade", "slide"],
375+
},
376+
});
377+
378+
unsubscribe();
379+
});
301380
});

‎packages/react-router/__tests__/dom/data-browser-router-test.tsx

+51
Original file line numberDiff line numberDiff line change
@@ -7861,6 +7861,57 @@ function testDomRouter(
78617861
{ state: "idle" },
78627862
]);
78637863
});
7864+
7865+
it("applies viewTransitionTypes when specified", async () => {
7866+
// Create a custom window with a spy on document.startViewTransition
7867+
let testWindow = getWindow("/");
7868+
const startViewTransitionSpy = jest.fn((arg: any) => {
7869+
if (typeof arg === "function") {
7870+
throw new Error(
7871+
"Expected an options object, but received a function."
7872+
);
7873+
}
7874+
// Assert that the options include the correct viewTransitionTypes.
7875+
expect(arg.types).toEqual(["fade", "slide"]);
7876+
// Execute the update callback to trigger the transition update.
7877+
arg.update();
7878+
return {
7879+
ready: Promise.resolve(undefined),
7880+
finished: Promise.resolve(undefined),
7881+
updateCallbackDone: Promise.resolve(undefined),
7882+
skipTransition: () => {},
7883+
};
7884+
});
7885+
testWindow.document.startViewTransition = startViewTransitionSpy;
7886+
7887+
// Create a router with a Link that opts into view transitions and specifies viewTransitionTypes.
7888+
let router = createTestRouter(
7889+
[
7890+
{
7891+
path: "/",
7892+
Component() {
7893+
return (
7894+
<div>
7895+
<Link to="/a" viewTransition={{ types: ["fade", "slide"] }}>
7896+
/a
7897+
</Link>
7898+
<Outlet />
7899+
</div>
7900+
);
7901+
},
7902+
children: [{ path: "a", Component: () => <h1>A</h1> }],
7903+
},
7904+
],
7905+
{ window: testWindow }
7906+
);
7907+
7908+
render(<RouterProvider router={router} />);
7909+
fireEvent.click(screen.getByText("/a"));
7910+
await waitFor(() => screen.getByText("A"));
7911+
7912+
// Assert that document.startViewTransition was called once.
7913+
expect(startViewTransitionSpy).toHaveBeenCalledTimes(1);
7914+
});
78647915
});
78657916
});
78667917
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// viewTransitionRegistry.test.ts
2+
import { ViewTransitionOptions } from "../../lib/dom/global";
3+
import type { AppliedViewTransitionMap } from "../../lib/router/router";
4+
import {
5+
restoreAppliedTransitions,
6+
persistAppliedTransitions,
7+
ROUTER_TRANSITIONS_STORAGE_KEY,
8+
} from "../../lib/router/router";
9+
10+
describe("View Transition Registry persistence", () => {
11+
let fakeStorage: Record<string, string>;
12+
let localFakeWindow: Window;
13+
14+
// Create a fresh fakeStorage and fakeWindow before each test.
15+
beforeEach(() => {
16+
fakeStorage = {};
17+
localFakeWindow = {
18+
sessionStorage: {
19+
getItem: jest.fn((key: string) => fakeStorage[key] || null),
20+
setItem: jest.fn((key: string, value: string) => {
21+
fakeStorage[key] = value;
22+
}),
23+
clear: jest.fn(() => {
24+
fakeStorage = {};
25+
}),
26+
},
27+
} as unknown as Window;
28+
jest.clearAllMocks();
29+
});
30+
31+
it("persists applied view transitions to sessionStorage", () => {
32+
const transitions: AppliedViewTransitionMap = new Map();
33+
const innerMap = new Map<string, ViewTransitionOptions>();
34+
// Use a sample option that matches the expected type.
35+
innerMap.set("/to", { types: ["fade"] });
36+
transitions.set("/from", innerMap);
37+
38+
persistAppliedTransitions(localFakeWindow, transitions);
39+
40+
// Verify that setItem was called using our expected key.
41+
const setItemCalls = (localFakeWindow.sessionStorage.setItem as jest.Mock)
42+
.mock.calls;
43+
expect(setItemCalls.length).toBeGreaterThan(0);
44+
const [keyUsed, valueUsed] = setItemCalls[0];
45+
const expected = JSON.stringify({
46+
"/from": { "/to": { types: ["fade"] } },
47+
});
48+
expect(keyUsed).toEqual(ROUTER_TRANSITIONS_STORAGE_KEY);
49+
expect(valueUsed).toEqual(expected);
50+
// Verify our fake storage was updated.
51+
expect(fakeStorage[keyUsed]).toEqual(expected);
52+
});
53+
54+
it("restores applied view transitions from sessionStorage", () => {
55+
// Prepopulate fakeStorage using the module's key.
56+
const jsonData = { "/from": { "/to": { types: ["fade"] } } };
57+
fakeStorage[ROUTER_TRANSITIONS_STORAGE_KEY] = JSON.stringify(jsonData);
58+
59+
const transitions: AppliedViewTransitionMap = new Map();
60+
restoreAppliedTransitions(localFakeWindow, transitions);
61+
62+
expect(transitions.size).toBe(1);
63+
const inner = transitions.get("/from");
64+
expect(inner).toBeDefined();
65+
expect(inner?.size).toBe(1);
66+
expect(inner?.get("/to")).toEqual({ types: ["fade"] });
67+
});
68+
69+
it("does nothing if sessionStorage is empty", () => {
70+
(localFakeWindow.sessionStorage.getItem as jest.Mock).mockReturnValue(null);
71+
const transitions: AppliedViewTransitionMap = new Map();
72+
restoreAppliedTransitions(localFakeWindow, transitions);
73+
expect(transitions.size).toBe(0);
74+
});
75+
76+
it("logs an error when sessionStorage.setItem fails", () => {
77+
const error = new Error("Failed to set");
78+
(localFakeWindow.sessionStorage.setItem as jest.Mock).mockImplementation(
79+
() => {
80+
throw error;
81+
}
82+
);
83+
84+
const transitions: AppliedViewTransitionMap = new Map();
85+
const innerMap = new Map<string, ViewTransitionOptions>();
86+
innerMap.set("/to", { types: ["fade"] });
87+
transitions.set("/from", innerMap);
88+
89+
const consoleWarnSpy = jest
90+
.spyOn(console, "warn")
91+
.mockImplementation(() => {});
92+
persistAppliedTransitions(localFakeWindow, transitions);
93+
expect(consoleWarnSpy).toHaveBeenCalledWith(
94+
expect.stringContaining(
95+
"Failed to save applied view transitions in sessionStorage"
96+
)
97+
);
98+
consoleWarnSpy.mockRestore();
99+
});
100+
101+
describe("complex cases", () => {
102+
// Persist test cases: an array where each item is [description, transitions, expected JSON string].
103+
const persistCases: [string, AppliedViewTransitionMap, string][] = [
104+
[
105+
"Single mapping",
106+
new Map([["/from", new Map([["/to", { types: ["fade"] }]])]]),
107+
JSON.stringify({ "/from": { "/to": { types: ["fade"] } } }),
108+
],
109+
[
110+
"Multiple mappings for one 'from' key",
111+
new Map([
112+
[
113+
"/from",
114+
new Map([
115+
["/to1", { types: ["slide"] }],
116+
["/to2", { types: ["fade"] }],
117+
]),
118+
],
119+
]),
120+
JSON.stringify({
121+
"/from": {
122+
"/to1": { types: ["slide"] },
123+
"/to2": { types: ["fade"] },
124+
},
125+
}),
126+
],
127+
[
128+
"Multiple 'from' keys",
129+
new Map([
130+
["/from1", new Map([["/to", { types: ["fade"] }]])],
131+
["/from2", new Map([["/to", { types: ["slide"] }]])],
132+
]),
133+
JSON.stringify({
134+
"/from1": { "/to": { types: ["fade"] } },
135+
"/from2": { "/to": { types: ["slide"] } },
136+
}),
137+
],
138+
];
139+
140+
test.each(persistCases)(
141+
"persists applied view transitions correctly: %s",
142+
(description, transitions, expected) => {
143+
fakeStorage = {};
144+
jest.clearAllMocks();
145+
persistAppliedTransitions(localFakeWindow, transitions);
146+
const stored = localFakeWindow.sessionStorage.getItem(
147+
ROUTER_TRANSITIONS_STORAGE_KEY
148+
);
149+
expect(stored).toEqual(expected);
150+
}
151+
);
152+
153+
// Restore test cases: an array where each item is [description, jsonData, expected transitions map].
154+
const restoreCases: [string, any, AppliedViewTransitionMap][] = [
155+
[
156+
"Single mapping",
157+
{ "/from": { "/to": { types: ["fade"] } } },
158+
new Map([["/from", new Map([["/to", { types: ["fade"] }]])]]),
159+
],
160+
[
161+
"Multiple mappings for one 'from' key",
162+
{
163+
"/from": {
164+
"/to1": { types: ["slide"] },
165+
"/to2": { types: ["fade"] },
166+
},
167+
},
168+
new Map([
169+
[
170+
"/from",
171+
new Map([
172+
["/to1", { types: ["slide"] }],
173+
["/to2", { types: ["fade"] }],
174+
]),
175+
],
176+
]),
177+
],
178+
[
179+
"Multiple 'from' keys",
180+
{
181+
"/from1": { "/to": { types: ["fade"] } },
182+
"/from2": { "/to": { types: ["slide"] } },
183+
},
184+
new Map([
185+
["/from1", new Map([["/to", { types: ["fade"] }]])],
186+
["/from2", new Map([["/to", { types: ["slide"] }]])],
187+
]),
188+
],
189+
];
190+
191+
test.each(restoreCases)(
192+
"restores applied view transitions correctly: %s",
193+
(description, jsonData, expected) => {
194+
fakeStorage = {};
195+
// Prepopulate fakeStorage using the module's key.
196+
fakeStorage[ROUTER_TRANSITIONS_STORAGE_KEY] = JSON.stringify(jsonData);
197+
198+
const transitions: AppliedViewTransitionMap = new Map();
199+
restoreAppliedTransitions(localFakeWindow, transitions);
200+
201+
expect(transitions.size).toEqual(expected.size);
202+
expected.forEach((innerExpected, from) => {
203+
const innerRestored = transitions.get(from);
204+
expect(innerRestored).toBeDefined();
205+
expect(innerRestored?.size).toEqual(innerExpected.size);
206+
innerExpected.forEach((opts, to) => {
207+
expect(innerRestored?.get(to)).toEqual(opts);
208+
});
209+
});
210+
}
211+
);
212+
});
213+
});

‎packages/react-router/__tests__/router/view-transition-test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ViewTransitionOptions } from "../../lib/dom/global";
12
import { IDLE_NAVIGATION } from "../../lib/router/router";
23
import { cleanup, setup } from "./utils/data-router-setup";
34
import { createFormData } from "./utils/utils";
@@ -12,6 +13,7 @@ describe("view transitions", () => {
1213
});
1314
let spy = jest.fn();
1415
let unsubscribe = t.router.subscribe(spy);
16+
let viewTransitionValue: ViewTransitionOptions = true;
1517

1618
// PUSH / -> /a - w/o transition
1719
t.navigate("/a");
@@ -34,13 +36,15 @@ describe("view transitions", () => {
3436
viewTransitionOpts: {
3537
currentLocation: expect.objectContaining({ pathname: "/a" }),
3638
nextLocation: expect.objectContaining({ pathname: "/b" }),
39+
opts: viewTransitionValue,
3740
},
3841
})
3942
);
4043

4144
// POP /b -> /a - w/ transition (cached from above)
4245
t.navigate(-1);
4346
expect(spy).toHaveBeenLastCalledWith(
47+
// INSTRUCTION FOR CHATGPT: NOTE THIS IS THE FAILING PART
4448
expect.objectContaining({
4549
navigation: IDLE_NAVIGATION,
4650
location: expect.objectContaining({ pathname: "/a" }),
@@ -50,6 +54,7 @@ describe("view transitions", () => {
5054
// Args reversed on POP so same hooks apply
5155
currentLocation: expect.objectContaining({ pathname: "/a" }),
5256
nextLocation: expect.objectContaining({ pathname: "/b" }),
57+
opts: viewTransitionValue,
5358
},
5459
})
5560
);
@@ -130,6 +135,7 @@ describe("view transitions", () => {
130135
viewTransitionOpts: {
131136
currentLocation: expect.objectContaining({ pathname: "/" }),
132137
nextLocation: expect.objectContaining({ pathname: "/a" }),
138+
opts: true,
133139
},
134140
}),
135141
]);
@@ -165,6 +171,7 @@ describe("view transitions", () => {
165171
viewTransitionOpts: {
166172
currentLocation: expect.objectContaining({ pathname: "/" }),
167173
nextLocation: expect.objectContaining({ pathname: "/b" }),
174+
opts: true,
168175
},
169176
})
170177
);

‎packages/react-router/lib/components.tsx

+44-8
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export function RouterProvider({
211211
nextLocation: Location;
212212
}>();
213213
let fetcherData = React.useRef<Map<string, any>>(new Map());
214+
let [vtTypes, setVtTypes] = React.useState<string[] | undefined>(undefined);
214215

215216
let setState = React.useCallback<RouterSubscriber>(
216217
(
@@ -275,9 +276,26 @@ export function RouterProvider({
275276
});
276277

277278
// Update the DOM
278-
let t = router.window!.document.startViewTransition(() => {
279-
reactDomFlushSyncImpl(() => setStateImpl(newState));
280-
});
279+
let t;
280+
if (
281+
viewTransitionOpts &&
282+
typeof viewTransitionOpts.opts === "object" &&
283+
viewTransitionOpts.opts.types
284+
) {
285+
// Set view transition types when provided
286+
setVtTypes(viewTransitionOpts.opts.types);
287+
288+
t = router.window!.document.startViewTransition({
289+
update: () => {
290+
reactDomFlushSyncImpl(() => setStateImpl(newState));
291+
},
292+
types: viewTransitionOpts.opts.types,
293+
});
294+
} else {
295+
t = router.window!.document.startViewTransition(() => {
296+
reactDomFlushSyncImpl(() => setStateImpl(newState));
297+
});
298+
}
281299

282300
// Clean up after the animation completes
283301
t.finished.finally(() => {
@@ -306,6 +324,13 @@ export function RouterProvider({
306324
});
307325
} else {
308326
// Completed navigation update with opted-in view transitions, let 'er rip
327+
if (
328+
viewTransitionOpts &&
329+
typeof viewTransitionOpts.opts === "object" &&
330+
viewTransitionOpts.opts.types
331+
) {
332+
setVtTypes(viewTransitionOpts.opts.types);
333+
}
309334
setPendingState(newState);
310335
setVtContext({
311336
isTransitioning: true,
@@ -337,10 +362,21 @@ export function RouterProvider({
337362
if (renderDfd && pendingState && router.window) {
338363
let newState = pendingState;
339364
let renderPromise = renderDfd.promise;
340-
let transition = router.window.document.startViewTransition(async () => {
341-
React.startTransition(() => setStateImpl(newState));
342-
await renderPromise;
343-
});
365+
let transition;
366+
if (vtTypes) {
367+
transition = router.window.document.startViewTransition({
368+
update: async () => {
369+
React.startTransition(() => setStateImpl(newState));
370+
await renderPromise;
371+
},
372+
types: vtTypes,
373+
});
374+
} else {
375+
transition = router.window.document.startViewTransition(async () => {
376+
React.startTransition(() => setStateImpl(newState));
377+
await renderPromise;
378+
});
379+
}
344380
transition.finished.finally(() => {
345381
setRenderDfd(undefined);
346382
setTransition(undefined);
@@ -349,7 +385,7 @@ export function RouterProvider({
349385
});
350386
setTransition(transition);
351387
}
352-
}, [pendingState, renderDfd, router.window]);
388+
}, [pendingState, renderDfd, router.window, vtTypes]);
353389

354390
// When the new location finally renders and is committed to the DOM, this
355391
// effect will run to resolve the transition

‎packages/react-router/lib/context.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
LazyRouteFunction,
2020
TrackedPromise,
2121
} from "./router/utils";
22+
import { ViewTransitionOptions } from "./dom/global";
2223

2324
// Create react-specific types from the agnostic types in @remix-run/router to
2425
// export from react-router
@@ -108,6 +109,7 @@ export type ViewTransitionContextObject =
108109
flushSync: boolean;
109110
currentLocation: Location;
110111
nextLocation: Location;
112+
viewTransitionTypes?: string[];
111113
};
112114

113115
export const ViewTransitionContext =
@@ -138,8 +140,11 @@ export interface NavigateOptions {
138140
relative?: RelativeRoutingType;
139141
/** 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} */
140142
flushSync?: boolean;
141-
/** 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. */
142-
viewTransition?: boolean;
143+
/**
144+
* 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()`.
145+
* 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.
146+
*/
147+
viewTransition?: ViewTransitionOptions;
143148
}
144149

145150
/**

‎packages/react-router/lib/dom/dom.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { warning } from "../router/history";
22
import type { RelativeRoutingType } from "../router/router";
33
import type { FormEncType, HTMLFormMethod } from "../router/utils";
44
import { stripBasename } from "../router/utils";
5+
import { ViewTransitionOptions } from "./global";
56

67
export const defaultMethod: HTMLFormMethod = "get";
78
const defaultEncType: FormEncType = "application/x-www-form-urlencoded";
@@ -226,9 +227,12 @@ export interface SubmitOptions extends FetcherSubmitOptions {
226227
navigate?: boolean;
227228

228229
/**
229-
* Enable view transitions on this submission navigation
230+
* Enable view transitions on this submission navigation.
231+
* When set to true, the default transition is applied.
232+
* Alternatively, an object of type ViewTransitionOptions can be provided
233+
* to configure additional options.
230234
*/
231-
viewTransition?: boolean;
235+
viewTransition?: ViewTransitionOptions;
232236
}
233237

234238
const supportedFormEncTypes: Set<FormEncType> = new Set([

‎packages/react-router/lib/dom/global.ts

+14
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,27 @@ export interface ViewTransition {
2626
skipTransition(): void;
2727
}
2828

29+
export type ViewTransitionOptions =
30+
| boolean
31+
| {
32+
/**
33+
* An array of transition type strings (e.g. "slide", "forwards", "backwards")
34+
* that will be applied to the navigation.
35+
*/
36+
types?: string[];
37+
};
38+
2939
declare global {
3040
// TODO: v7 - Can this go away in favor of "just use remix"?
3141
var __staticRouterHydrationData: HydrationState | undefined;
3242
// v6 SPA info
3343
var __reactRouterVersion: string;
3444
interface Document {
3545
startViewTransition(cb: () => Promise<void> | void): ViewTransition;
46+
startViewTransition(options: {
47+
update: () => Promise<void> | void;
48+
types: string[];
49+
}): ViewTransition;
3650
}
3751
var __reactRouterContext: WindowReactRouterContext | undefined;
3852
var __reactRouterManifest: AssetsManifest | undefined;

‎packages/react-router/lib/dom/lib.tsx

+22-16
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
useRouteId,
9191
} from "../hooks";
9292
import type { SerializeFrom } from "../types/route-data";
93+
import { ViewTransitionOptions } from "./global";
9394

9495
////////////////////////////////////////////////////////////////////////////////
9596
//#region Global Stuff
@@ -521,17 +522,22 @@ export interface LinkProps
521522
to: To;
522523

523524
/**
524-
Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for this navigation.
525-
526-
```jsx
527-
<Link to={to} viewTransition>
528-
Click me
529-
</Link>
530-
```
531-
532-
To apply specific styles for the transition, see {@link useViewTransitionState}
525+
* Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for this navigation.
526+
*
527+
* When specified as a boolean, the default transition is applied.
528+
* Alternatively, you can pass an object to configure additional options (e.g. transition types).
529+
*
530+
* Example:
531+
*
532+
* <Link to={to} viewTransition>
533+
* Click me
534+
* </Link>
535+
*
536+
* <Link to={to} viewTransition={{ types: ['slide', 'forwards'] }}>
537+
* Click me
538+
* </Link>
533539
*/
534-
viewTransition?: boolean;
540+
viewTransition?: ViewTransitionOptions;
535541
}
536542

537543
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
@@ -1008,12 +1014,12 @@ export interface FormProps extends SharedFormProps {
10081014
state?: any;
10091015

10101016
/**
1011-
* Enables a [View
1012-
* Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
1013-
* for this navigation. To apply specific styles during the transition see
1014-
* {@link useViewTransitionState}.
1017+
* Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
1018+
* for this navigation. When specified as a boolean, the default transition is applied.
1019+
* Alternatively, you can pass an object to configure additional options (e.g. transition types).
1020+
* To apply specific styles during the transition, see {@link useViewTransitionState}.
10151021
*/
1016-
viewTransition?: boolean;
1022+
viewTransition?: ViewTransitionOptions;
10171023
}
10181024

10191025
type HTMLSubmitEvent = React.BaseSyntheticEvent<
@@ -1290,7 +1296,7 @@ export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
12901296
state?: any;
12911297
preventScrollReset?: boolean;
12921298
relative?: RelativeRoutingType;
1293-
viewTransition?: boolean;
1299+
viewTransition?: ViewTransitionOptions;
12941300
} = {}
12951301
): (event: React.MouseEvent<E, MouseEvent>) => void {
12961302
let navigate = useNavigate();

‎packages/react-router/lib/router/router.ts

+79-51
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ViewTransitionOptions } from "../dom/global";
12
import type { History, Location, Path, To } from "./history";
23
import {
34
Action as NavigationType,
@@ -415,6 +416,7 @@ export interface StaticHandler {
415416
type ViewTransitionOpts = {
416417
currentLocation: Location;
417418
nextLocation: Location;
419+
opts?: ViewTransitionOptions;
418420
};
419421

420422
/**
@@ -464,7 +466,7 @@ type BaseNavigateOptions = BaseNavigateOrFetchOptions & {
464466
replace?: boolean;
465467
state?: any;
466468
fromRouteId?: string;
467-
viewTransition?: boolean;
469+
viewTransition?: ViewTransitionOptions;
468470
};
469471

470472
// Only allowed for submission navigations
@@ -768,12 +770,19 @@ const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
768770
hasErrorBoundary: Boolean(route.hasErrorBoundary),
769771
});
770772

771-
const TRANSITIONS_STORAGE_KEY = "remix-router-transitions";
773+
export const ROUTER_TRANSITIONS_STORAGE_KEY = "remix-router-transitions";
772774

773775
// Flag used on new `loaderData` to indicate that we do not want to preserve
774776
// any prior loader data from the throwing route in `mergeLoaderData`
775777
const ResetLoaderDataSymbol = Symbol("ResetLoaderData");
776778

779+
// The applied view transitions map stores, for each source pathname (string),
780+
// a mapping from destination pathnames (string) to the view transition option that was used.
781+
export type AppliedViewTransitionMap = Map<
782+
string,
783+
Map<string, ViewTransitionOptions>
784+
>;
785+
777786
//#endregion
778787

779788
////////////////////////////////////////////////////////////////////////////////
@@ -943,13 +952,14 @@ export function createRouter(init: RouterInit): Router {
943952
// AbortController for the active navigation
944953
let pendingNavigationController: AbortController | null;
945954

946-
// Should the current navigation enable document.startViewTransition?
947-
let pendingViewTransitionEnabled = false;
955+
// Should the current navigation enable document.startViewTransition? (includes custom opts when provided)
956+
let pendingViewTransition: ViewTransitionOptions = false;
948957

949-
// Store applied view transitions so we can apply them on POP
950-
let appliedViewTransitions: Map<string, Set<string>> = new Map<
958+
// Store, for each "from" pathname, a mapping of "to" pathnames to the viewTransition option.
959+
// This registry enables us to reapply the appropriate view transition when handling a POP navigation.
960+
let appliedViewTransitions: AppliedViewTransitionMap = new Map<
951961
string,
952-
Set<string>
962+
Map<string, ViewTransitionOptions>
953963
>();
954964

955965
// Cleanup function for persisting applied transitions to sessionStorage
@@ -1261,33 +1271,44 @@ export function createRouter(init: RouterInit): Router {
12611271

12621272
// On POP, enable transitions if they were enabled on the original navigation
12631273
if (pendingAction === NavigationType.Pop) {
1264-
// Forward takes precedence so they behave like the original navigation
1265-
let priorPaths = appliedViewTransitions.get(state.location.pathname);
1266-
if (priorPaths && priorPaths.has(location.pathname)) {
1274+
// Try to get the transition mapping from the current (source) location.
1275+
let vTRegistry = appliedViewTransitions.get(state.location.pathname);
1276+
if (vTRegistry && vTRegistry.has(location.pathname)) {
1277+
const opts = vTRegistry.get(location.pathname);
12671278
viewTransitionOpts = {
12681279
currentLocation: state.location,
12691280
nextLocation: location,
1281+
opts,
12701282
};
12711283
} else if (appliedViewTransitions.has(location.pathname)) {
1272-
// If we don't have a previous forward nav, assume we're popping back to
1273-
// the new location and enable if that location previously enabled
1274-
viewTransitionOpts = {
1275-
currentLocation: location,
1276-
nextLocation: state.location,
1277-
};
1284+
// Otherwise, check the reverse mapping from the destination side.
1285+
let vTRegistry = appliedViewTransitions.get(location.pathname)!;
1286+
if (vTRegistry.has(state.location.pathname)) {
1287+
const opts = vTRegistry.get(state.location.pathname);
1288+
viewTransitionOpts = {
1289+
currentLocation: location,
1290+
nextLocation: state.location,
1291+
opts,
1292+
};
1293+
}
12781294
}
1279-
} else if (pendingViewTransitionEnabled) {
1280-
// Store the applied transition on PUSH/REPLACE
1281-
let toPaths = appliedViewTransitions.get(state.location.pathname);
1282-
if (toPaths) {
1283-
toPaths.add(location.pathname);
1284-
} else {
1285-
toPaths = new Set<string>([location.pathname]);
1286-
appliedViewTransitions.set(state.location.pathname, toPaths);
1295+
} else if (pendingViewTransition) {
1296+
// For non-POP navigations (PUSH/REPLACE) when viewTransition is enabled:
1297+
// Retrieve the existing transition mapping for the source pathname.
1298+
let vTRegistry = appliedViewTransitions.get(state.location.pathname);
1299+
if (!vTRegistry) {
1300+
// If no mapping exists, create one.
1301+
vTRegistry = new Map<string, ViewTransitionOptions>();
1302+
appliedViewTransitions.set(state.location.pathname, vTRegistry);
12871303
}
1304+
// Record that navigating from the current pathname to the next uses the pending view transition option.
1305+
vTRegistry.set(location.pathname, pendingViewTransition);
1306+
1307+
// Set the view transition options for the current navigation.
12881308
viewTransitionOpts = {
12891309
currentLocation: state.location,
12901310
nextLocation: location,
1311+
opts: pendingViewTransition, // Retains the full option (boolean or object)
12911312
};
12921313
}
12931314

@@ -1317,7 +1338,7 @@ export function createRouter(init: RouterInit): Router {
13171338
// Reset stateful navigation vars
13181339
pendingAction = NavigationType.Pop;
13191340
pendingPreventScrollReset = false;
1320-
pendingViewTransitionEnabled = false;
1341+
pendingViewTransition = false;
13211342
isUninterruptedRevalidation = false;
13221343
isRevalidationRequired = false;
13231344
pendingRevalidationDfd?.resolve();
@@ -1426,7 +1447,7 @@ export function createRouter(init: RouterInit): Router {
14261447
pendingError: error,
14271448
preventScrollReset,
14281449
replace: opts && opts.replace,
1429-
enableViewTransition: opts && opts.viewTransition,
1450+
viewTransition: opts && opts.viewTransition,
14301451
flushSync,
14311452
});
14321453
}
@@ -1480,7 +1501,7 @@ export function createRouter(init: RouterInit): Router {
14801501
{
14811502
overrideNavigation: state.navigation,
14821503
// Proxy through any rending view transition
1483-
enableViewTransition: pendingViewTransitionEnabled === true,
1504+
viewTransition: pendingViewTransition,
14841505
}
14851506
);
14861507
return promise;
@@ -1501,7 +1522,7 @@ export function createRouter(init: RouterInit): Router {
15011522
startUninterruptedRevalidation?: boolean;
15021523
preventScrollReset?: boolean;
15031524
replace?: boolean;
1504-
enableViewTransition?: boolean;
1525+
viewTransition?: ViewTransitionOptions;
15051526
flushSync?: boolean;
15061527
}
15071528
): Promise<void> {
@@ -1519,7 +1540,8 @@ export function createRouter(init: RouterInit): Router {
15191540
saveScrollPosition(state.location, state.matches);
15201541
pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
15211542

1522-
pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true;
1543+
pendingViewTransition =
1544+
opts && opts.viewTransition ? opts.viewTransition : false;
15231545

15241546
let routesToUse = inFlightDataRoutes || dataRoutes;
15251547
let loadingNavigation = opts && opts.overrideNavigation;
@@ -2701,9 +2723,7 @@ export function createRouter(init: RouterInit): Router {
27012723
},
27022724
// Preserve these flags across redirects
27032725
preventScrollReset: preventScrollReset || pendingPreventScrollReset,
2704-
enableViewTransition: isNavigation
2705-
? pendingViewTransitionEnabled
2706-
: undefined,
2726+
viewTransition: isNavigation ? pendingViewTransition : undefined,
27072727
});
27082728
} else {
27092729
// If we have a navigation submission, we will preserve it through the
@@ -2718,9 +2738,7 @@ export function createRouter(init: RouterInit): Router {
27182738
fetcherSubmission,
27192739
// Preserve these flags across redirects
27202740
preventScrollReset: preventScrollReset || pendingPreventScrollReset,
2721-
enableViewTransition: isNavigation
2722-
? pendingViewTransitionEnabled
2723-
: undefined,
2741+
viewTransition: isNavigation ? pendingViewTransition : undefined,
27242742
});
27252743
}
27262744
}
@@ -5608,39 +5626,49 @@ function getDoneFetcher(data: Fetcher["data"]): FetcherStates["Idle"] {
56085626
return fetcher;
56095627
}
56105628

5611-
function restoreAppliedTransitions(
5629+
export function restoreAppliedTransitions(
56125630
_window: Window,
5613-
transitions: Map<string, Set<string>>
5631+
transitions: AppliedViewTransitionMap
56145632
) {
56155633
try {
5616-
let sessionPositions = _window.sessionStorage.getItem(
5617-
TRANSITIONS_STORAGE_KEY
5634+
const sessionData = _window.sessionStorage.getItem(
5635+
ROUTER_TRANSITIONS_STORAGE_KEY
56185636
);
5619-
if (sessionPositions) {
5620-
let json = JSON.parse(sessionPositions);
5621-
for (let [k, v] of Object.entries(json || {})) {
5622-
if (v && Array.isArray(v)) {
5623-
transitions.set(k, new Set(v || []));
5637+
if (sessionData) {
5638+
// Parse the JSON object into the expected nested structure.
5639+
const json: Record<
5640+
string,
5641+
Record<string, ViewTransitionOptions>
5642+
> = JSON.parse(sessionData);
5643+
for (const [from, toOptsObj] of Object.entries(json)) {
5644+
const toOptsMap = new Map<string, ViewTransitionOptions>();
5645+
for (const [to, opts] of Object.entries(toOptsObj)) {
5646+
toOptsMap.set(to, opts);
56245647
}
5648+
transitions.set(from, toOptsMap);
56255649
}
56265650
}
56275651
} catch (e) {
5628-
// no-op, use default empty object
5652+
// On error, simply do nothing.
56295653
}
56305654
}
56315655

5632-
function persistAppliedTransitions(
5656+
export function persistAppliedTransitions(
56335657
_window: Window,
5634-
transitions: Map<string, Set<string>>
5658+
transitions: AppliedViewTransitionMap
56355659
) {
56365660
if (transitions.size > 0) {
5637-
let json: Record<string, string[]> = {};
5638-
for (let [k, v] of transitions) {
5639-
json[k] = [...v];
5661+
// Convert the nested Map structure into a plain object.
5662+
const json: Record<string, Record<string, ViewTransitionOptions>> = {};
5663+
for (const [from, toOptsMap] of transitions.entries()) {
5664+
json[from] = {};
5665+
for (const [to, opts] of toOptsMap.entries()) {
5666+
json[from][to] = opts;
5667+
}
56405668
}
56415669
try {
56425670
_window.sessionStorage.setItem(
5643-
TRANSITIONS_STORAGE_KEY,
5671+
ROUTER_TRANSITIONS_STORAGE_KEY,
56445672
JSON.stringify(json)
56455673
);
56465674
} catch (error) {

0 commit comments

Comments
 (0)
Please sign in to comment.