Skip to content

Commit 45333f0

Browse files
committed
feat: add viewTransitionTypes support for selective view transitions
1 parent ac399b7 commit 45333f0

12 files changed

+541
-79
lines changed

contributors.yml

+1
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@
190190
- lounsbrough
191191
- lpaube
192192
- lqze
193+
- lsharir
193194
- lukerSpringTree
194195
- m-dad
195196
- m-shojaei

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+
});

0 commit comments

Comments
 (0)