waymark 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1404 -7
- package/dist/index.d.ts +196 -4
- package/dist/index.js +1 -4
- package/package.json +27 -15
- package/tsdown.config.ts +8 -0
- package/dist/react/components.d.ts +0 -20
- package/dist/react/components.js +0 -107
- package/dist/react/contexts.d.ts +0 -4
- package/dist/react/contexts.js +0 -3
- package/dist/react/hooks.d.ts +0 -13
- package/dist/react/hooks.js +0 -54
- package/dist/react/index.d.ts +0 -3
- package/dist/react/index.js +0 -3
- package/dist/route.d.ts +0 -27
- package/dist/route.js +0 -57
- package/dist/router/browser-history.d.ts +0 -11
- package/dist/router/browser-history.js +0 -48
- package/dist/router/index.d.ts +0 -3
- package/dist/router/index.js +0 -3
- package/dist/router/memory-history.d.ts +0 -18
- package/dist/router/memory-history.js +0 -39
- package/dist/router/router.d.ts +0 -31
- package/dist/router/router.js +0 -64
- package/dist/utils/index.d.ts +0 -5
- package/dist/utils/index.js +0 -5
- package/dist/utils/misc.d.ts +0 -17
- package/dist/utils/misc.js +0 -27
- package/dist/utils/path.d.ts +0 -9
- package/dist/utils/path.js +0 -19
- package/dist/utils/react.d.ts +0 -10
- package/dist/utils/react.js +0 -50
- package/dist/utils/router.d.ts +0 -37
- package/dist/utils/router.js +0 -1
- package/dist/utils/search.d.ts +0 -3
- package/dist/utils/search.js +0 -31
package/README.md
CHANGED
|
@@ -1,15 +1,1412 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" width="400" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
A lightweight, type-safe router for React that just works.
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/v/waymark?style=flat-square&color=000&labelColor=000" alt="npm version" /></a>
|
|
11
|
+
<a href="https://bundlephobia.com/package/waymark"><img src="https://img.shields.io/bundlephobia/minzip/waymark?style=flat-square&color=000&labelColor=000" alt="bundle size" /></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/dm/waymark?style=flat-square&color=000&labelColor=000" alt="downloads" /></a>
|
|
13
|
+
<a href="https://github.com/strblr/waymark/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/waymark?style=flat-square&color=000&labelColor=000" alt="license" /></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://strblr.github.io/waymark">📖 Documentation</a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
|
|
23
|
+
|
|
24
|
+
- **Fully type-safe** - Complete TypeScript inference for routes, params, and search queries
|
|
25
|
+
- **Zero config** - No build plugins, no CLI tools, no configuration files, very low boilerplate
|
|
26
|
+
- **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
|
|
27
|
+
- **3.5kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so less than 4kB total
|
|
28
|
+
- **Just works** - Define routes, get autocomplete everywhere
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Table of contents
|
|
33
|
+
|
|
34
|
+
- [Showcase](#showcase)
|
|
35
|
+
- [Installation](#installation)
|
|
36
|
+
- [Defining routes](#defining-routes)
|
|
37
|
+
- [Nested routes and layouts](#nested-routes-and-layouts)
|
|
38
|
+
- [Setting up the router](#setting-up-the-router)
|
|
39
|
+
- [Code organization](#code-organization)
|
|
40
|
+
- [Navigation](#navigation)
|
|
41
|
+
- [The Link component](#the-link-component)
|
|
42
|
+
- [Active state detection](#active-state-detection)
|
|
43
|
+
- [Link preloading](#link-preloading)
|
|
44
|
+
- [Programmatic navigation](#programmatic-navigation)
|
|
45
|
+
- [Declarative navigation](#declarative-navigation)
|
|
46
|
+
- [Path parameters](#path-parameters)
|
|
47
|
+
- [Search queries](#search-queries)
|
|
48
|
+
- [Lazy loading](#lazy-loading)
|
|
49
|
+
- [Error boundaries](#error-boundaries)
|
|
50
|
+
- [Suspense boundaries](#suspense-boundaries)
|
|
51
|
+
- [Route handles](#route-handles)
|
|
52
|
+
- [Route matching and ranking](#route-matching-and-ranking)
|
|
53
|
+
- [History implementations](#history-implementations)
|
|
54
|
+
- [Cookbook](#cookbook)
|
|
55
|
+
- [Scroll to top on navigation](#scroll-to-top-on-navigation)
|
|
56
|
+
- [Global link configuration](#global-link-configuration)
|
|
57
|
+
- [History middleware](#history-middleware)
|
|
58
|
+
- [View transitions](#view-transitions)
|
|
59
|
+
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
60
|
+
- [API reference](#api-reference)
|
|
61
|
+
- [Types](#types)
|
|
62
|
+
- [Router class](#router-class)
|
|
63
|
+
- [History interface](#history-interface)
|
|
64
|
+
- [Route class](#route-class)
|
|
65
|
+
- [Hooks](#hooks)
|
|
66
|
+
- [Components](#components)
|
|
67
|
+
- [Roadmap](#roadmap)
|
|
68
|
+
- [License](#license)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Showcase
|
|
73
|
+
|
|
74
|
+
Here's what a small routing setup looks like. Define some routes, render them, and get full type safety:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { route, RouterRoot, Link, useParams } from "waymark";
|
|
78
|
+
|
|
79
|
+
// Define routes
|
|
80
|
+
const home = route("/").component(() => <h1>Home</h1>);
|
|
81
|
+
|
|
82
|
+
const user = route("/users/:id").component(UserPage);
|
|
83
|
+
|
|
84
|
+
function UserPage() {
|
|
85
|
+
const { id } = useParams(user); // Fully typed
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<h1>User {id}</h1>
|
|
89
|
+
<Link to="/">Back to home</Link> {/* Also fully typed */}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Render
|
|
95
|
+
const routes = [home, user];
|
|
96
|
+
|
|
97
|
+
function App() {
|
|
98
|
+
return <RouterRoot routes={routes} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Register for type safety
|
|
102
|
+
declare module "waymark" {
|
|
103
|
+
interface Register {
|
|
104
|
+
routes: typeof routes;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
7
107
|
```
|
|
8
108
|
|
|
9
|
-
|
|
109
|
+
Links, navigation, params, search queries - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Installation
|
|
10
114
|
|
|
11
115
|
```bash
|
|
12
|
-
|
|
116
|
+
npm install waymark
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Waymark requires React 18 or higher.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Defining routes
|
|
124
|
+
|
|
125
|
+
Routes are created using the `route()` function, following the [builder pattern](https://dev.to/superviz/design-pattern-7-builder-pattern-10j4). You pass it a path and chain methods to configure the route.
|
|
126
|
+
|
|
127
|
+
The `.component()` method tells the route what to render when the path matches. It takes a React component and returns a new route instance with that component attached:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
import { route } from "waymark";
|
|
131
|
+
|
|
132
|
+
const home = route("/").component(HomePage);
|
|
133
|
+
const about = route("/about").component(AboutPage);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Routes support dynamic segments (path params) using the `:param` syntax:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
const required = route("/posts/:id");
|
|
140
|
+
const nested = route("/org/:orgId/team/:teamId");
|
|
141
|
+
const optional = route("/book/:title?");
|
|
142
|
+
const suffix = route("/movies/:title.(mp4|mov)");
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
And wildcard segments that capture everything after a certain point:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
const notFound = route("/*").component(NotFoundPage);
|
|
149
|
+
const files = route("/files/*").component(FileBrowser);
|
|
150
|
+
const optional = route("/books/*?").component(FileBrowser);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Route building is immutable: every method on a route returns a new route instance, which means you can branch off at any point to create variations or nested routes without affecting the original.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Nested routes and layouts
|
|
158
|
+
|
|
159
|
+
Nesting is the core mechanism for building layouts and route hierarchies in Waymark. When you call `.route()` on an existing route, you create a child route that inherits everything from the parent: its path as a prefix, its params, its components, its handles, and its search mappers.
|
|
160
|
+
|
|
161
|
+
Here's how it works. Let's start with a layout route:
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
const dashboard = route("/dashboard").component(DashboardLayout);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Then create child routes by calling `.route()` on it:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
const overview = dashboard.route("/").component(Overview);
|
|
171
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
172
|
+
const profile = dashboard.route("/profile").component(Profile);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The child routes combine the parent's path pattern with their own. So `overview` has the full pattern `/dashboard`, `settings` has `/dashboard/settings`, and `profile` has `/dashboard/profile`.
|
|
176
|
+
|
|
177
|
+
For this to work, the parent component must render an `<Outlet />` where these children should appear:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
function DashboardLayout() {
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
<Sidebar />
|
|
184
|
+
<main>
|
|
185
|
+
<Outlet />
|
|
186
|
+
</main>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. The layout stays mounted (and doesn't even rerender) as users navigate between child routes, preserving any state it holds.
|
|
193
|
+
|
|
194
|
+
You can nest as deep as you need:
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
const app = route("/").component(AppShell);
|
|
198
|
+
const dashboard = app.route("/dashboard").component(DashboardLayout);
|
|
199
|
+
const settings = dashboard.route("/settings").component(SettingsLayout);
|
|
200
|
+
const security = settings.route("/security").component(SecurityPage);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
For the path `/dashboard/settings/security`, this renders:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
AppShell
|
|
207
|
+
└── DashboardLayout
|
|
208
|
+
└── SettingsLayout
|
|
209
|
+
└── SecurityPage
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Each level must include an `<Outlet />` to render the next level.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Setting up the router
|
|
217
|
+
|
|
218
|
+
Before setting up the router, you need to collect your navigable routes into an array. When building nested route hierarchies, you'll often create intermediate parent routes solely for grouping and shared layouts. These intermediate routes shouldn't be included in your routes array - only the final, navigable routes should be:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// Intermediate route used for hierarchy
|
|
222
|
+
const layout = route("/").component(Layout);
|
|
223
|
+
|
|
224
|
+
// Navigable routes that users can actually visit
|
|
225
|
+
const home = layout.route("/").component(Home);
|
|
226
|
+
const about = layout.route("/about").component(About);
|
|
227
|
+
|
|
228
|
+
// Collect only the navigable routes
|
|
229
|
+
const routes = [home, about]; // ✅ Don't include `layout`
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
This keeps your route list clean and makes sure that only actual pages can be matched and appear in autocomplete when using `Link` or `navigate`. The intermediate routes still exist - they're part of the hierarchy - they just aren't directly navigable.
|
|
233
|
+
|
|
234
|
+
The `RouterRoot` component is the entry point to Waymark. It listens to URL changes, matches the current path against your routes, and renders the matching route's component hierarchy.
|
|
235
|
+
|
|
236
|
+
There are two ways to set it up. The simplest is passing your routes array directly to `RouterRoot`. This creates a router instance internally (accessible via `useRouter`):
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
import { RouterRoot } from "waymark";
|
|
240
|
+
|
|
241
|
+
const routes = [home, about];
|
|
242
|
+
|
|
243
|
+
function App() {
|
|
244
|
+
return <RouterRoot routes={routes} />;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
You can also pass a `basePath` if your app lives under a subpath:
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
<RouterRoot routes={routes} basePath="/my-app" />
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The second approach is to create a `Router` instance outside of React. This is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context, or when you don't want to bother with `useRouter` / `useNavigate`:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { Router, RouterRoot } from "waymark";
|
|
258
|
+
|
|
259
|
+
const router = new Router({ routes });
|
|
260
|
+
|
|
261
|
+
// Now you can navigate from anywhere
|
|
262
|
+
router.navigate({ to: "/about" });
|
|
263
|
+
|
|
264
|
+
// And pass the instance to RouterRoot
|
|
265
|
+
function App() {
|
|
266
|
+
return <RouterRoot router={router} />;
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
For full type safety across your app, register your routes using TypeScript's module augmentation. This is a required step for proper autocompletion and type checking:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
declare module "waymark" {
|
|
274
|
+
interface Register {
|
|
275
|
+
routes: typeof routes;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs will know exactly which routes exist and what input they expect, and you're good to go.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Code organization
|
|
285
|
+
|
|
286
|
+
There's no prescribed way to organize your routing code. Since Waymark isn't file-based routing, the structure is entirely up to you.
|
|
287
|
+
|
|
288
|
+
That said, here's a pattern that tends to work well: define each route and its component in the same file, then export the route. This keeps everything related to that page in one place:
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
// pages/home.tsx
|
|
292
|
+
import { route } from "waymark";
|
|
293
|
+
|
|
294
|
+
export const home = route("/").component(Home);
|
|
295
|
+
|
|
296
|
+
function Home() {
|
|
297
|
+
return <div>Home page</div>;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
// pages/about.tsx
|
|
303
|
+
import { route } from "waymark";
|
|
304
|
+
|
|
305
|
+
export const about = route("/about").component(About);
|
|
306
|
+
|
|
307
|
+
function About() {
|
|
308
|
+
return <div>About page</div>;
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Then in your root app component file, import all the routes, register them with module augmentation, and render `RouterRoot`:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
// app.tsx
|
|
316
|
+
import { RouterRoot } from "waymark";
|
|
317
|
+
import { home } from "./pages/home";
|
|
318
|
+
import { about } from "./pages/about";
|
|
319
|
+
|
|
320
|
+
const routes = [home, about];
|
|
321
|
+
|
|
322
|
+
export function App() {
|
|
323
|
+
return <RouterRoot routes={routes} />;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
declare module "waymark" {
|
|
327
|
+
interface Register {
|
|
328
|
+
routes: typeof routes;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
But again, this is just one approach. You could keep all routes in a single file, split them by feature, organize them by route depth, whatever fits your project. Waymark doesn't care where the route objects come from or how you structure your files.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Navigation
|
|
338
|
+
|
|
339
|
+
### The Link component
|
|
340
|
+
|
|
341
|
+
The `Link` component renders an anchor tag that navigates without a full page reload. It accepts a `to` prop that can be either a route pattern string or a route object:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
<Link to="/about">About</Link>
|
|
345
|
+
<Link to={about}>About</Link>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
When the route has non-optional path params, you must provide the `params` prop:
|
|
349
|
+
|
|
350
|
+
```tsx
|
|
351
|
+
<Link to="/posts/:id" params={{ id: postId }}>
|
|
352
|
+
View post
|
|
353
|
+
</Link>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
And if the route has search params defined, you can pass them too:
|
|
357
|
+
|
|
358
|
+
```tsx
|
|
359
|
+
<Link to={userProfile} params={{ id: "42" }} search={{ tab: "posts" }}>
|
|
360
|
+
User posts
|
|
361
|
+
</Link>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
To replace the current history entry instead of pushing a new one, use `replace`:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
<Link to="/login" replace>
|
|
368
|
+
Login
|
|
369
|
+
</Link>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
You can also pass arbitrary state that will be available via `useLocation().state`:
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
<Link to="/checkout" state={{ from: "cart" }}>
|
|
376
|
+
Checkout
|
|
377
|
+
</Link>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The `asChild` prop lets you use your own component while keeping Link's behavior:
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
<Link to="/profile" asChild>
|
|
384
|
+
<MyCustomAnchor>Go to profile</MyCustomAnchor>
|
|
385
|
+
</Link>
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Active state detection
|
|
389
|
+
|
|
390
|
+
Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
|
|
391
|
+
|
|
392
|
+
By default, a link is considered active if the current path starts with the link's target (called "loose matching"). This means a link to `/dashboard` stays active on `/dashboard/settings`. To require an exact match, use the `strict` prop:
|
|
393
|
+
|
|
394
|
+
```tsx
|
|
395
|
+
<Link to="/dashboard">Active on /dashboard and child routes</Link>
|
|
396
|
+
<Link strict to="/dashboard">Active only on /dashboard</Link>
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
You can style active links using the data attribute in CSS:
|
|
400
|
+
|
|
401
|
+
```css
|
|
402
|
+
.nav-link[data-active="true"] {
|
|
403
|
+
font-weight: bold;
|
|
404
|
+
color: blue;
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Or use the `activeClassName` and `activeStyle` props directly:
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
<Link
|
|
412
|
+
to="/dashboard"
|
|
413
|
+
className="nav-link"
|
|
414
|
+
activeClassName="active"
|
|
415
|
+
style={{ opacity: 0.7 }}
|
|
416
|
+
activeStyle={{ opacity: 1 }}
|
|
417
|
+
>
|
|
418
|
+
Dashboard
|
|
419
|
+
</Link>
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Link preloading
|
|
423
|
+
|
|
424
|
+
When a route has preloaders, e.g. when using lazy-loaded routes, you can preload them before the user actually navigates. This makes the subsequent navigation instant. The `preload` prop controls when preloading happens:
|
|
425
|
+
|
|
426
|
+
**`preload="intent"`** triggers preloading when the user shows intent to navigate by hovering over the link or focusing it. This is the most common choice as it balances eager loading with not wasting bandwidth:
|
|
427
|
+
|
|
428
|
+
```tsx
|
|
429
|
+
<Link to="/heavy-page" preload="intent">
|
|
430
|
+
Heavy page
|
|
431
|
+
</Link>
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**`preload="render"`** preloads as soon as the link mounts. Use this for routes you're confident the user will visit:
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
<Link to="/next-step" preload="render">
|
|
438
|
+
Next step
|
|
439
|
+
</Link>
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**`preload="viewport"`** uses an Intersection Observer to preload when the link scrolls into view. Good for links further down the page and mobile:
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
<Link to="/section" preload="viewport">
|
|
446
|
+
See more
|
|
447
|
+
</Link>
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**`preload={false}`** disables preloading entirely. This is the default.
|
|
451
|
+
|
|
452
|
+
You can also preload routes programmatically by calling the route's `.preload()` method:
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
userProfile.preload();
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Programmatic navigation
|
|
459
|
+
|
|
460
|
+
For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
import { useNavigate } from "waymark";
|
|
464
|
+
|
|
465
|
+
function LoginForm() {
|
|
466
|
+
const navigate = useNavigate();
|
|
467
|
+
|
|
468
|
+
const onSubmit = async () => {
|
|
469
|
+
await login();
|
|
470
|
+
navigate({ to: "/dashboard" });
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// ...
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The navigate function accepts the same options as `Link`:
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
navigate({ to: userProfile, params: { id: "42" }, search: { tab: "posts" } });
|
|
481
|
+
navigate({ to: "/login", replace: true });
|
|
482
|
+
navigate({ to: "/checkout", state: { from: "cart" } });
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
To go back or forward in history, pass a number:
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
navigate(-1); // Go back
|
|
489
|
+
navigate(1); // Go forward
|
|
490
|
+
navigate(-2); // Go back two steps
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
You can also access the router directly via `useRouter()` (or import the router if created outside of React) and call its `navigate` method, which works the same way.
|
|
494
|
+
|
|
495
|
+
For unsafe navigation that bypasses type checking, you can pass `url` instead of `to`, `params` and `search`. This is useful when you don't know the target URL statically (e.g. external redirects):
|
|
496
|
+
|
|
497
|
+
```tsx
|
|
498
|
+
const router = useRouter();
|
|
499
|
+
|
|
500
|
+
// Type-safe navigation
|
|
501
|
+
router.navigate({ to: userProfile, params: { id: "42" } });
|
|
502
|
+
|
|
503
|
+
// Unsafe navigation - no type checking
|
|
504
|
+
router.navigate({ url: "/some/unknown/path" });
|
|
505
|
+
router.navigate({ url: "/callback", replace: true, state: { data: 123 } });
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Declarative navigation
|
|
509
|
+
|
|
510
|
+
For redirects triggered by rendering rather than events, use the `Navigate` component. It navigates as soon as it mounts, making it useful for conditional redirects based on application state:
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
import { Navigate } from "waymark";
|
|
514
|
+
|
|
515
|
+
function ProtectedPage() {
|
|
516
|
+
const { isAuthenticated } = useAuth();
|
|
517
|
+
|
|
518
|
+
if (!isAuthenticated) {
|
|
519
|
+
return <Navigate to="/login" replace />;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return <div>Protected content</div>;
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
The `Navigate` component accepts the same props as the `Link` component, minus the visual and interaction properties. You can pass route patterns, params, search parameters, and state:
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
<Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
|
|
530
|
+
<Navigate to="/home" replace />
|
|
531
|
+
<Navigate to={checkout} state={{ from: "cart" }} />
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation is triggered before the browser repaints the screen.
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Path parameters
|
|
539
|
+
|
|
540
|
+
Dynamic segments in route patterns become typed path parameters. Define them with a colon prefix. They can also be made optional.
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
const post = route("/posts/:id").component(PostPage);
|
|
544
|
+
const comment = route("/posts/:postId/comments/:commentId?").component(
|
|
545
|
+
CommentPage
|
|
546
|
+
);
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Access parameters with `useParams`, passing the route pattern or object as an argument:
|
|
550
|
+
|
|
551
|
+
```tsx
|
|
552
|
+
function PostPage() {
|
|
553
|
+
const { id } = useParams(post);
|
|
554
|
+
// id is typed as string
|
|
555
|
+
|
|
556
|
+
const { id } = useParams("/posts/:id");
|
|
557
|
+
// Also works
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function CommentPage() {
|
|
561
|
+
const { postId, commentId } = useParams(comment);
|
|
562
|
+
// postId: string
|
|
563
|
+
// commentId?: string | undefined
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
|
|
568
|
+
|
|
569
|
+
```tsx
|
|
570
|
+
const files = route("/files/*").component(FileBrowser);
|
|
571
|
+
|
|
572
|
+
function FileBrowser() {
|
|
573
|
+
const params = useParams(files);
|
|
574
|
+
const path = params["*"]; // e.g., "documents/report.pdf"
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## Search queries
|
|
581
|
+
|
|
582
|
+
Search parameters (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://github.com/standard-schema/standard-schema) validator like Zod, or a plain mapping function.
|
|
583
|
+
|
|
584
|
+
With Zod:
|
|
585
|
+
|
|
586
|
+
```tsx
|
|
587
|
+
import { z } from "zod";
|
|
588
|
+
|
|
589
|
+
const searchPage = route("/search")
|
|
590
|
+
.search(
|
|
591
|
+
z.object({
|
|
592
|
+
q: z.string().catch(""),
|
|
593
|
+
page: z.coerce.number().catch(1)
|
|
594
|
+
})
|
|
595
|
+
)
|
|
596
|
+
.component(SearchPage);
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
With a plain function:
|
|
600
|
+
|
|
601
|
+
```tsx
|
|
602
|
+
const searchPage = route("/search")
|
|
603
|
+
.search(raw => ({
|
|
604
|
+
q: String(raw.q ?? ""),
|
|
605
|
+
page: Number(raw.page ?? 1)
|
|
606
|
+
}))
|
|
607
|
+
.component(SearchPage);
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
function SearchPage() {
|
|
614
|
+
const [search, setSearch] = useSearch(searchPage);
|
|
615
|
+
// search.q: string
|
|
616
|
+
// search.page: number
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
The setter merges your updates with existing values:
|
|
621
|
+
|
|
622
|
+
```tsx
|
|
623
|
+
setSearch({ page: 2 }); // Only updates page
|
|
624
|
+
setSearch(prev => ({ page: prev.page + 1 })); // Increment page
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Pass `true` as the second argument to replace the history entry instead of pushing:
|
|
628
|
+
|
|
629
|
+
```tsx
|
|
630
|
+
setSearch({ page: 1 }, true);
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
**JSON-first search params**
|
|
634
|
+
|
|
635
|
+
Waymark uses a JSON-first approach for search parameters, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
636
|
+
|
|
637
|
+
- Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
|
|
638
|
+
- Everything else is JSON-encoded (and URL-encoded):
|
|
639
|
+
- `true` → `?enabled=true` → `true`
|
|
640
|
+
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
641
|
+
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
642
|
+
- `42` → `count=42` → `42`
|
|
643
|
+
|
|
644
|
+
This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, Waymark automatically parses JSON values back to their original types.
|
|
645
|
+
|
|
646
|
+
The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation with Zod or a mapping function is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Lazy loading
|
|
651
|
+
|
|
652
|
+
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
653
|
+
|
|
654
|
+
```tsx
|
|
655
|
+
const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
The imported module should use a default export:
|
|
659
|
+
|
|
660
|
+
```tsx
|
|
661
|
+
// AnalyticsPage.tsx
|
|
662
|
+
export default function AnalyticsPage() { ... }
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
|
|
666
|
+
|
|
667
|
+
```tsx
|
|
668
|
+
const analytics = route("/analytics").lazy(() =>
|
|
669
|
+
import("./AnalyticsPage").then(m => m.AnalyticsPage)
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// AnalyticsPage.tsx
|
|
673
|
+
export function AnalyticsPage() { ... }
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Lazy routes work seamlessly with nesting. Child routes inherit the lazy-loaded parent's components:
|
|
677
|
+
|
|
678
|
+
```tsx
|
|
679
|
+
const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
|
|
680
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
When navigating to `/dashboard/settings`, React loads the dashboard component first, then renders settings inside it. The Dashboard component must include an `<Outlet />` for the child route to appear.
|
|
684
|
+
|
|
685
|
+
See [Link preloading](#link-preloading) for ways to load these components before the user navigates.
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
## Error boundaries
|
|
690
|
+
|
|
691
|
+
Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
|
|
692
|
+
|
|
693
|
+
```tsx
|
|
694
|
+
const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
|
|
695
|
+
|
|
696
|
+
function ErrorFallback({ error }: { error: unknown }) {
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
<h2>Something went wrong</h2>
|
|
700
|
+
<pre>{String(error)}</pre>
|
|
701
|
+
<button onClick={() => window.location.reload()}>Retry</button>
|
|
702
|
+
</div>
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
|
|
708
|
+
|
|
709
|
+
```tsx
|
|
710
|
+
const app = route("/").error(ErrorPage).component(AppLayout);
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
The error boundary automatically resets when navigation occurs, giving the new route a fresh start.
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## Suspense boundaries
|
|
718
|
+
|
|
719
|
+
When using lazy loading or React's `use()` hook for data fetching, you may want to add suspense boundaries to show loading states. Add them with `.suspense()`:
|
|
720
|
+
|
|
721
|
+
```tsx
|
|
722
|
+
const dataPage = route("/data")
|
|
723
|
+
.suspense(LoadingPage)
|
|
724
|
+
.lazy(() => import("./DataPage"));
|
|
725
|
+
|
|
726
|
+
function LoadingPage() {
|
|
727
|
+
return <div>Loading...</div>;
|
|
728
|
+
}
|
|
13
729
|
```
|
|
14
730
|
|
|
15
|
-
|
|
731
|
+
The suspense boundary wraps everything below it in the route tree. Place it strategically to control which parts of the UI show a loading state.
|
|
732
|
+
|
|
733
|
+
You can combine suspense with error boundaries:
|
|
734
|
+
|
|
735
|
+
```tsx
|
|
736
|
+
const riskyPage = route("/risky")
|
|
737
|
+
.error(ErrorFallback)
|
|
738
|
+
.suspense(Loading)
|
|
739
|
+
.lazy(() => import("./RiskyPage"));
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Note: React 19 has a [known throttling behavior](https://github.com/facebook/react/issues/31819) where suspense fallback hiding is delayed by up to 300ms. This can make fast-loading content feel slower than it is. Keep this in mind when designing loading experiences.
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## Route handles
|
|
747
|
+
|
|
748
|
+
Handles let you attach static arbitrary metadata to routes. This is useful for breadcrumbs, page titles, access control flags, or any other static data you want to associate with a route.
|
|
749
|
+
|
|
750
|
+
Define handles with `.handle()`:
|
|
751
|
+
|
|
752
|
+
```tsx
|
|
753
|
+
const dashboard = route("/dashboard")
|
|
754
|
+
.handle({ title: "Dashboard", requiresAuth: true })
|
|
755
|
+
.component(DashboardPage);
|
|
756
|
+
|
|
757
|
+
const settings = dashboard
|
|
758
|
+
.route("/settings")
|
|
759
|
+
.handle({ title: "Settings" })
|
|
760
|
+
.component(SettingsPage);
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Access all handles from the current route chain with `useHandles()`. It returns an array of all handles from the root down to the current matching route. This hook can be called from anywhere inside the route tree:
|
|
764
|
+
|
|
765
|
+
```tsx
|
|
766
|
+
function Breadcrumbs() {
|
|
767
|
+
const handles = useHandles();
|
|
768
|
+
return (
|
|
769
|
+
<nav>
|
|
770
|
+
{handles.map((h, i) => (
|
|
771
|
+
<span key={i}>
|
|
772
|
+
{h.title}
|
|
773
|
+
{i < handles.length - 1 && " / "}
|
|
774
|
+
</span>
|
|
775
|
+
))}
|
|
776
|
+
</nav>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
On `/dashboard/settings`, this renders "Dashboard / Settings". You can place the `Breadcrumbs` component anywhere in your app layout, and it will always reflect the current route's handle chain.
|
|
782
|
+
|
|
783
|
+
For type safety, register your handle type in the module augmentation:
|
|
784
|
+
|
|
785
|
+
```tsx
|
|
786
|
+
declare module "waymark" {
|
|
787
|
+
interface Register {
|
|
788
|
+
routes: typeof routes;
|
|
789
|
+
handle: { title: string; requiresAuth?: boolean };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
---
|
|
795
|
+
|
|
796
|
+
## Route matching and ranking
|
|
797
|
+
|
|
798
|
+
When a user navigates to a URL, Waymark needs to determine which route matches. Since multiple routes can potentially match the same path (think `/users/:id` vs `/users/new`), Waymark uses a ranking algorithm to pick the most specific one.
|
|
799
|
+
|
|
800
|
+
Each segment in a route pattern gets a weight:
|
|
801
|
+
|
|
802
|
+
| Segment type | Weight | Example |
|
|
803
|
+
| ------------ | ------ | -------------------------- |
|
|
804
|
+
| Static | 2 | `users`, `settings`, `new` |
|
|
805
|
+
| Dynamic | 1 | `:id`, `:slug?` |
|
|
806
|
+
| Wildcard | 0 | `*`, `*?` |
|
|
807
|
+
|
|
808
|
+
When multiple routes match, Waymark compares them segment by segment from left to right. The route with the higher weight at the first differing position wins. If weights are equal, it continues to the next segment.
|
|
809
|
+
|
|
810
|
+
Consider these routes:
|
|
811
|
+
|
|
812
|
+
```tsx
|
|
813
|
+
const userNew = route("/users/new").component(NewUser);
|
|
814
|
+
const userProfile = route("/users/:id").component(UserProfile);
|
|
815
|
+
const userCatchAll = route("/users/*").component(UserCatchAll);
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
For the path `/users/new`, all three would match. Waymark ranks them to pick the most specific:
|
|
819
|
+
|
|
820
|
+
```
|
|
821
|
+
/users/new → [static, static] → weights [2, 2] ✓ Wins
|
|
822
|
+
/users/:id → [static, dynamic] → weights [2, 1]
|
|
823
|
+
/users/* → [static, wildcard] → weights [2, 0]
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
The first segment (`users`) is static in all routes, so they all score 2 there. The second segment differs: `new` is static (2), `:id` is dynamic (1), and `*` is a wildcard (0). So `/users/new` wins.
|
|
827
|
+
|
|
828
|
+
For the path `/users/42`:
|
|
829
|
+
|
|
830
|
+
```
|
|
831
|
+
/users/new → doesn't match
|
|
832
|
+
/users/:id → [static, dynamic] → weights [2, 1] ✓ Wins
|
|
833
|
+
/users/* → [static, wildcard] → weights [2, 0]
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
This ranking algorithm means you don't need to order your routes array carefully. Define them in any order and Waymark figures out the right match regardless:
|
|
837
|
+
|
|
838
|
+
```tsx
|
|
839
|
+
const routes = [
|
|
840
|
+
route("/posts/*").component(NotFound),
|
|
841
|
+
route("/posts/:id").component(PostPage),
|
|
842
|
+
route("/posts/new").component(NewPost)
|
|
843
|
+
]; // Order doesn't matter
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## History implementations
|
|
849
|
+
|
|
850
|
+
History is an abstraction layer that sits between the router and the actual low-level navigation logic. It handles reading and updating the current location, managing navigation state, and notifying when the URL changes. This abstraction allows Waymark to work in different environments (browser, hash-based, in-memory, server-side, tests, etc.) without changing the router's core logic. You can switch between environments simply by swapping the history implementation - the rest of your app stays exactly the same.
|
|
851
|
+
|
|
852
|
+
Waymark supports three history modes out of the box.
|
|
853
|
+
|
|
854
|
+
**BrowserHistory** is the default. It uses the browser's History API, working with browser URLs like `/posts/123`:
|
|
855
|
+
|
|
856
|
+
```tsx
|
|
857
|
+
import { BrowserHistory } from "waymark";
|
|
858
|
+
|
|
859
|
+
<RouterRoot routes={routes} history={new BrowserHistory()} />;
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
**HashHistory** stores the path in the URL hash, producing URLs like `/#/posts/123`. This is useful for static file hosting where you can't configure server-side routing:
|
|
863
|
+
|
|
864
|
+
```tsx
|
|
865
|
+
import { HashHistory } from "waymark";
|
|
866
|
+
|
|
867
|
+
<RouterRoot routes={routes} history={new HashHistory()} />;
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
**MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering, or embedded applications:
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
import { MemoryHistory } from "waymark";
|
|
874
|
+
|
|
875
|
+
<RouterRoot routes={routes} history={new MemoryHistory("/initial/path")} />;
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
All history implementations conform to the `HistoryLike` interface, so you can create custom implementations if needed.
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
## Cookbook
|
|
883
|
+
|
|
884
|
+
### Scroll to top on navigation
|
|
885
|
+
|
|
886
|
+
Create a component that scrolls to top when the path changes and include it in your layout:
|
|
887
|
+
|
|
888
|
+
```tsx
|
|
889
|
+
import { useLocation } from "waymark";
|
|
890
|
+
import { useEffect } from "react";
|
|
891
|
+
|
|
892
|
+
function ScrollToTop() {
|
|
893
|
+
const { path } = useLocation();
|
|
894
|
+
useEffect(() => window.scrollTo(0, 0), [path]);
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function AppLayout() {
|
|
899
|
+
return (
|
|
900
|
+
<>
|
|
901
|
+
<ScrollToTop />
|
|
902
|
+
<Header />
|
|
903
|
+
<Outlet />
|
|
904
|
+
</>
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
### Global link configuration
|
|
910
|
+
|
|
911
|
+
Set defaults for all `Link` components using `defaultLinkOptions` on the router. This is useful for consistent styling and preload behavior across your app:
|
|
912
|
+
|
|
913
|
+
```tsx
|
|
914
|
+
<RouterRoot
|
|
915
|
+
routes={routes}
|
|
916
|
+
defaultLinkOptions={{
|
|
917
|
+
preload: "intent",
|
|
918
|
+
className: "app-link",
|
|
919
|
+
activeClassName: "active"
|
|
920
|
+
}}
|
|
921
|
+
/>
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Individual links can override any of these defaults by passing their own props.
|
|
925
|
+
|
|
926
|
+
### History middleware
|
|
927
|
+
|
|
928
|
+
This is a design pattern rather than a feature. You can extend history behavior for logging, analytics, or other side effects by monkey-patching the history instance:
|
|
929
|
+
|
|
930
|
+
```tsx
|
|
931
|
+
function withAnalytics(history: HistoryLike): HistoryLike {
|
|
932
|
+
const { push } = history;
|
|
933
|
+
|
|
934
|
+
history.push = options => {
|
|
935
|
+
analytics.track("page_view", { url: options.url });
|
|
936
|
+
push(options);
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
return history;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function withLogging(history: HistoryLike): HistoryLike {
|
|
943
|
+
const { go, push } = history;
|
|
944
|
+
|
|
945
|
+
history.go = delta => {
|
|
946
|
+
console.log("Navigate", delta > 0 ? "forward" : "back");
|
|
947
|
+
go(delta);
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
history.push = options => {
|
|
951
|
+
console.log("Navigate to", options.url);
|
|
952
|
+
push(options);
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
return history;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Compose middlewares
|
|
959
|
+
const router = new Router({
|
|
960
|
+
routes,
|
|
961
|
+
history: withLogging(withAnalytics(new BrowserHistory()))
|
|
962
|
+
});
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
### View transitions
|
|
966
|
+
|
|
967
|
+
Use the View Transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
|
|
968
|
+
|
|
969
|
+
```tsx
|
|
970
|
+
import { flushSync } from "react-dom";
|
|
971
|
+
import { BrowserHistory, type HistoryLike } from "waymark";
|
|
972
|
+
|
|
973
|
+
const withViewTransition = (history: HistoryLike) => {
|
|
974
|
+
const { go, push } = history;
|
|
975
|
+
|
|
976
|
+
const wrap = (fn: () => void) => {
|
|
977
|
+
return !document.startViewTransition
|
|
978
|
+
? fn()
|
|
979
|
+
: document.startViewTransition(() => flushSync(fn));
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
history.go = delta => wrap(() => go(delta));
|
|
983
|
+
history.push = options => wrap(() => push(options));
|
|
984
|
+
return history;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const history = withViewTransition(new BrowserHistory());
|
|
988
|
+
|
|
989
|
+
function App() {
|
|
990
|
+
return <RouterRoot routes={routes} history={history} />;
|
|
991
|
+
}
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
Add CSS to control the transition:
|
|
995
|
+
|
|
996
|
+
```css
|
|
997
|
+
::view-transition-old(root),
|
|
998
|
+
::view-transition-new(root) {
|
|
999
|
+
animation-duration: 200ms;
|
|
1000
|
+
}
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
|
|
1004
|
+
|
|
1005
|
+
### Matching a route anywhere
|
|
1006
|
+
|
|
1007
|
+
Use `useMatch` to check if a route matches the current path from anywhere in your component tree. You can pass either a route pattern string or a route object, just like with `Link` and `navigate`. This is useful for conditional rendering, styling, access control, and more. It's also used internally by `useParams` and `Link`.
|
|
1008
|
+
|
|
1009
|
+
```tsx
|
|
1010
|
+
import { useMatch } from "waymark";
|
|
1011
|
+
|
|
1012
|
+
const dashboard = route("/dashboard").component(Dashboard);
|
|
1013
|
+
const settings = route("/settings").component(Settings);
|
|
1014
|
+
|
|
1015
|
+
function Sidebar() {
|
|
1016
|
+
// Using route patterns
|
|
1017
|
+
const dashboardMatch = useMatch({ from: "/dashboard" });
|
|
1018
|
+
const settingsMatch = useMatch({ from: "/settings", strict: true });
|
|
1019
|
+
|
|
1020
|
+
// Using route objects
|
|
1021
|
+
const dashboardMatch = useMatch({ from: dashboard });
|
|
1022
|
+
const settingsMatch = useMatch({ from: settings, strict: true });
|
|
1023
|
+
|
|
1024
|
+
return (
|
|
1025
|
+
<nav>
|
|
1026
|
+
{dashboardMatch && <DashboardMenu />}
|
|
1027
|
+
{settingsMatch && <SettingsSubmenu />}
|
|
1028
|
+
</nav>
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
You can also filter by param values to match only specific instances:
|
|
1034
|
+
|
|
1035
|
+
```tsx
|
|
1036
|
+
const adminMatch = useMatch({
|
|
1037
|
+
from: "/users/:id",
|
|
1038
|
+
params: { id: "admin" }
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
if (adminMatch) {
|
|
1042
|
+
// Currently viewing the admin user
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
## API reference
|
|
1049
|
+
|
|
1050
|
+
### Types
|
|
1051
|
+
|
|
1052
|
+
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1053
|
+
|
|
1054
|
+
```tsx
|
|
1055
|
+
type NavigateOptions<P extends Pattern> = {
|
|
1056
|
+
to: P | Route<P>; // Route pattern or route object
|
|
1057
|
+
params?: Params<P>; // Required if route has dynamic segments
|
|
1058
|
+
search?: Search<P>; // Search params if route defines them
|
|
1059
|
+
replace?: boolean; // Replace history instead of push
|
|
1060
|
+
state?: any; // Arbitrary state to pass
|
|
1061
|
+
};
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
**`HistoryPushOptions`** is for untyped navigation:
|
|
1065
|
+
|
|
1066
|
+
```tsx
|
|
1067
|
+
interface HistoryPushOptions {
|
|
1068
|
+
url: string; // The URL to navigate to
|
|
1069
|
+
replace?: boolean; // Replace history instead of push
|
|
1070
|
+
state?: any; // Arbitrary state to pass
|
|
1071
|
+
}
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
**`MatchOptions<P>`** is used for route matching:
|
|
1075
|
+
|
|
1076
|
+
```tsx
|
|
1077
|
+
type MatchOptions<P extends Pattern> = {
|
|
1078
|
+
from: P | Route<P>; // Route to match against
|
|
1079
|
+
strict?: boolean; // Require exact match (not just prefix)
|
|
1080
|
+
params?: Partial<Params<P>>; // Filter by specific param values
|
|
1081
|
+
};
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
**`Match<P>`** is the result of a successful match:
|
|
1085
|
+
|
|
1086
|
+
```tsx
|
|
1087
|
+
type Match<P extends Pattern> = {
|
|
1088
|
+
route: Route<P>; // The matched route
|
|
1089
|
+
params: Params<P>; // Extracted parameters
|
|
1090
|
+
};
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
**`LinkOptions`** controls link behavior and styling:
|
|
1094
|
+
|
|
1095
|
+
```tsx
|
|
1096
|
+
interface LinkOptions {
|
|
1097
|
+
strict?: boolean; // Strict active matching
|
|
1098
|
+
preload?: "intent" | "render" | "viewport" | false;
|
|
1099
|
+
style?: CSSProperties;
|
|
1100
|
+
className?: string;
|
|
1101
|
+
activeStyle?: CSSProperties;
|
|
1102
|
+
activeClassName?: string;
|
|
1103
|
+
}
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
### Router class
|
|
1107
|
+
|
|
1108
|
+
The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
|
|
1109
|
+
|
|
1110
|
+
**Constructor:**
|
|
1111
|
+
|
|
1112
|
+
```tsx
|
|
1113
|
+
const router = new Router({
|
|
1114
|
+
routes: Route[], // Required: array of routes
|
|
1115
|
+
basePath: string, // Optional: base path prefix (default: "/")
|
|
1116
|
+
history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
|
|
1117
|
+
defaultLinkOptions: LinkOptions // Optional: defaults for all Links
|
|
1118
|
+
});
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
**Properties:**
|
|
1122
|
+
|
|
1123
|
+
- `router.basePath` - The configured base path
|
|
1124
|
+
- `router.routes` - The array of routes
|
|
1125
|
+
- `router.history` - The history instance
|
|
1126
|
+
- `router.defaultLinkOptions` - Default link options
|
|
1127
|
+
|
|
1128
|
+
**Methods:**
|
|
1129
|
+
|
|
1130
|
+
`router.navigate(options)` navigates to a new location:
|
|
1131
|
+
|
|
1132
|
+
```tsx
|
|
1133
|
+
// Type-safe navigation
|
|
1134
|
+
router.navigate({ to: "/posts/:id", params: { id: "42" } });
|
|
1135
|
+
|
|
1136
|
+
// Untyped navigation
|
|
1137
|
+
router.navigate({ url: "/any/path" });
|
|
1138
|
+
|
|
1139
|
+
// History navigation
|
|
1140
|
+
router.navigate(-1); // Back
|
|
1141
|
+
router.navigate(1); // Forward
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
`router.createUrl(options)` builds a URL string without navigating:
|
|
1145
|
+
|
|
1146
|
+
```tsx
|
|
1147
|
+
const url = router.createUrl({ to: userProfile, params: { id: "42" } });
|
|
1148
|
+
// Returns "/users/42"
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
`router.match(path, options)` checks if a path matches a specific route:
|
|
1152
|
+
|
|
1153
|
+
```tsx
|
|
1154
|
+
const match = router.match("/users/42", { from: "/users/:id" });
|
|
1155
|
+
// Returns { route, params: { id: "42" } } or null
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
`router.matchAll(path)` finds the best matching route from all registered routes:
|
|
1159
|
+
|
|
1160
|
+
```tsx
|
|
1161
|
+
const match = router.matchAll("/users/42");
|
|
1162
|
+
// Returns the best match or null
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
`router.getRoute(pattern)` retrieves a route by its pattern:
|
|
1166
|
+
|
|
1167
|
+
```tsx
|
|
1168
|
+
const route = router.getRoute("/users/:id");
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
### History interface
|
|
1172
|
+
|
|
1173
|
+
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1174
|
+
|
|
1175
|
+
**Interface:**
|
|
1176
|
+
|
|
1177
|
+
```tsx
|
|
1178
|
+
interface HistoryLike {
|
|
1179
|
+
getPath: () => string;
|
|
1180
|
+
getSearch: () => string;
|
|
1181
|
+
getState: () => any;
|
|
1182
|
+
go: (delta: number) => void;
|
|
1183
|
+
push: (options: HistoryPushOptions) => void;
|
|
1184
|
+
subscribe: (listener: () => void) => () => void;
|
|
1185
|
+
}
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
**Methods:**
|
|
1189
|
+
|
|
1190
|
+
`history.getPath()` returns the current pathname:
|
|
1191
|
+
|
|
1192
|
+
```tsx
|
|
1193
|
+
const path = history.getPath();
|
|
1194
|
+
// Returns "/users/42"
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
`history.getSearch()` returns the current search string (without `?`):
|
|
1198
|
+
|
|
1199
|
+
```tsx
|
|
1200
|
+
const search = history.getSearch();
|
|
1201
|
+
// Returns "tab=posts&page=2"
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
`history.getState()` returns the current history state:
|
|
1205
|
+
|
|
1206
|
+
```tsx
|
|
1207
|
+
const state = history.getState();
|
|
1208
|
+
// Returns any state passed during navigation
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
`history.go(delta)` navigates forward or back in history:
|
|
1212
|
+
|
|
1213
|
+
```tsx
|
|
1214
|
+
history.go(-1); // Go back
|
|
1215
|
+
history.go(1); // Go forward
|
|
1216
|
+
history.go(-2); // Go back two steps
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
`history.push(options)` pushes or replaces a history entry:
|
|
1220
|
+
|
|
1221
|
+
```tsx
|
|
1222
|
+
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1223
|
+
history.push({ url: "/login", replace: true });
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
`history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
|
|
1227
|
+
|
|
1228
|
+
```tsx
|
|
1229
|
+
const unsubscribe = history.subscribe(() => {
|
|
1230
|
+
console.log("Navigation occurred");
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Later: unsubscribe()
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
### Route class
|
|
1237
|
+
|
|
1238
|
+
Routes are created with the `route()` function and configured by chaining methods.
|
|
1239
|
+
|
|
1240
|
+
**`route(pattern)`** creates a new route:
|
|
1241
|
+
|
|
1242
|
+
```tsx
|
|
1243
|
+
const users = route("/users");
|
|
1244
|
+
const user = route("/users/:id");
|
|
1245
|
+
const catchAll = route("/*");
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
**`.route(subPattern)`** creates a nested child route:
|
|
1249
|
+
|
|
1250
|
+
```tsx
|
|
1251
|
+
const userSettings = user.route("/settings");
|
|
1252
|
+
// Pattern becomes "/users/:id/settings"
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
**`.component(component)`** adds a React component to render:
|
|
1256
|
+
|
|
1257
|
+
```tsx
|
|
1258
|
+
const users = route("/users").component(UsersPage);
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
**`.lazy(loader)`** adds a lazy-loaded component to render:
|
|
1262
|
+
|
|
1263
|
+
```tsx
|
|
1264
|
+
const users = route("/users").lazy(() => import("./UsersPage"));
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
**`.search(validator)`** adds search parameter validation:
|
|
1268
|
+
|
|
1269
|
+
```tsx
|
|
1270
|
+
const search = route("/search").search(z.object({ q: z.string() }));
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
**`.handle(data)`** attaches static metadata:
|
|
1274
|
+
|
|
1275
|
+
```tsx
|
|
1276
|
+
const admin = route("/admin").handle({ requiresAuth: true });
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
**`.suspense(fallback)`** wraps children in a suspense boundary:
|
|
1280
|
+
|
|
1281
|
+
```tsx
|
|
1282
|
+
const lazy = route("/lazy")
|
|
1283
|
+
.suspense(Loading)
|
|
1284
|
+
.lazy(() => import("./Page"));
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
**`.error(fallback)`** wraps children in an error boundary:
|
|
1288
|
+
|
|
1289
|
+
```tsx
|
|
1290
|
+
const risky = route("/risky").error(ErrorPage).component(RiskyPage);
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
**`.preloader(loader)`** registers a preloader function that will be called when `.preload()` is invoked or when a `Link` with a preload strategy triggers it:
|
|
1294
|
+
|
|
1295
|
+
```tsx
|
|
1296
|
+
const users = route("/users").preloader(async () => {
|
|
1297
|
+
await prefetchData();
|
|
1298
|
+
});
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
**`.preload()`** manually triggers all registered preloaders (including lazy component loading):
|
|
1302
|
+
|
|
1303
|
+
```tsx
|
|
1304
|
+
await userProfile.preload();
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
### Hooks
|
|
1308
|
+
|
|
1309
|
+
**`useRouter()`** returns the Router instance:
|
|
1310
|
+
|
|
1311
|
+
```tsx
|
|
1312
|
+
const router = useRouter();
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
**`useNavigate()`** returns a navigation function:
|
|
1316
|
+
|
|
1317
|
+
```tsx
|
|
1318
|
+
const navigate = useNavigate();
|
|
1319
|
+
navigate({ to: "/home" });
|
|
1320
|
+
navigate(-1);
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
**`useLocation()`** returns the current location:
|
|
1324
|
+
|
|
1325
|
+
```tsx
|
|
1326
|
+
const { path, search, state } = useLocation();
|
|
1327
|
+
// path: string, search: Record<string, unknown>, state: any
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
**`useOutlet()`** returns the nested route content (used internally by `Outlet`):
|
|
1331
|
+
|
|
1332
|
+
```tsx
|
|
1333
|
+
const outlet = useOutlet();
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
**`useParams(route)`** returns typed parameters for a route:
|
|
1337
|
+
|
|
1338
|
+
```tsx
|
|
1339
|
+
const { id } = useParams(userRoute);
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
**`useSearch(route)`** returns search params and a setter:
|
|
1343
|
+
|
|
1344
|
+
```tsx
|
|
1345
|
+
const [search, setSearch] = useSearch(searchRoute);
|
|
1346
|
+
setSearch({ page: 2 });
|
|
1347
|
+
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
**`useMatch(options)`** checks if a route matches the current path:
|
|
1351
|
+
|
|
1352
|
+
```tsx
|
|
1353
|
+
const match = useMatch({ from: "/users/:id" });
|
|
1354
|
+
const strictMatch = useMatch({ from: "/users", strict: true });
|
|
1355
|
+
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
**`useHandles()`** returns all handles from the matched route chain in order:
|
|
1359
|
+
|
|
1360
|
+
```tsx
|
|
1361
|
+
const handles = useHandles();
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
### Components
|
|
1365
|
+
|
|
1366
|
+
**`RouterRoot`** is the root provider. Pass either router options or a router instance:
|
|
1367
|
+
|
|
1368
|
+
```tsx
|
|
1369
|
+
<RouterRoot routes={routes} basePath="/app" history={history} />
|
|
1370
|
+
<RouterRoot router={router} />
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
**`Outlet`** renders child route content:
|
|
1374
|
+
|
|
1375
|
+
```tsx
|
|
1376
|
+
function Layout() {
|
|
1377
|
+
return (
|
|
1378
|
+
<div>
|
|
1379
|
+
<Outlet />
|
|
1380
|
+
</div>
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
**`Link`** navigates on click. Props extend `NavigateOptions` and `LinkOptions`:
|
|
1386
|
+
|
|
1387
|
+
```tsx
|
|
1388
|
+
<Link to="/path" params={...} search={...} replace strict preload="intent">
|
|
1389
|
+
Click me
|
|
1390
|
+
</Link>
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
**`Navigate`** redirects on render. Props are `NavigateOptions`:
|
|
1394
|
+
|
|
1395
|
+
```tsx
|
|
1396
|
+
<Navigate to="/login" replace />
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
---
|
|
1400
|
+
|
|
1401
|
+
## Roadmap
|
|
1402
|
+
|
|
1403
|
+
Future improvements planned for Waymark:
|
|
1404
|
+
|
|
1405
|
+
- **Preloader context** - Pass route params and search queries to preloader functions, enabling data fetching based on the target route's dynamic segments
|
|
1406
|
+
- **Server-side rendering guide** - Add documentation for using Waymark in SSR environments
|
|
1407
|
+
|
|
1408
|
+
---
|
|
1409
|
+
|
|
1410
|
+
## License
|
|
1411
|
+
|
|
1412
|
+
MIT
|