From 81d0544397d0898be6327c183c29dd1eaa4afef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 03:43:32 +0000 Subject: [PATCH] feat: tag navigation transitions with addTransitionType Each entry-change transition now calls React's addTransitionType inside startTransition with "navigation" by default. The new experimentalTransitionTypes prop on accepts a function that returns a custom type list per navigation, receiving the destination URL and underlying navigationType (push/replace/reload/traverse). addTransitionType is currently only available in React Canary, so the implementation looks the API up dynamically and degrades to a no-op on stable React. The prop carries an "experimental" prefix that will be dropped once the React API is stable. Internal adapter subscribe callbacks now pass an EntryChange discriminated union instead of a bare string so the navigationType can flow through. --- .../docs/src/pages/LearnTransitionsPage.tsx | 46 +++++++ packages/router/src/Router/index.tsx | 46 ++++++- packages/router/src/__tests__/Router.test.tsx | 114 ++++++++++++++++++ .../router/src/core/NavigationAPIAdapter.ts | 30 +++-- packages/router/src/core/NullAdapter.ts | 4 +- packages/router/src/core/RouterAdapter.ts | 15 ++- packages/router/src/core/StaticAdapter.ts | 4 +- packages/router/src/core/addTransitionType.ts | 15 +++ packages/router/src/index.ts | 3 + packages/router/src/types.ts | 27 +++++ 10 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 packages/router/src/core/addTransitionType.ts diff --git a/packages/docs/src/pages/LearnTransitionsPage.tsx b/packages/docs/src/pages/LearnTransitionsPage.tsx index 2ffc5e4..d13b51f 100644 --- a/packages/docs/src/pages/LearnTransitionsPage.tsx +++ b/packages/docs/src/pages/LearnTransitionsPage.tsx @@ -60,6 +60,52 @@ function UserDetail({ data }: { data: Promise }) { // → Once loaded, the UI swaps to /user/2 instantly`} +
+

+ Tagging Navigations with experimentalTransitionTypes +

+

+ React's{" "} + + addTransitionType + {" "} + 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 addTransitionType inside its{" "} + startTransition for every entry change, tagged{" "} + "navigation" by default. +

+

+ You can replace the default by passing an{" "} + experimentalTransitionTypes function to{" "} + {""}. The function receives the destination{" "} + url and the underlying navigationType (one + of push, replace, reload,{" "} + traverse) and returns an array of transition types: +

+ {` [ + "navigation", + \`navigation-\${navigationType}\`, + url.pathname.startsWith("/admin") ? "admin" : "public", + ]} +/>`} +

+ The returned types replace the default — include{" "} + "navigation" yourself if you want it. +

+
+ React Canary required. At the time of writing,{" "} + addTransitionType is only available in React Canary + (exported as unstable_addTransitionType). On stable + React, the prop is still invoked but the returned types are silently + discarded. The prop is named with an experimental prefix + to reflect this; it will be renamed once{" "} + addTransitionType becomes stable in React. +
+
+

Showing Pending UI with useIsPending diff --git a/packages/router/src/Router/index.tsx b/packages/router/src/Router/index.tsx index 8273477..e0b4ccb 100644 --- a/packages/router/src/Router/index.tsx +++ b/packages/router/src/Router/index.tsx @@ -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"; @@ -98,6 +101,22 @@ 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({ @@ -105,6 +124,7 @@ export function Router({ onNavigate, fallback = "none", ssr, + experimentalTransitionTypes, }: RouterProps): ReactNode { const routes = internalRoutes(inputRoutes); @@ -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 diff --git a/packages/router/src/__tests__/Router.test.tsx b/packages/router/src/__tests__/Router.test.tsx index c13de97..0f51fed 100644 --- a/packages/router/src/__tests__/Router.test.tsx +++ b/packages/router/src/__tests__/Router.test.tsx @@ -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; @@ -308,4 +310,116 @@ describe("Router", () => { expect(screen.getByText("Child Page")).toBeInTheDocument(); }); }); + + describe("experimentalTransitionTypes", () => { + let addTransitionTypeSpy: ReturnType; + + beforeEach(() => { + addTransitionTypeSpy = vi.spyOn( + addTransitionTypeModule, + "addTransitionType", + ); + }); + + afterEach(() => { + addTransitionTypeSpy.mockRestore(); + }); + + it("adds the 'navigation' transition type by default on navigation", () => { + const routes: RouteDefinition[] = [ + { path: "/", component: () =>
Home
}, + { path: "/about", component: () =>
About
}, + ]; + + render(); + + 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: () =>
Home
}, + { path: "/about", component: () =>
About
}, + ]; + + render(); + + 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: () =>
Home
}, + { path: "/about", component: () =>
About
}, + ]; + + render(); + + // 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: () =>
Home
}, + ]; + + render(); + + addTransitionTypeSpy.mockClear(); + + act(() => { + mockNavigation.updateCurrentEntry({ state: { foo: "bar" } }); + }); + + expect(addTransitionTypeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/router/src/core/NavigationAPIAdapter.ts b/packages/router/src/core/NavigationAPIAdapter.ts index 70d9070..7903dc7 100644 --- a/packages/router/src/core/NavigationAPIAdapter.ts +++ b/packages/router/src/core/NavigationAPIAdapter.ts @@ -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"; @@ -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(); + // 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; @@ -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 }, ); @@ -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); @@ -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; diff --git a/packages/router/src/core/NullAdapter.ts b/packages/router/src/core/NullAdapter.ts index 97f1fe8..3b5719b 100644 --- a/packages/router/src/core/NullAdapter.ts +++ b/packages/router/src/core/NullAdapter.ts @@ -1,7 +1,7 @@ import type { RouterAdapter, LocationEntry, - EntryChangeType, + EntryChange, } from "./RouterAdapter.js"; import type { InternalRouteDefinition, @@ -20,7 +20,7 @@ export class NullAdapter implements RouterAdapter { return null; } - subscribe(_callback: (changeType: EntryChangeType) => void): () => void { + subscribe(_callback: (change: EntryChange) => void): () => void { return () => {}; } diff --git a/packages/router/src/core/RouterAdapter.ts b/packages/router/src/core/RouterAdapter.ts index 414e1fe..1cc895c 100644 --- a/packages/router/src/core/RouterAdapter.ts +++ b/packages/router/src/core/RouterAdapter.ts @@ -1,6 +1,7 @@ import type { InternalRouteDefinition, NavigateOptions, + NavigationType, OnNavigateCallback, } from "../types.js"; @@ -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. @@ -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. diff --git a/packages/router/src/core/StaticAdapter.ts b/packages/router/src/core/StaticAdapter.ts index 5c111cc..01e37ed 100644 --- a/packages/router/src/core/StaticAdapter.ts +++ b/packages/router/src/core/StaticAdapter.ts @@ -1,7 +1,7 @@ import type { RouterAdapter, LocationEntry, - EntryChangeType, + EntryChange, } from "./RouterAdapter.js"; import type { InternalRouteDefinition, @@ -37,7 +37,7 @@ export class StaticAdapter implements RouterAdapter { return this.#cachedSnapshot; } - subscribe(_callback: (changeType: EntryChangeType) => void): () => void { + subscribe(_callback: (change: EntryChange) => void): () => void { // Static mode never fires location change events return () => {}; } diff --git a/packages/router/src/core/addTransitionType.ts b/packages/router/src/core/addTransitionType.ts new file mode 100644 index 0000000..200783c --- /dev/null +++ b/packages/router/src/core/addTransitionType.ts @@ -0,0 +1,15 @@ +import * as React from "react"; + +type AddTransitionType = (type: string) => void; + +const reactExports = React as unknown as Record; +const candidate = + reactExports.addTransitionType ?? reactExports.unstable_addTransitionType; + +/** + * 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. + */ +export const addTransitionType: AddTransitionType = + typeof candidate === "function" ? (candidate as AddTransitionType) : () => {}; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 16c9146..89fc13a 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -30,6 +30,9 @@ export type { OnNavigateCallback, OnNavigateInfo, FallbackMode, + NavigationType, + TransitionTypeContext, + GetTransitionTypes, } from "./types.js"; export type { diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts index f902410..6aabe64 100644 --- a/packages/router/src/types.ts +++ b/packages/router/src/types.ts @@ -104,6 +104,33 @@ export type OnNavigateInfo = { formData: FormData | null; }; +/** + * Navigation type for an entry-change, mirroring the Navigation API's + * `NavigationCurrentEntryChangeEvent.navigationType` (excluding `null`, + * which corresponds to a state-only update and never triggers a transition). + */ +export type NavigationType = "push" | "replace" | "reload" | "traverse"; + +/** + * Context passed to an `experimentalTransitionTypes` callback. + */ +export type TransitionTypeContext = { + /** URL of the new navigation entry. */ + url: URL; + /** How this entry change was triggered. */ + navigationType: NavigationType; +}; + +/** + * 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). + */ +export type GetTransitionTypes = ( + context: TransitionTypeContext, +) => readonly string[]; + /** * Options for navigation. */