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
46 changes: 46 additions & 0 deletions packages/docs/src/pages/LearnTransitionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,52 @@ function UserDetail({ data }: { data: Promise<User> }) {
// → Once loaded, the UI swaps to /user/2 instantly`}</CodeBlock>
</section>

<section>
<h3>
Tagging Navigations with <code>experimentalTransitionTypes</code>
</h3>
<p>
React's{" "}
<a href="https://react.dev/reference/react/addTransitionType">
<code>addTransitionType</code>
</a>{" "}
API lets you attach semantic labels to a transition so other parts of
the app (e.g. View Transitions) can react to them. FUNSTACK Router
calls <code>addTransitionType</code> inside its{" "}
<code>startTransition</code> for every entry change, tagged{" "}
<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:
</p>
<CodeBlock language="tsx">{`<Router
routes={routes}
experimentalTransitionTypes={({ navigationType, url }) => [
"navigation",
\`navigation-\${navigationType}\`,
url.pathname.startsWith("/admin") ? "admin" : "public",
]}
/>`}</CodeBlock>
<p>
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>
</section>

<section>
<h3>
Showing Pending UI with <code>useIsPending</code>
Expand Down
46 changes: 43 additions & 3 deletions packages/router/src/Router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ import {
createBlockerRegistry,
} from "../context/BlockerContext.js";
import {
type GetTransitionTypes,
type NavigateOptions,
type OnNavigateCallback,
type FallbackMode,
type TransitionTypeContext,
internalRoutes,
} from "../types.js";
import { addTransitionType } from "../core/addTransitionType.js";
import { matchRoutes } from "../core/matchRoutes.js";
import { createAdapter } from "../core/createAdapter.js";
import { executeLoaders, createLoaderRequest } from "../core/loaderCache.js";
Expand Down Expand Up @@ -98,13 +101,30 @@ export type RouterProps = {
* ```
*/
ssr?: SSRConfig;
/**
* **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"]`.
*
* Requires a React build that exports `addTransitionType` (currently React
* Canary). 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"]
*/
experimentalTransitionTypes?: GetTransitionTypes;
};

export function Router({
routes: inputRoutes,
onNavigate,
fallback = "none",
ssr,
experimentalTransitionTypes,
}: RouterProps): ReactNode {
const routes = internalRoutes(inputRoutes);

Expand Down Expand Up @@ -152,12 +172,32 @@ export function Router({
setLocationEntry(initialEntry);
}

// 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"],
);

// Subscribe to navigation changes (conditionally wrapped in transition)
useEffect(() => {
return adapter.subscribe((changeType) => {
if (changeType === "navigation") {
return adapter.subscribe((change) => {
if (change.kind === "navigation") {
const newSnapshot = adapter.getSnapshot();
startTransition(() => {
setLocationEntry(adapter.getSnapshot());
if (newSnapshot) {
const types = getTransitionTypes({
url: newSnapshot.url,
navigationType: change.navigationType,
});
for (const type of types) {
addTransitionType(type);
}
}
setLocationEntry(newSnapshot);
});
} else {
// State-only update: apply synchronously, no transition
Expand Down
114 changes: 114 additions & 0 deletions packages/router/src/__tests__/Router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Outlet } from "../Outlet.js";
import { useLocation } from "../hooks/useLocation.js";
import { setupNavigationMock, cleanupNavigationMock } from "./setup.js";
import { route, type RouteDefinition } from "../route.js";
import * as addTransitionTypeModule from "../core/addTransitionType.js";
import type { GetTransitionTypes, TransitionTypeContext } from "../types.js";

describe("Router", () => {
let mockNavigation: ReturnType<typeof setupNavigationMock>;
Expand Down Expand Up @@ -308,4 +310,116 @@ describe("Router", () => {
expect(screen.getByText("Child Page")).toBeInTheDocument();
});
});

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

beforeEach(() => {
addTransitionTypeSpy = vi.spyOn(
addTransitionTypeModule,
"addTransitionType",
);
});

afterEach(() => {
addTransitionTypeSpy.mockRestore();
});

it("adds the 'navigation' transition type by default on navigation", () => {
const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home</div> },
{ path: "/about", component: () => <div>About</div> },
];

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

addTransitionTypeSpy.mockClear();

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

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

it("uses the returned types when experimentalTransitionTypes 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} />);

addTransitionTypeSpy.mockClear();

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

expect(addTransitionTypeSpy).toHaveBeenCalledTimes(2);
expect(addTransitionTypeSpy).toHaveBeenNthCalledWith(1, "page");
expect(addTransitionTypeSpy).toHaveBeenNthCalledWith(2, "fade");
expect(addTransitionTypeSpy).not.toHaveBeenCalledWith("navigation");
});

it("passes the new URL and navigationType to the callback", () => {
const calls: TransitionTypeContext[] = [];
const getTypes: GetTransitionTypes = (ctx) => {
calls.push(ctx);
return ["navigation"];
};

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

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

// Push
act(() => {
mockNavigation.navigate("http://localhost/about");
});
// Replace
act(() => {
mockNavigation.navigate("http://localhost/", { history: "replace" });
});
// Traverse (back to entry 0, which is still '/')
act(() => {
mockNavigation.__simulateTraversal(0);
});
// Reload
act(() => {
mockNavigation.__simulateReload();
});

const types = calls.map((c) => c.navigationType);
expect(types).toContain("push");
expect(types).toContain("replace");
expect(types).toContain("traverse");
expect(types).toContain("reload");

const pushCall = calls.find((c) => c.navigationType === "push");
expect(pushCall?.url.pathname).toBe("/about");
});

it("does not call addTransitionType for state-only updates", () => {
const routes: RouteDefinition[] = [
{ path: "/", component: () => <div>Home</div> },
];

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

addTransitionTypeSpy.mockClear();

act(() => {
mockNavigation.updateCurrentEntry({ state: { foo: "bar" } });
});

expect(addTransitionTypeSpy).not.toHaveBeenCalled();
});
});
});
30 changes: 22 additions & 8 deletions packages/router/src/core/NavigationAPIAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type {
RouterAdapter,
LocationEntry,
EntryChangeType,
EntryChange,
} from "./RouterAdapter.js";
import type {
InternalRouteDefinition,
MatchedRoute,
NavigateOptions,
NavigationType,
OnNavigateCallback,
} from "../types.js";
import { matchRoutes } from "./matchRoutes.js";
Expand Down Expand Up @@ -56,6 +57,11 @@ export class NavigationAPIAdapter implements RouterAdapter {
// Per-(entry, URL) reload counters, used to generate unique cache keys
// so that loaders re-execute on reload instead of returning cached results.
#reloadCounts = new Map<string, number>();
// The navigationType of the most recently intercepted navigate event.
// Used by the `navigatesuccess` fallback (WebKit Private Browsing) where
// the `currententrychange` event — and thus its navigationType — may be
// missing. Falls back to "push" if no intercept has been observed yet.
#lastInterceptedNavigationType: NavigationType = "push";

getSnapshot(): LocationEntry | null {
const entry = navigation.currentEntry;
Expand Down Expand Up @@ -98,18 +104,20 @@ export class NavigationAPIAdapter implements RouterAdapter {
return this.#cachedSnapshot;
}

subscribe(callback: (changeType: EntryChangeType) => void): () => void {
subscribe(callback: (change: EntryChange) => void): () => void {
const controller = new AbortController();
navigation.addEventListener(
"currententrychange",
(event) => {
// NavigationCurrentEntryChangeEvent.navigationType is null
// when the change was caused by updateCurrentEntry()
const changeType: EntryChangeType =
(event as NavigationCurrentEntryChangeEvent).navigationType === null
? "state"
: "navigation";
callback(changeType);
const navigationType = (event as NavigationCurrentEntryChangeEvent)
.navigationType;
if (navigationType === null) {
callback({ kind: "state" });
} else {
callback({ kind: "navigation", navigationType });
}
},
{ signal: controller.signal },
);
Expand All @@ -119,7 +127,10 @@ export class NavigationAPIAdapter implements RouterAdapter {
navigation.addEventListener(
"navigatesuccess",
() => {
callback("navigation");
callback({
kind: "navigation",
navigationType: this.#lastInterceptedNavigationType,
});
// currententrychange may have been skipped; ensure new entries
// still get dispose subscriptions.
this.#subscribeToDisposeEvents(controller.signal);
Expand Down Expand Up @@ -222,6 +233,9 @@ export class NavigationAPIAdapter implements RouterAdapter {
// Capture ephemeral info from the navigate event
// This info is only available during this navigation and resets on the next one
this.#currentNavigationInfo = event.info;
// Remember the navigationType so the navigatesuccess fallback (used in
// WebKit Private Browsing) can report it to subscribers.
this.#lastInterceptedNavigationType = event.navigationType;
// Invalidate cached snapshot to pick up new info
this.#cachedSnapshot = null;

Expand Down
4 changes: 2 additions & 2 deletions packages/router/src/core/NullAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {
RouterAdapter,
LocationEntry,
EntryChangeType,
EntryChange,
} from "./RouterAdapter.js";
import type {
InternalRouteDefinition,
Expand All @@ -20,7 +20,7 @@ export class NullAdapter implements RouterAdapter {
return null;
}

subscribe(_callback: (changeType: EntryChangeType) => void): () => void {
subscribe(_callback: (change: EntryChange) => void): () => void {
return () => {};
}

Expand Down
15 changes: 13 additions & 2 deletions packages/router/src/core/RouterAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
InternalRouteDefinition,
NavigateOptions,
NavigationType,
OnNavigateCallback,
} from "../types.js";

Expand All @@ -11,6 +12,16 @@ import type {
*/
export type EntryChangeType = "navigation" | "state";

/**
* Describes an entry-change observed by the adapter.
* - `kind: "navigation"` carries the underlying navigation type
* (push, replace, reload, traverse).
* - `kind: "state"` is a state-only update via `updateCurrentEntry()`.
*/
export type EntryChange =
| { kind: "navigation"; navigationType: NavigationType }
| { kind: "state" };

/**
* Represents the current location state.
* Abstracts NavigationHistoryEntry for static mode compatibility.
Expand Down Expand Up @@ -43,10 +54,10 @@ export interface RouterAdapter {

/**
* Subscribe to location changes.
* The callback receives the type of change that occurred.
* The callback receives an object describing the change.
* Returns an unsubscribe function.
*/
subscribe(callback: (changeType: EntryChangeType) => void): () => void;
subscribe(callback: (change: EntryChange) => void): () => void;

/**
* Perform programmatic navigation.
Expand Down
Loading