diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 7a6498c2..413e99e1 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -14,6 +14,10 @@ jobs: run: bun install --frozen-lockfile - name: Prepare wouter-preact (copy source files) run: cd packages/wouter-preact && npm run prepublishOnly + - name: Symlink npm to bun + run: | + sudo ln -sf $(which bun) /usr/local/bin/npm + sudo ln -sf $(which bunx) /usr/local/bin/npx - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 1b587976..afcf59d7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ projects that use wouter: **[Ultra](https://ultrajs.dev/)**, - [Can I initiate navigation from outside a component?](#can-i-initiate-navigation-from-outside-a-component) - [Can I use _wouter_ in my TypeScript project?](#can-i-use-wouter-in-my-typescript-project) - [How can add animated route transitions?](#how-can-add-animated-route-transitions) + - [How do I add view transitions to my app?](#how-do-i-add-view-transitions-to-my-app) - [Preact support?](#preact-support) - [Server-side Rendering support (SSR)?](#server-side-rendering-support-ssr) - [How do I configure the router to render a specific route in tests?](#how-do-i-configure-the-router-to-render-a-specific-route-in-tests) @@ -630,6 +631,16 @@ available options: - `hrefs: (href: boolean) => string` — a function for transforming `href` attribute of an `` element rendered by `Link`. It is used to support hash-based routing. By default, `href` attribute is the same as the `href` or `to` prop of a `Link`. A location hook can also define a `hook.hrefs` property, in this case the `href` will be inferred. +- **`aroundNav: (navigate, to, options) => void`** — a handler that wraps all navigation calls. Use this to intercept navigation and perform custom logic before and after the navigation occurs. You can modify navigation parameters, add side effects, or prevent navigation entirely. This is particularly useful for implementing [view transitions](#how-do-i-add-view-transitions-to-my-app). By default, it simply calls `navigate(to, options)`. + + ```js + const aroundNav = (navigate, to, options) => { + // do something before navigation + navigate(to, options); // perform navigation + // do something after navigation + }; + ``` + ## FAQ and Code Recipes ### I deploy my app to the subfolder. Can I specify a base path? @@ -837,6 +848,64 @@ export const MyComponent = ({ isVisible }) => { More complex examples involve using `useRoutes` hook (similar to how React Router does it), but wouter does not ship it out-of-the-box. Please refer to [this issue](https://github.com/molefrog/wouter/issues/414#issuecomment-1954192679) for the workaround. +### How do I use wouter with View Transitions API? + +Wouter works seamlessly with the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API), but you'll need to manually activate it. This is because view transitions require synchronous DOM rendering and must be wrapped in `flushSync` from `react-dom`. Following wouter's philosophy of staying lightweight and avoiding unnecessary dependencies, view transitions aren't built-in. However, there's a simple escape hatch to enable them: the `aroundNav` prop. + +```jsx +import { flushSync } from "react-dom"; +import { Router, type AroundNavHandler } from "wouter"; + +const aroundNav: AroundNavHandler = (navigate, to, options) => { + // Check if View Transitions API is supported + if (!document.startViewTransition) { + navigate(to, options); + return; + } + + document.startViewTransition(() => { + flushSync(() => { + navigate(to, options); + }); + }); +}; + +const App = () => ( + + {/* Your routes here */} + +); +``` + +You can also enable transitions selectively using the `transition` prop, which will be available in the `options` parameter: + +```jsx +// Enable transition for a specific link +About + +// Or programmatically +const [location, navigate] = useLocation(); +navigate("/about", { transition: true }); + +// Then check for it in your handler +const aroundNav: AroundNavHandler = (navigate, to, options) => { + if (!document.startViewTransition) { + navigate(to, options); + return; + } + + if (options?.transition) { + document.startViewTransition(() => { + flushSync(() => { + navigate(to, options); + }); + }); + } else { + navigate(to, options); + } +}; +``` + ### Preact support? Preact exports are available through a separate package named `wouter-preact` (or within the diff --git a/packages/magazin/client.tsx b/packages/magazin/client.tsx index 7984e137..8182c814 100644 --- a/packages/magazin/client.tsx +++ b/packages/magazin/client.tsx @@ -1,12 +1,33 @@ import { hydrateRoot } from "react-dom/client"; -import { Router } from "wouter"; +import { flushSync } from "react-dom"; +import { Router, type NavigateOptions, type AroundNavHandler } from "wouter"; import { HelmetProvider } from "@dr.pogodin/react-helmet"; import { App } from "./App"; +// Enable view transitions for navigation +const aroundNav: AroundNavHandler = (navigate, to, options) => { + // Feature detection for browsers that don't support View Transitions + if (!document.startViewTransition) { + navigate(to, options); + return; + } + + // Only use view transitions if explicitly requested + if (options?.transition) { + document.startViewTransition(() => { + flushSync(() => { + navigate(to, options); + }); + }); + } else { + navigate(to, options); + } +}; + hydrateRoot( document.body, - + diff --git a/packages/magazin/components/navbar.tsx b/packages/magazin/components/navbar.tsx index 3a045598..ff03835d 100644 --- a/packages/magazin/components/navbar.tsx +++ b/packages/magazin/components/navbar.tsx @@ -14,6 +14,7 @@ function NavLink({ return ( `text-sm font-medium ${ active ? "text-gray-900" : "text-gray-500 hover:text-gray-900" @@ -31,6 +32,7 @@ export function Navbar() {
@@ -43,6 +45,7 @@ export function Navbar() { diff --git a/packages/magazin/components/with-status-code.tsx b/packages/magazin/components/with-status-code.tsx index 51c5048d..31435bf4 100644 --- a/packages/magazin/components/with-status-code.tsx +++ b/packages/magazin/components/with-status-code.tsx @@ -10,9 +10,8 @@ export function WithStatusCode({ const router = useRouter(); // Set status code on SSR context if available - // Cast to any because statusCode is not yet in the official types if (router.ssrContext) { - (router.ssrContext as any).statusCode = code; + router.ssrContext.statusCode = code; } return <>{children}; diff --git a/packages/magazin/routes/home.tsx b/packages/magazin/routes/home.tsx index 9a9f286a..da38abd4 100644 --- a/packages/magazin/routes/home.tsx +++ b/packages/magazin/routes/home.tsx @@ -6,15 +6,16 @@ function ProductCard({ slug, brand, category, name, price, image }: Product) { return (
{name}
-
+
{brand} · {category}
diff --git a/packages/magazin/routes/products/[slug].tsx b/packages/magazin/routes/products/[slug].tsx index c7d9bcc3..5d08d167 100644 --- a/packages/magazin/routes/products/[slug].tsx +++ b/packages/magazin/routes/products/[slug].tsx @@ -28,12 +28,16 @@ export function ProductPage({ slug }: { slug: string }) {
-
+
{product.name} = ( to: Path, - options?: { replace?: boolean; state?: S } + options?: { replace?: boolean; state?: S; transition?: boolean } ) => void; type HookReturnValue = { hook: BaseLocationHook; navigate: Navigate }; diff --git a/packages/wouter-preact/types/router.d.ts b/packages/wouter-preact/types/router.d.ts index 8133e53b..56a0872e 100644 --- a/packages/wouter-preact/types/router.d.ts +++ b/packages/wouter-preact/types/router.d.ts @@ -11,6 +11,21 @@ export type Parser = ( loose?: boolean ) => { pattern: RegExp; keys: string[] }; +// Standard navigation options supported by all built-in location hooks +export type NavigateOptions = { + replace?: boolean; + state?: S; + /** Enable view transitions for this navigation (used with aroundNav) */ + transition?: boolean; +}; + +// Function that wraps navigate calls, useful for view transitions +export type AroundNavHandler = ( + navigate: (to: Path, options?: NavigateOptions) => void, + to: Path, + options?: NavigateOptions +) => void; + // the object returned from `useRouter` export interface RouterObject { readonly hook: BaseLocationHook; @@ -20,9 +35,19 @@ export interface RouterObject { readonly parser: Parser; readonly ssrPath?: Path; readonly ssrSearch?: SearchString; + readonly ssrContext?: SsrContext; readonly hrefs: HrefsFormatter; + readonly aroundNav: AroundNavHandler; } +// state captured during SSR render +export type SsrContext = { + // if a redirect was encountered, this will be populated with the path + redirectTo?: Path; + // HTTP status code to set for SSR response + statusCode?: number; +}; + // basic options to construct a router export type RouterOptions = { hook?: BaseLocationHook; @@ -31,5 +56,7 @@ export type RouterOptions = { parser?: Parser; ssrPath?: Path; ssrSearch?: SearchString; + ssrContext?: SsrContext; hrefs?: HrefsFormatter; + aroundNav?: AroundNavHandler; }; diff --git a/packages/wouter-preact/types/use-browser-location.d.ts b/packages/wouter-preact/types/use-browser-location.d.ts index 6485d76e..5b23c2e2 100644 --- a/packages/wouter-preact/types/use-browser-location.d.ts +++ b/packages/wouter-preact/types/use-browser-location.d.ts @@ -18,7 +18,7 @@ export const useHistoryState: () => T; export const navigate: ( to: string | URL, - options?: { replace?: boolean; state?: S } + options?: { replace?: boolean; state?: S; transition?: boolean } ) => void; /* diff --git a/packages/wouter-preact/types/use-hash-location.d.ts b/packages/wouter-preact/types/use-hash-location.d.ts index 3e649f5c..0fb32d3f 100644 --- a/packages/wouter-preact/types/use-hash-location.d.ts +++ b/packages/wouter-preact/types/use-hash-location.d.ts @@ -2,7 +2,7 @@ import { Path } from "./location-hook.js"; export function navigate( to: Path, - options?: { state?: S; replace?: boolean } + options?: { state?: S; replace?: boolean; transition?: boolean } ): void; export function useHashLocation(options?: { diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 07e6288e..957a7be6 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -40,6 +40,8 @@ const defaultRouter = { ssrContext: undefined, // customizes how `href` props are transformed for hrefs: (x) => x, + // wraps navigate calls, useful for view transitions + aroundNav: (n, t, o) => n(t, o), }; const RouterCtx = createContext(defaultRouter); @@ -71,7 +73,9 @@ const useLocationFromRouter = (router) => { // (This is achieved via `useEvent`.) return [ relativePath(router.base, location), - useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts)), + useEvent((to, opts) => + router.aroundNav(navigate, absolutePath(to, router.base), opts) + ), ]; }; @@ -270,6 +274,7 @@ export const Link = forwardRef((props, ref) => { /* eslint-disable no-unused-vars */ replace /* ignore nav props */, state /* ignore nav props */, + transition /* ignore nav props */, /* eslint-enable no-unused-vars */ ...restProps diff --git a/packages/wouter/test/router.test.tsx b/packages/wouter/test/router.test.tsx index b1abe151..6314c626 100644 --- a/packages/wouter/test/router.test.tsx +++ b/packages/wouter/test/router.test.tsx @@ -211,6 +211,49 @@ describe("`hrefs` prop", () => { }); }); +describe("`aroundNav` prop", () => { + it("sets the router's `aroundNav` property", () => { + const aroundNav = () => {}; + + const { result } = renderHook(() => useRouter(), { + wrapper: (props) => ( + {props.children} + ), + }); + + expect(result.current.aroundNav).toBe(aroundNav); + }); + + it("is inherited from parent router", () => { + const aroundNav = () => {}; + + const { result } = renderHook(() => useRouter(), { + wrapper: (props) => ( + + {props.children} + + ), + }); + + expect(result.current.aroundNav).toBe(aroundNav); + }); + + it("can be overridden in nested router", () => { + const parentAroundNav = () => {}; + const childAroundNav = () => {}; + + const { result } = renderHook(() => useRouter(), { + wrapper: (props) => ( + + {props.children} + + ), + }); + + expect(result.current.aroundNav).toBe(childAroundNav); + }); +}); + it("updates the context when settings are changed", () => { const state: { renders: number } & Partial> = { renders: 0, diff --git a/packages/wouter/test/view-transitions.test.tsx b/packages/wouter/test/view-transitions.test.tsx new file mode 100644 index 00000000..6c44c8bd --- /dev/null +++ b/packages/wouter/test/view-transitions.test.tsx @@ -0,0 +1,106 @@ +import { test, expect, describe, mock, afterEach } from "bun:test"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { Router, Link, useLocation, type AroundNavHandler } from "../src/index.js"; +import { memoryLocation } from "../src/memory-location.js"; + +afterEach(cleanup); + +describe("view transitions", () => { + test("Link with transition prop triggers aroundNav with transition in options", () => { + // 1. Setup: create aroundNav callback that captures calls + const aroundNav: AroundNavHandler = mock((navigate, to, options) => { + navigate(to, options); + }); + + const { hook } = memoryLocation({ path: "/" }); + + // 2. Render Link with transition prop + const { getByTestId } = render( + + + About + + + ); + + // 3. Click the link + fireEvent.click(getByTestId("link")); + + // 4. Verify aroundNav was called with transition: true in options + expect(aroundNav).toHaveBeenCalledTimes(1); + + const [navigateFn, to, options] = (aroundNav as ReturnType) + .mock.calls[0]; + + expect(typeof navigateFn).toBe("function"); + expect(to).toBe("/about"); + expect(options.transition).toBe(true); + }); + + test("useLocation navigate with transition option triggers aroundNav", () => { + const aroundNav: AroundNavHandler = mock((navigate, to, options) => { + navigate(to, options); + }); + + const { hook } = memoryLocation({ path: "/" }); + + const NavigateButton = () => { + const [, navigate] = useLocation(); + return ( + + ); + }; + + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId("btn")); + + expect(aroundNav).toHaveBeenCalledTimes(1); + + const [, to, options] = (aroundNav as ReturnType).mock.calls[0]; + expect(to).toBe("/about"); + expect(options.transition).toBe(true); + }); + + test("navigation does not happen if aroundNav doesn't call navigate", () => { + // aroundNav that does nothing + const aroundNav: AroundNavHandler = mock(() => {}); + + const { hook } = memoryLocation({ path: "/" }); + + const LocationDisplay = () => { + const [location] = useLocation(); + return {location}; + }; + + const { getByTestId } = render( + + + + About + + + ); + + // Verify initial location + expect(getByTestId("location").textContent).toBe("/"); + + // Click the link + fireEvent.click(getByTestId("link")); + + // aroundNav was called but didn't call navigate + expect(aroundNav).toHaveBeenCalledTimes(1); + + // Location should remain unchanged + expect(getByTestId("location").textContent).toBe("/"); + }); +}); diff --git a/packages/wouter/types/memory-location.d.ts b/packages/wouter/types/memory-location.d.ts index d40d44a0..04888b41 100644 --- a/packages/wouter/types/memory-location.d.ts +++ b/packages/wouter/types/memory-location.d.ts @@ -7,7 +7,7 @@ import { type Navigate = ( to: Path, - options?: { replace?: boolean; state?: S } + options?: { replace?: boolean; state?: S; transition?: boolean } ) => void; type HookReturnValue = { diff --git a/packages/wouter/types/router.d.ts b/packages/wouter/types/router.d.ts index 29ca142d..56a0872e 100644 --- a/packages/wouter/types/router.d.ts +++ b/packages/wouter/types/router.d.ts @@ -11,6 +11,21 @@ export type Parser = ( loose?: boolean ) => { pattern: RegExp; keys: string[] }; +// Standard navigation options supported by all built-in location hooks +export type NavigateOptions = { + replace?: boolean; + state?: S; + /** Enable view transitions for this navigation (used with aroundNav) */ + transition?: boolean; +}; + +// Function that wraps navigate calls, useful for view transitions +export type AroundNavHandler = ( + navigate: (to: Path, options?: NavigateOptions) => void, + to: Path, + options?: NavigateOptions +) => void; + // the object returned from `useRouter` export interface RouterObject { readonly hook: BaseLocationHook; @@ -20,13 +35,17 @@ export interface RouterObject { readonly parser: Parser; readonly ssrPath?: Path; readonly ssrSearch?: SearchString; + readonly ssrContext?: SsrContext; readonly hrefs: HrefsFormatter; + readonly aroundNav: AroundNavHandler; } // state captured during SSR render export type SsrContext = { // if a redirect was encountered, this will be populated with the path redirectTo?: Path; + // HTTP status code to set for SSR response + statusCode?: number; }; // basic options to construct a router @@ -39,4 +58,5 @@ export type RouterOptions = { ssrSearch?: SearchString; ssrContext?: SsrContext; hrefs?: HrefsFormatter; + aroundNav?: AroundNavHandler; }; diff --git a/packages/wouter/types/use-browser-location.d.ts b/packages/wouter/types/use-browser-location.d.ts index 6485d76e..5b23c2e2 100644 --- a/packages/wouter/types/use-browser-location.d.ts +++ b/packages/wouter/types/use-browser-location.d.ts @@ -18,7 +18,7 @@ export const useHistoryState: () => T; export const navigate: ( to: string | URL, - options?: { replace?: boolean; state?: S } + options?: { replace?: boolean; state?: S; transition?: boolean } ) => void; /* diff --git a/packages/wouter/types/use-hash-location.d.ts b/packages/wouter/types/use-hash-location.d.ts index 3e649f5c..0fb32d3f 100644 --- a/packages/wouter/types/use-hash-location.d.ts +++ b/packages/wouter/types/use-hash-location.d.ts @@ -2,7 +2,7 @@ import { Path } from "./location-hook.js"; export function navigate( to: Path, - options?: { state?: S; replace?: boolean } + options?: { state?: S; replace?: boolean; transition?: boolean } ): void; export function useHashLocation(options?: { diff --git a/specs/view-transitions-spec.md b/specs/view-transitions-spec.md new file mode 100644 index 00000000..63a938cb --- /dev/null +++ b/specs/view-transitions-spec.md @@ -0,0 +1,124 @@ +# View Transitions API in Wouter + +View Transitions are baseline available (as of Oct 2025). This doc describes the API for using them in wouter. + +Though the browser API is super simple, there are certain obstacles to overcome: + +## Problems + +- `startViewTransition` accepts a callback that must modify the DOM synchronously +- `setState` can't guarantee that the DOM will be modified synchronously +- There is `flushSync` but it requires `react-dom`, we want wouter to only depend on `react` +- Wouter uses `useSyncExternalStore` to react to events. In theory sending event inside `flushSync` + should trigger updates synchronously, but this is not 100% proven and could break + +## Solution + +Users implement their own behavior before and after navigate is called, so they can control +view transitions behavior. + +### Basic Implementation (enable view transitions by default) + +```js +import { flushSync } from "react-dom"; + +function aroundNav(navigate, ...navArgs) { + // Feature detection for older browsers + if (!document.startViewTransition) { + navigate(...navArgs); + return; + } + + document.startViewTransition(() => { + flushSync(() => { + navigate(...navArgs); + }); + }); +} + + + +; +``` + +Alternatively, with explicit arguments: + +```js +function aroundNav(navigate, to, options) { + if (!document.startViewTransition) { + navigate(to, options); + return; + } + + document.startViewTransition(() => { + flushSync(() => { + navigate(to, options); + }); + }); +} +``` + +### Granular control (opt-in transitions) + +For more control over when transitions occur: + +```jsx +// In your component + + Home +; + +// Or programmatically +const [location, navigate] = useLocation(); +navigate("/", { transition: true }); +``` + +**Note:** The `transition` prop is now part of wouter's type definitions (`NavigateOptions`) and is available on all location hooks (`useBrowserLocation`, `useHashLocation`, `memoryLocation`). When `` calls `navigate(targetPath, props)`, all props are automatically passed as navigation options, making them available in `aroundNav`. + +```js +import { flushSync } from "react-dom"; + +function aroundNav(navigate, to, options) { + // Feature detection + if (!document.startViewTransition) { + navigate(to, options); + return; + } + + // Only use transitions when explicitly requested + if (options?.transition) { + document.startViewTransition(() => { + flushSync(() => { + navigate(to, options); + }); + }); + } else { + navigate(to, options); + } +} +``` + +### TypeScript types + +Wouter provides built-in types for view transitions: + +```typescript +import type { NavigateOptions, AroundNavHandler } from "wouter"; + +// NavigateOptions already includes transition +const navigate = (to: string, options?: NavigateOptions) => { + // options.transition is available + // options.replace is available + // options.state is available +}; + +// AroundNavHandler type for the aroundNav callback +const aroundNav: AroundNavHandler = (navigate, to, options) => { + if (options?.transition) { + // handle transition + } + navigate(to, options); +}; +``` + +The `transition` option is included in `NavigateOptions` along with `replace` and `state`, and is available on all built-in location hooks.