Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/docs/src/pages/ApiComponentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ export function ApiComponentsPage() {
takes over. See the <a href="/learn/ssr">SSR guide</a> for details.
</td>
</tr>
<tr>
<td>
<code>transitionTypes</code>
</td>
<td>
<code>GetTransitionTypes</code>
</td>
<td>
Function returning the React transition types to attach to each navigation via{" "}
<code>addTransitionType</code> (React 19.3+). Receives the destination{" "}
<code>url</code> and <code>navigationType</code>; the returned types replace the
default <code>["navigation"]</code>. See the{" "}
<a href="/learn/transitions">Transitions guide</a> for details.
</td>
</tr>
<tr>
<td>
<code>features</code>
Expand Down
29 changes: 14 additions & 15 deletions packages/docs/src/pages/LearnTransitionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function UserDetail({ data }: { data: Promise<User> }) {

<section>
<h3>
Tagging Navigations with <code>experimentalTransitionTypes</code>
Tagging Navigations with <code>transitionTypes</code>
</h3>
<p>
React's{" "}
Expand All @@ -77,15 +77,14 @@ function UserDetail({ data }: { data: Promise<User> }) {
<code>"navigation"</code> by default.
</p>
<p>
You can replace the default by passing an <code>experimentalTransitionTypes</code>{" "}
function to <code>{"<Router>"}</code>. The function receives the destination{" "}
<code>url</code> and the underlying <code>navigationType</code> (one of <code>push</code>,{" "}
<code>replace</code>, <code>reload</code>, <code>traverse</code>) and returns an array of
transition types:
You can replace the default by passing a <code>transitionTypes</code> function to{" "}
<code>{"<Router>"}</code>. The function receives the destination <code>url</code> and the
underlying <code>navigationType</code> (one of <code>push</code>, <code>replace</code>,{" "}
<code>reload</code>, <code>traverse</code>) and returns an array of transition types:
</p>
<CodeBlock language="tsx">{`<Router
routes={routes}
experimentalTransitionTypes={({ navigationType, url }) => [
transitionTypes={({ navigationType, url }) => [
"navigation",
\`navigation-\${navigationType}\`,
url.pathname.startsWith("/admin") ? "admin" : "public",
Expand All @@ -95,14 +94,14 @@ function UserDetail({ data }: { data: Promise<User> }) {
The returned types <strong>replace</strong> the default — include{" "}
<code>"navigation"</code> yourself if you want it.
</p>
<div className="callout callout-warning">
<strong>React Canary required.</strong> At the time of writing,{" "}
<code>addTransitionType</code> is only available in React Canary (exported as{" "}
<code>unstable_addTransitionType</code>). On stable React, the prop is still invoked but
the returned types are silently discarded. The prop is named with an{" "}
<code>experimental</code> prefix to reflect this; it will be renamed once{" "}
<code>addTransitionType</code> becomes stable in React.
</div>
<p>
<code>addTransitionType</code> is available in React 19.3 or later. On older React
versions, the function is still invoked but the returned types are silently discarded.
</p>
<p>
The prop was previously named <code>experimentalTransitionTypes</code>. The old name is
still accepted as a deprecated alias and will be removed in the next major version.
</p>
</section>

<section>
Expand Down
30 changes: 17 additions & 13 deletions packages/router/src/Router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,22 @@ export type RouterProps = {
*/
trailingSlash?: TrailingSlashMode;
/**
* **Experimental.** Function returning the React transition types to attach
* to entry-change transitions via `addTransitionType`. Called inside
* `startTransition` for each navigation; the returned types replace the
* default `["navigation"]`.
* Function returning the React transition types to attach to entry-change
* transitions via `addTransitionType`. Called inside `startTransition` for
* each navigation; the returned types replace the default `["navigation"]`.
*
* Requires a React build that exports `addTransitionType` (currently React
* Canary). On builds that don't expose the API, the function is still
* Requires a React build that exports `addTransitionType` (React 19.3 or
* later). On builds that don't expose the API, the function is still
* invoked but the types are silently discarded.
*
* The `experimental` prefix will be dropped once `addTransitionType`
* becomes stable in React.
*
* @default () => ["navigation"]
*/
transitionTypes?: GetTransitionTypes;
/**
* @deprecated Use {@link RouterProps.transitionTypes} instead. This alias
* will be removed in the next major version. When both are given,
* `transitionTypes` takes precedence.
*/
experimentalTransitionTypes?: GetTransitionTypes;
/**
* Opt-in feature flags that change the Router's behavior.
Expand Down Expand Up @@ -170,6 +172,7 @@ export function Router({
fallback = "none",
ssr,
trailingSlash,
transitionTypes,
experimentalTransitionTypes,
features,
}: RouterProps): ReactNode {
Expand Down Expand Up @@ -230,10 +233,11 @@ export function Router({

// Resolve transition types for the current navigation. Wrapped in
// useEffectEvent so the subscription effect doesn't re-run when the
// `experimentalTransitionTypes` prop identity changes.
const getTransitionTypes = useEffectEvent((context: TransitionTypeContext): readonly string[] =>
experimentalTransitionTypes ? experimentalTransitionTypes(context) : ["navigation"],
);
// `transitionTypes` prop identity changes.
const getTransitionTypes = useEffectEvent((context: TransitionTypeContext): readonly string[] => {
const getTypes = transitionTypes ?? experimentalTransitionTypes;
return getTypes ? getTypes(context) : ["navigation"];
});

// Subscribe to navigation changes (conditionally wrapped in transition)
useEffect(() => {
Expand Down
56 changes: 52 additions & 4 deletions packages/router/src/__tests__/Router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ describe("Router", () => {
});
});

describe("experimentalTransitionTypes", () => {
describe("transitionTypes", () => {
let addTransitionTypeSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
Expand Down Expand Up @@ -324,15 +324,15 @@ describe("Router", () => {
expect(addTransitionTypeSpy).toHaveBeenCalledWith("navigation");
});

it("uses the returned types when experimentalTransitionTypes is provided", () => {
it("uses the returned types when transitionTypes is provided", () => {
const getTypes: GetTransitionTypes = vi.fn(() => ["page", "fade"]);

const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home</div> },
{ path: "/about", component: () => <div>About</div> },
];

render(<Router routes={routes} experimentalTransitionTypes={getTypes} />);
render(<Router routes={routes} transitionTypes={getTypes} />);

addTransitionTypeSpy.mockClear();

Expand All @@ -346,6 +346,54 @@ describe("Router", () => {
expect(addTransitionTypeSpy).not.toHaveBeenCalledWith("navigation");
});

it("supports the deprecated experimentalTransitionTypes alias", () => {
const getTypes: GetTransitionTypes = () => ["legacy"];

const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home</div> },
{ path: "/about", component: () => <div>About</div> },
];

render(<Router routes={routes} experimentalTransitionTypes={getTypes} />);

addTransitionTypeSpy.mockClear();

act(() => {
mockNavigation.__simulateNavigation("http://localhost/about");
});

expect(addTransitionTypeSpy).toHaveBeenCalledTimes(1);
expect(addTransitionTypeSpy).toHaveBeenCalledWith("legacy");
});

it("prefers transitionTypes over experimentalTransitionTypes", () => {
const getTypes: GetTransitionTypes = () => ["new"];
const getLegacyTypes: GetTransitionTypes = vi.fn(() => ["legacy"]);

const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home</div> },
{ path: "/about", component: () => <div>About</div> },
];

render(
<Router
routes={routes}
transitionTypes={getTypes}
experimentalTransitionTypes={getLegacyTypes}
/>,
);

addTransitionTypeSpy.mockClear();

act(() => {
mockNavigation.__simulateNavigation("http://localhost/about");
});

expect(addTransitionTypeSpy).toHaveBeenCalledTimes(1);
expect(addTransitionTypeSpy).toHaveBeenCalledWith("new");
expect(getLegacyTypes).not.toHaveBeenCalled();
});

it("passes the new URL and navigationType to the callback", () => {
const calls: TransitionTypeContext[] = [];
const getTypes: GetTransitionTypes = (ctx) => {
Expand All @@ -358,7 +406,7 @@ describe("Router", () => {
{ path: "/about", component: () => <div>About</div> },
];

render(<Router routes={routes} experimentalTransitionTypes={getTypes} />);
render(<Router routes={routes} transitionTypes={getTypes} />);

// Push
act(() => {
Expand Down
5 changes: 3 additions & 2 deletions packages/router/src/core/addTransitionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ const candidate = reactExports.addTransitionType ?? reactExports.unstable_addTra

/**
* Wrapper around React's `addTransitionType` that degrades to a no-op when
* the host React build doesn't expose the API (e.g. stable React 19.2).
* `addTransitionType` is currently available only in React Canary.
* the host React build doesn't expose the API (e.g. React 19.2).
* `addTransitionType` is available in React starting with 19.3; older Canary
* builds export it as `unstable_addTransitionType`.
*/
export const addTransitionType: AddTransitionType =
typeof candidate === "function" ? (candidate as AddTransitionType) : () => {};
4 changes: 2 additions & 2 deletions packages/router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export type OnNavigateInfo = {
export type NavigationType = "push" | "replace" | "reload" | "traverse";

/**
* Context passed to an `experimentalTransitionTypes` callback.
* Context passed to a `transitionTypes` callback.
*/
export type TransitionTypeContext = {
/** URL of the new navigation entry. */
Expand All @@ -122,7 +122,7 @@ export type TransitionTypeContext = {
* Returns the React transition types to associate with a navigation entry
* change. Called inside `startTransition` for each navigation; the return
* value is passed to React's `addTransitionType`. Requires a React build
* that exports `addTransitionType` (currently React Canary).
* that exports `addTransitionType` (React 19.3 or later).
*/
export type GetTransitionTypes = (context: TransitionTypeContext) => readonly string[];

Expand Down
Loading