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
4 changes: 4 additions & 0 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -630,6 +631,16 @@ available options:

- `hrefs: (href: boolean) => string` — a function for transforming `href` attribute of an `<a />` 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?
Expand Down Expand Up @@ -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 = () => (
<Router aroundNav={aroundNav}>
{/* Your routes here */}
</Router>
);
```

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
<Link to="/about" transition>About</Link>

// 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
Expand Down
25 changes: 23 additions & 2 deletions packages/magazin/client.tsx
Original file line number Diff line number Diff line change
@@ -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,
<HelmetProvider>
<Router>
<Router aroundNav={aroundNav}>
<App />
</Router>
</HelmetProvider>
Expand Down
3 changes: 3 additions & 0 deletions packages/magazin/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function NavLink({
return (
<Link
href={href}
transition
className={(active) =>
`text-sm font-medium ${
active ? "text-gray-900" : "text-gray-500 hover:text-gray-900"
Expand All @@ -31,6 +32,7 @@ export function Navbar() {
<div className="max-w-4xl mx-auto flex items-center justify-between px-6">
<Link
href="/"
transition
className="flex items-center gap-2 hover:bg-neutral-200/50 rounded-md p-1"
>
<Logo />
Expand All @@ -43,6 +45,7 @@ export function Navbar() {

<Link
href="/cart"
transition
className="relative flex items-center hover:bg-neutral-200/50 rounded-md p-1"
>
<i className="iconoir-cart text-xl" />
Expand Down
3 changes: 1 addition & 2 deletions packages/magazin/components/with-status-code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}</>;
Expand Down
7 changes: 4 additions & 3 deletions packages/magazin/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ function ProductCard({ slug, brand, category, name, price, image }: Product) {
return (
<Link
href={`/products/${slug}`}
className="rounded-lg bg-stone-100/75 overflow-hidden hover:bg-stone-200/75 transition-colors"
transition
className="overflow-hidden group"
>
<div
className="aspect-square p-12"
className="aspect-square p-12 bg-stone-100/75 group-hover:bg-stone-200/75 transition-colors rounded-t-lg"
style={{ viewTransitionName: `product-image-${slug}` }}
>
<img src={image} alt={name} className="object-cover w-full h-full" />
</div>
<div className="p-4">
<div className="p-4 bg-stone-100/75 rounded-b-lg group-hover:bg-stone-200/75 transition-colors">
<div className="text-sm text-neutral-400/75">
{brand} · {category}
</div>
Expand Down
6 changes: 5 additions & 1 deletion packages/magazin/routes/products/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ export function ProductPage({ slug }: { slug: string }) {
</Helmet>
<Link
href="/"
transition
className=" inline-flex items-center gap-2 hover:bg-neutral-100/75 rounded-md p-1.5 hover:text-neutral-900 mb-2"
>
<i className="iconoir-reply text-base" />
</Link>
<div className="grid grid-cols-3 gap-12">
<div className="bg-stone-100/75 rounded-lg aspect-square col-span-2 p-12">
<div
className="bg-stone-100/75 rounded-lg aspect-square col-span-2 p-12"
style={{ viewTransitionName: `product-image-${product.slug}` }}
>
<img
src={product.image}
alt={product.name}
Expand Down
12 changes: 12 additions & 0 deletions packages/magazin/styles.css
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
@import "tailwindcss";

/* View Transitions */
@view-transition {
navigation: auto;
}

/* Default: simple 0.25s cross-fade */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.2s;
animation-timing-function: ease;
}
2 changes: 1 addition & 1 deletion packages/wouter-preact/types/memory-location.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BaseLocationHook, Path } from "./location-hook.js";

type Navigate<S = any> = (
to: Path,
options?: { replace?: boolean; state?: S }
options?: { replace?: boolean; state?: S; transition?: boolean }
) => void;

type HookReturnValue = { hook: BaseLocationHook; navigate: Navigate };
Expand Down
27 changes: 27 additions & 0 deletions packages/wouter-preact/types/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S = any> = {
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;
Expand All @@ -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;
Expand All @@ -31,5 +56,7 @@ export type RouterOptions = {
parser?: Parser;
ssrPath?: Path;
ssrSearch?: SearchString;
ssrContext?: SsrContext;
hrefs?: HrefsFormatter;
aroundNav?: AroundNavHandler;
};
2 changes: 1 addition & 1 deletion packages/wouter-preact/types/use-browser-location.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const useHistoryState: <T = any>() => T;

export const navigate: <S = any>(
to: string | URL,
options?: { replace?: boolean; state?: S }
options?: { replace?: boolean; state?: S; transition?: boolean }
) => void;

/*
Expand Down
2 changes: 1 addition & 1 deletion packages/wouter-preact/types/use-hash-location.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Path } from "./location-hook.js";

export function navigate<S = any>(
to: Path,
options?: { state?: S; replace?: boolean }
options?: { state?: S; replace?: boolean; transition?: boolean }
): void;

export function useHashLocation(options?: {
Expand Down
7 changes: 6 additions & 1 deletion packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const defaultRouter = {
ssrContext: undefined,
// customizes how `href` props are transformed for <Link />
hrefs: (x) => x,
// wraps navigate calls, useful for view transitions
aroundNav: (n, t, o) => n(t, o),
};

const RouterCtx = createContext(defaultRouter);
Expand Down Expand Up @@ -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)
),
];
};

Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/wouter/test/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Router aroundNav={aroundNav}>{props.children}</Router>
),
});

expect(result.current.aroundNav).toBe(aroundNav);
});

it("is inherited from parent router", () => {
const aroundNav = () => {};

const { result } = renderHook(() => useRouter(), {
wrapper: (props) => (
<Router aroundNav={aroundNav}>
<Router base="/nested">{props.children}</Router>
</Router>
),
});

expect(result.current.aroundNav).toBe(aroundNav);
});

it("can be overridden in nested router", () => {
const parentAroundNav = () => {};
const childAroundNav = () => {};

const { result } = renderHook(() => useRouter(), {
wrapper: (props) => (
<Router aroundNav={parentAroundNav}>
<Router aroundNav={childAroundNav}>{props.children}</Router>
</Router>
),
});

expect(result.current.aroundNav).toBe(childAroundNav);
});
});

it("updates the context when settings are changed", () => {
const state: { renders: number } & Partial<ComponentProps<typeof Router>> = {
renders: 0,
Expand Down
Loading