waymark 0.2.2 → 0.3.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 +740 -408
- package/dist/index.d.ts +85 -78
- package/dist/index.js +1 -1
- package/package.json +7 -8
- package/tsdown.config.ts +0 -8
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark"
|
|
2
|
+
<img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -8,28 +8,30 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
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://
|
|
11
|
+
<a href="https://www.npmjs.com/package/waymark"><img src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=000&labelColor=000" alt="gzip size" /></a>
|
|
12
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
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
|
+
<a href="https://github.com/sponsors/strblr"><img src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=000&labelColor=000" alt="sponsors" /></a>
|
|
14
15
|
</p>
|
|
15
16
|
|
|
16
17
|
<p align="center">
|
|
17
|
-
<a href="https://strblr.
|
|
18
|
+
<a href="https://waymark.strblr.workers.dev">📖 Documentation</a>
|
|
18
19
|
</p>
|
|
19
20
|
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
|
|
23
24
|
|
|
24
|
-
- **Fully type-safe** - Complete TypeScript inference for routes, params, and search
|
|
25
|
+
- **Fully type-safe** - Complete TypeScript inference for routes, path params, and search params
|
|
25
26
|
- **Zero config** - No build plugins, no CLI tools, no configuration files, very low boilerplate
|
|
26
27
|
- **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
|
|
27
|
-
- **3.
|
|
28
|
+
- **3.6kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so around 4kB total
|
|
29
|
+
- **Not vibe-coded** - Built with careful design and attention to detail by a human
|
|
28
30
|
- **Just works** - Define routes, get autocomplete everywhere
|
|
29
31
|
|
|
30
32
|
---
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
# Table of contents
|
|
33
35
|
|
|
34
36
|
- [Showcase](#showcase)
|
|
35
37
|
- [Installation](#installation)
|
|
@@ -37,39 +39,46 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
37
39
|
- [Nested routes and layouts](#nested-routes-and-layouts)
|
|
38
40
|
- [Setting up the router](#setting-up-the-router)
|
|
39
41
|
- [Code organization](#code-organization)
|
|
42
|
+
- [Path params](#path-params)
|
|
43
|
+
- [Search params](#search-params)
|
|
44
|
+
- [Basic usage](#basic-usage)
|
|
45
|
+
- [JSON-first approach](#json-first-approach)
|
|
46
|
+
- [Inheritance](#inheritance)
|
|
47
|
+
- [Idempotency requirement](#idempotency-requirement)
|
|
40
48
|
- [Navigation](#navigation)
|
|
41
49
|
- [The Link component](#the-link-component)
|
|
42
50
|
- [Active state detection](#active-state-detection)
|
|
43
|
-
- [
|
|
51
|
+
- [Route preloading](#route-preloading)
|
|
44
52
|
- [Programmatic navigation](#programmatic-navigation)
|
|
45
53
|
- [Declarative navigation](#declarative-navigation)
|
|
46
|
-
- [Path parameters](#path-parameters)
|
|
47
|
-
- [Search queries](#search-queries)
|
|
48
54
|
- [Lazy loading](#lazy-loading)
|
|
55
|
+
- [Data preloading](#data-preloading)
|
|
49
56
|
- [Error boundaries](#error-boundaries)
|
|
50
57
|
- [Suspense boundaries](#suspense-boundaries)
|
|
51
58
|
- [Route handles](#route-handles)
|
|
52
59
|
- [Route matching and ranking](#route-matching-and-ranking)
|
|
53
60
|
- [History implementations](#history-implementations)
|
|
54
61
|
- [Cookbook](#cookbook)
|
|
62
|
+
- [Quick start example](#quick-start-example)
|
|
63
|
+
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
55
64
|
- [Scroll to top on navigation](#scroll-to-top-on-navigation)
|
|
65
|
+
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
56
66
|
- [Global link configuration](#global-link-configuration)
|
|
57
67
|
- [History middleware](#history-middleware)
|
|
58
68
|
- [View transitions](#view-transitions)
|
|
59
|
-
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
60
69
|
- [API reference](#api-reference)
|
|
61
|
-
- [Types](#types)
|
|
62
70
|
- [Router class](#router-class)
|
|
63
|
-
- [History interface](#history-interface)
|
|
64
71
|
- [Route class](#route-class)
|
|
65
72
|
- [Hooks](#hooks)
|
|
66
73
|
- [Components](#components)
|
|
74
|
+
- [History interface](#history-interface)
|
|
75
|
+
- [Types](#types)
|
|
67
76
|
- [Roadmap](#roadmap)
|
|
68
77
|
- [License](#license)
|
|
69
78
|
|
|
70
79
|
---
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
# Showcase
|
|
73
82
|
|
|
74
83
|
Here's what a small routing setup looks like. Define some routes, render them, and get full type safety:
|
|
75
84
|
|
|
@@ -106,11 +115,11 @@ declare module "waymark" {
|
|
|
106
115
|
}
|
|
107
116
|
```
|
|
108
117
|
|
|
109
|
-
Links, navigation, params, search
|
|
118
|
+
Links, navigation, path params, search params - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
|
|
110
119
|
|
|
111
120
|
---
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
# Installation
|
|
114
123
|
|
|
115
124
|
```bash
|
|
116
125
|
npm install waymark
|
|
@@ -120,7 +129,7 @@ Waymark requires React 18 or higher.
|
|
|
120
129
|
|
|
121
130
|
---
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
# Defining routes
|
|
124
133
|
|
|
125
134
|
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
135
|
|
|
@@ -154,11 +163,11 @@ Route building is immutable: every method on a route returns a new route instanc
|
|
|
154
163
|
|
|
155
164
|
---
|
|
156
165
|
|
|
157
|
-
|
|
166
|
+
# Nested routes and layouts
|
|
158
167
|
|
|
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,
|
|
168
|
+
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, etc.
|
|
160
169
|
|
|
161
|
-
Here's how it works.
|
|
170
|
+
Here's how it works. Start with any route:
|
|
162
171
|
|
|
163
172
|
```tsx
|
|
164
173
|
const dashboard = route("/dashboard").component(DashboardLayout);
|
|
@@ -174,7 +183,7 @@ const profile = dashboard.route("/profile").component(Profile);
|
|
|
174
183
|
|
|
175
184
|
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
185
|
|
|
177
|
-
|
|
186
|
+
The parent component must render an `<Outlet />` where child routes should appear:
|
|
178
187
|
|
|
179
188
|
```tsx
|
|
180
189
|
function DashboardLayout() {
|
|
@@ -189,7 +198,7 @@ function DashboardLayout() {
|
|
|
189
198
|
}
|
|
190
199
|
```
|
|
191
200
|
|
|
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
|
|
201
|
+
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.
|
|
193
202
|
|
|
194
203
|
You can nest as deep as you need:
|
|
195
204
|
|
|
@@ -213,7 +222,7 @@ Each level must include an `<Outlet />` to render the next level.
|
|
|
213
222
|
|
|
214
223
|
---
|
|
215
224
|
|
|
216
|
-
|
|
225
|
+
# Setting up the router
|
|
217
226
|
|
|
218
227
|
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
228
|
|
|
@@ -229,7 +238,7 @@ const about = layout.route("/about").component(About);
|
|
|
229
238
|
const routes = [home, about]; // ✅ Don't include `layout`
|
|
230
239
|
```
|
|
231
240
|
|
|
232
|
-
This
|
|
241
|
+
This makes sure that only actual pages can be matched and appear in autocomplete. The intermediate routes still exist as part of the hierarchy, they just aren't directly navigable. Note that the order of routes in the array doesn't matter - Waymark uses a [ranking algorithm](#route-matching-and-ranking) to pick the most specific match.
|
|
233
242
|
|
|
234
243
|
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
244
|
|
|
@@ -251,7 +260,7 @@ You can also pass a `basePath` if your app lives under a subpath:
|
|
|
251
260
|
<RouterRoot routes={routes} basePath="/my-app" />
|
|
252
261
|
```
|
|
253
262
|
|
|
254
|
-
The second approach is to create a `Router` instance outside of React. This
|
|
263
|
+
The second approach is to create a `Router` instance outside of React. This gives you a global router instance that can be accessed from non-React contexts (e.g., utility functions, service modules, or other non-React code):
|
|
255
264
|
|
|
256
265
|
```tsx
|
|
257
266
|
import { Router, RouterRoot } from "waymark";
|
|
@@ -281,7 +290,7 @@ With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs
|
|
|
281
290
|
|
|
282
291
|
---
|
|
283
292
|
|
|
284
|
-
|
|
293
|
+
# Code organization
|
|
285
294
|
|
|
286
295
|
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
296
|
|
|
@@ -330,13 +339,183 @@ declare module "waymark" {
|
|
|
330
339
|
}
|
|
331
340
|
```
|
|
332
341
|
|
|
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
|
|
342
|
+
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 routes come from or how you structure your files.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
# Path params
|
|
347
|
+
|
|
348
|
+
Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
|
|
349
|
+
|
|
350
|
+
```tsx
|
|
351
|
+
const post = route("/posts/:id").component(PostPage);
|
|
352
|
+
const comment = route("/posts/:postId/comments/:commentId?").component(
|
|
353
|
+
CommentPage
|
|
354
|
+
);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Access parameters with `useParams`, passing the route pattern or object as an argument:
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
function PostPage() {
|
|
361
|
+
const { id } = useParams(post);
|
|
362
|
+
// id is typed as string
|
|
363
|
+
|
|
364
|
+
const { id } = useParams("/posts/:id");
|
|
365
|
+
// Also works
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function CommentPage() {
|
|
369
|
+
const { postId, commentId } = useParams(comment);
|
|
370
|
+
// postId: string
|
|
371
|
+
// commentId?: string | undefined
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
const files = route("/files/*").component(FileBrowser);
|
|
379
|
+
|
|
380
|
+
function FileBrowser() {
|
|
381
|
+
const params = useParams(files);
|
|
382
|
+
const path = params["*"]; // e.g., "documents/report.pdf"
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
# Search params
|
|
389
|
+
|
|
390
|
+
## Basic usage
|
|
391
|
+
|
|
392
|
+
Search params (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://standardschema.dev/schema#what-schema-libraries-implement-the-spec) validator like Zod, or a plain validation function.
|
|
393
|
+
|
|
394
|
+
With Zod:
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
import { z } from "zod";
|
|
398
|
+
|
|
399
|
+
const searchPage = route("/search")
|
|
400
|
+
.search(
|
|
401
|
+
z.object({
|
|
402
|
+
q: z.string().catch(""),
|
|
403
|
+
page: z.coerce.number().catch(1)
|
|
404
|
+
})
|
|
405
|
+
)
|
|
406
|
+
.component(SearchPage);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
With a plain function:
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
const searchPage = route("/search")
|
|
413
|
+
.search(raw => ({
|
|
414
|
+
q: String(raw.q ?? ""),
|
|
415
|
+
page: Number(raw.page ?? 1)
|
|
416
|
+
}))
|
|
417
|
+
.component(SearchPage);
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Since you can't control what users put in the URL, your validator should handle missing or malformed values gracefully - validate and normalize rather than reject.
|
|
421
|
+
|
|
422
|
+
Access validated search params with `useSearch`, which returns a tuple of the current values and a setter function:
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
function SearchPage() {
|
|
426
|
+
const [search, setSearch] = useSearch(searchPage);
|
|
427
|
+
// search.q: string
|
|
428
|
+
// search.page: number
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The setter merges your updates with existing values:
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
setSearch({ page: 2 }); // Only updates page
|
|
436
|
+
setSearch(prev => ({ page: prev.page + 1 })); // Increment page
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Pass `true` as the second argument to replace the history entry instead of pushing:
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
setSearch({ page: 1 }, true);
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## JSON-first approach
|
|
446
|
+
|
|
447
|
+
Waymark uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
448
|
+
|
|
449
|
+
- Plain strings that aren't valid JSON are kept as-is (and URL-encoded): `"John"` → `?name=John` → `"John"`
|
|
450
|
+
- Everything else is JSON-encoded (then URL-encoded):
|
|
451
|
+
- `true` → `?enabled=true` → `true`
|
|
452
|
+
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
453
|
+
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
454
|
+
- `42` → `count=42` → `42`
|
|
455
|
+
|
|
456
|
+
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.
|
|
457
|
+
|
|
458
|
+
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 is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
|
|
459
|
+
|
|
460
|
+
## Inheritance
|
|
461
|
+
|
|
462
|
+
When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing.
|
|
463
|
+
|
|
464
|
+
Here's how it works. Start with a parent route that defines a search param:
|
|
465
|
+
|
|
466
|
+
```tsx
|
|
467
|
+
const dashboard = route("/dashboard")
|
|
468
|
+
.search(
|
|
469
|
+
z.object({
|
|
470
|
+
view: z.enum(["grid", "list"]).catch("grid")
|
|
471
|
+
})
|
|
472
|
+
)
|
|
473
|
+
.component(DashboardLayout);
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Any child route created from `dashboard` inherits the `view` search param and its validation:
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
const projects = dashboard.route("/projects").component(ProjectsPage);
|
|
480
|
+
|
|
481
|
+
function ProjectsPage() {
|
|
482
|
+
const [search] = useSearch(projects);
|
|
483
|
+
// search.view is typed as "grid" | "list"
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL merged with the parent's already-validated params. After validation, your result is combined with the parent's validated params to produce the final search params object.
|
|
488
|
+
|
|
489
|
+
In practice, this means you only need to validate the new params you're adding - the parent's params are automatically included in the final result:
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
const projects = dashboard
|
|
493
|
+
.route("/projects")
|
|
494
|
+
.search(
|
|
495
|
+
z.object({
|
|
496
|
+
status: z.enum(["active", "archived"]).catch("active")
|
|
497
|
+
})
|
|
498
|
+
)
|
|
499
|
+
.component(ProjectsPage);
|
|
500
|
+
|
|
501
|
+
function ProjectsPage() {
|
|
502
|
+
const [search] = useSearch(projects);
|
|
503
|
+
// search.view: "grid" | "list" (from parent)
|
|
504
|
+
// search.status: "active" | "archived" (from child)
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
## Idempotency requirement
|
|
509
|
+
|
|
510
|
+
The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
|
|
511
|
+
|
|
512
|
+
When you read search params, the values are passed through your validator. When you update search params, the navigation APIs expect values in that same validated format, which are then JSON-encoded back into the URL. On the next read, those encoded values are decoded and passed through your validator again - meaning your validator may receive its own output as input.
|
|
334
513
|
|
|
335
514
|
---
|
|
336
515
|
|
|
337
|
-
|
|
516
|
+
# Navigation
|
|
338
517
|
|
|
339
|
-
|
|
518
|
+
## The Link component
|
|
340
519
|
|
|
341
520
|
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
521
|
|
|
@@ -385,7 +564,7 @@ The `asChild` prop lets you use your own component while keeping Link's behavior
|
|
|
385
564
|
</Link>
|
|
386
565
|
```
|
|
387
566
|
|
|
388
|
-
|
|
567
|
+
## Active state detection
|
|
389
568
|
|
|
390
569
|
Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
|
|
391
570
|
|
|
@@ -419,11 +598,13 @@ Or use the `activeClassName` and `activeStyle` props directly:
|
|
|
419
598
|
</Link>
|
|
420
599
|
```
|
|
421
600
|
|
|
422
|
-
|
|
601
|
+
## Route preloading
|
|
602
|
+
|
|
603
|
+
Links can optionally trigger route preloading before navigation occurs. When preloading is enabled, any [lazy-loaded components](#lazy-loading) (defined with `.lazy()`) and [preload functions](#data-preloading) (defined with `.preload()`) are called early. This improves perceived performance by loading component bundles and running preparation logic like prefetching data ahead of time.
|
|
423
604
|
|
|
424
|
-
|
|
605
|
+
The `preload` prop controls when preloading happens:
|
|
425
606
|
|
|
426
|
-
**`preload="intent"`**
|
|
607
|
+
**`preload="intent"`** preloads when the user shows intent to navigate by hovering or focusing the link. This is the most common choice as it balances eager loading with not wasting bandwidth:
|
|
427
608
|
|
|
428
609
|
```tsx
|
|
429
610
|
<Link to="/heavy-page" preload="intent">
|
|
@@ -449,15 +630,26 @@ When a route has preloaders, e.g. when using lazy-loaded routes, you can preload
|
|
|
449
630
|
|
|
450
631
|
**`preload={false}`** disables preloading entirely. This is the default.
|
|
451
632
|
|
|
452
|
-
|
|
633
|
+
To prevent unwanted preloads from quick hover/focus interactions, Link waits 50ms before triggering. You can customize this with `preloadDelay`:
|
|
634
|
+
|
|
635
|
+
```tsx
|
|
636
|
+
<Link to="/heavy-page" preload="intent" preloadDelay={100}>
|
|
637
|
+
Heavy page
|
|
638
|
+
</Link>
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
You can also preload programmatically using `router.preload()`:
|
|
453
642
|
|
|
454
643
|
```tsx
|
|
455
|
-
|
|
644
|
+
const router = useRouter();
|
|
645
|
+
router.preload({ to: userProfile, params: { id: "42" } });
|
|
456
646
|
```
|
|
457
647
|
|
|
458
|
-
|
|
648
|
+
To set a preload strategy globally for all links in your app, see [Global link configuration](#global-link-configuration).
|
|
649
|
+
|
|
650
|
+
## Programmatic navigation
|
|
459
651
|
|
|
460
|
-
For navigation triggered by code rather than user clicks, use the `useNavigate` hook
|
|
652
|
+
For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
|
|
461
653
|
|
|
462
654
|
```tsx
|
|
463
655
|
import { useNavigate } from "waymark";
|
|
@@ -474,7 +666,7 @@ function LoginForm() {
|
|
|
474
666
|
}
|
|
475
667
|
```
|
|
476
668
|
|
|
477
|
-
The navigate function accepts the same options as `Link`:
|
|
669
|
+
The navigate function accepts the same navigation options as `Link`:
|
|
478
670
|
|
|
479
671
|
```tsx
|
|
480
672
|
navigate({ to: userProfile, params: { id: "42" }, search: { tab: "posts" } });
|
|
@@ -490,20 +682,24 @@ navigate(1); // Go forward
|
|
|
490
682
|
navigate(-2); // Go back two steps
|
|
491
683
|
```
|
|
492
684
|
|
|
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
|
|
685
|
+
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:
|
|
686
|
+
|
|
687
|
+
```tsx
|
|
688
|
+
router.navigate({ to: "/login" });
|
|
689
|
+
```
|
|
494
690
|
|
|
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
|
|
691
|
+
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., URLs from user input or API responses):
|
|
496
692
|
|
|
497
693
|
```tsx
|
|
498
694
|
// Type-safe navigation
|
|
499
695
|
navigate({ to: userProfile, params: { id: "42" } });
|
|
500
696
|
|
|
501
697
|
// Unsafe navigation - no type checking
|
|
502
|
-
navigate({ url: "/some/
|
|
698
|
+
navigate({ url: "/some/path?tab=settings" });
|
|
503
699
|
navigate({ url: "/callback", replace: true, state: { data: 123 } });
|
|
504
700
|
```
|
|
505
701
|
|
|
506
|
-
|
|
702
|
+
## Declarative navigation
|
|
507
703
|
|
|
508
704
|
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:
|
|
509
705
|
|
|
@@ -521,7 +717,7 @@ function ProtectedPage() {
|
|
|
521
717
|
}
|
|
522
718
|
```
|
|
523
719
|
|
|
524
|
-
The `Navigate` component accepts the same props as the `Link` component
|
|
720
|
+
The `Navigate` component accepts the same navigation props as the `Link` component:
|
|
525
721
|
|
|
526
722
|
```tsx
|
|
527
723
|
<Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
|
|
@@ -533,186 +729,115 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
|
|
|
533
729
|
|
|
534
730
|
---
|
|
535
731
|
|
|
536
|
-
|
|
732
|
+
# Lazy loading
|
|
537
733
|
|
|
538
|
-
|
|
734
|
+
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
539
735
|
|
|
540
736
|
```tsx
|
|
541
|
-
const
|
|
542
|
-
const comment = route("/posts/:postId/comments/:commentId?").component(
|
|
543
|
-
CommentPage
|
|
544
|
-
);
|
|
737
|
+
const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
|
|
545
738
|
```
|
|
546
739
|
|
|
547
|
-
|
|
740
|
+
The imported module should use a default export:
|
|
548
741
|
|
|
549
742
|
```tsx
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
// id is typed as string
|
|
553
|
-
|
|
554
|
-
const { id } = useParams("/posts/:id");
|
|
555
|
-
// Also works
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function CommentPage() {
|
|
559
|
-
const { postId, commentId } = useParams(comment);
|
|
560
|
-
// postId: string
|
|
561
|
-
// commentId?: string | undefined
|
|
562
|
-
}
|
|
743
|
+
// AnalyticsPage.tsx
|
|
744
|
+
export default function AnalyticsPage() { ... }
|
|
563
745
|
```
|
|
564
746
|
|
|
565
|
-
|
|
747
|
+
If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
|
|
566
748
|
|
|
567
749
|
```tsx
|
|
568
|
-
const
|
|
750
|
+
const analytics = route("/analytics").lazy(() =>
|
|
751
|
+
import("./AnalyticsPage").then(m => m.AnalyticsPage)
|
|
752
|
+
);
|
|
569
753
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const path = params["*"]; // e.g., "documents/report.pdf"
|
|
573
|
-
}
|
|
754
|
+
// AnalyticsPage.tsx
|
|
755
|
+
export function AnalyticsPage() { ... }
|
|
574
756
|
```
|
|
575
757
|
|
|
576
|
-
|
|
758
|
+
Lazy routes work like any other route. Child routes inherit the parent's lazy-loaded components:
|
|
577
759
|
|
|
578
|
-
|
|
760
|
+
```tsx
|
|
761
|
+
const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
|
|
762
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
763
|
+
```
|
|
579
764
|
|
|
580
|
-
|
|
765
|
+
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.
|
|
581
766
|
|
|
582
|
-
|
|
767
|
+
See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
|
|
583
768
|
|
|
584
|
-
|
|
585
|
-
import { z } from "zod";
|
|
769
|
+
---
|
|
586
770
|
|
|
587
|
-
|
|
588
|
-
.search(
|
|
589
|
-
z.object({
|
|
590
|
-
q: z.string().catch(""),
|
|
591
|
-
page: z.coerce.number().catch(1)
|
|
592
|
-
})
|
|
593
|
-
)
|
|
594
|
-
.component(SearchPage);
|
|
595
|
-
```
|
|
771
|
+
# Data preloading
|
|
596
772
|
|
|
597
|
-
|
|
773
|
+
Use `.preload()` to run logic before navigation occurs, typically to prefetch data. Preload functions receive the target route's typed params and search values:
|
|
598
774
|
|
|
599
775
|
```tsx
|
|
600
|
-
const
|
|
601
|
-
.search(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
776
|
+
const userProfile = route("/users/:id")
|
|
777
|
+
.search(z.object({ tab: z.enum(["posts", "comments"]).catch("posts") }))
|
|
778
|
+
.preload(async ({ params, search }) => {
|
|
779
|
+
await queryClient.prefetchQuery({
|
|
780
|
+
queryKey: ["user", params.id, search.tab],
|
|
781
|
+
queryFn: () => fetchUser(params.id, search.tab)
|
|
782
|
+
});
|
|
783
|
+
})
|
|
784
|
+
.component(UserProfile);
|
|
606
785
|
```
|
|
607
786
|
|
|
608
|
-
|
|
787
|
+
See [Route preloading](#route-preloading) for how to trigger preload functions.
|
|
788
|
+
|
|
789
|
+
Depending on when and how preloading is triggered, these functions may run repeatedly. Waymark intentionally doesn't cache or deduplicate the calls - that's the job of your data layer. Libraries like TanStack Query, SWR, or Apollo handle this well. For example, TanStack Query's `staleTime` prevents refetches when data is still fresh:
|
|
609
790
|
|
|
610
791
|
```tsx
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
//
|
|
615
|
-
}
|
|
792
|
+
await queryClient.prefetchQuery({
|
|
793
|
+
queryKey: ["user", params.id],
|
|
794
|
+
queryFn: () => fetchUser(params.id),
|
|
795
|
+
staleTime: 60_000 // No refetch within 60s
|
|
796
|
+
});
|
|
616
797
|
```
|
|
617
798
|
|
|
618
|
-
|
|
799
|
+
Preload functions inherit to child routes:
|
|
619
800
|
|
|
620
801
|
```tsx
|
|
621
|
-
|
|
622
|
-
|
|
802
|
+
const dashboard = route("/dashboard")
|
|
803
|
+
.preload(prefetchDashboardData)
|
|
804
|
+
.component(DashboardLayout);
|
|
805
|
+
|
|
806
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
807
|
+
// Preloading /dashboard/settings runs prefetchDashboardData
|
|
623
808
|
```
|
|
624
809
|
|
|
625
|
-
|
|
810
|
+
---
|
|
626
811
|
|
|
627
|
-
|
|
628
|
-
setSearch({ page: 1 }, true);
|
|
629
|
-
```
|
|
812
|
+
# Error boundaries
|
|
630
813
|
|
|
631
|
-
|
|
814
|
+
Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
|
|
632
815
|
|
|
633
|
-
|
|
816
|
+
```tsx
|
|
817
|
+
const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
|
|
634
818
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
819
|
+
function ErrorFallback({ error }: { error: unknown }) {
|
|
820
|
+
return (
|
|
821
|
+
<div>
|
|
822
|
+
<h2>Something went wrong</h2>
|
|
823
|
+
<pre>{String(error)}</pre>
|
|
824
|
+
<button onClick={() => window.location.reload()}>Retry</button>
|
|
825
|
+
</div>
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
```
|
|
641
829
|
|
|
642
|
-
|
|
830
|
+
Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
|
|
831
|
+
|
|
832
|
+
```tsx
|
|
833
|
+
const app = route("/").error(ErrorPage).component(AppLayout);
|
|
834
|
+
```
|
|
643
835
|
|
|
644
|
-
|
|
836
|
+
To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
|
|
645
837
|
|
|
646
838
|
---
|
|
647
839
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
651
|
-
|
|
652
|
-
```tsx
|
|
653
|
-
const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
The imported module should use a default export:
|
|
657
|
-
|
|
658
|
-
```tsx
|
|
659
|
-
// AnalyticsPage.tsx
|
|
660
|
-
export default function AnalyticsPage() { ... }
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
|
|
664
|
-
|
|
665
|
-
```tsx
|
|
666
|
-
const analytics = route("/analytics").lazy(() =>
|
|
667
|
-
import("./AnalyticsPage").then(m => m.AnalyticsPage)
|
|
668
|
-
);
|
|
669
|
-
|
|
670
|
-
// AnalyticsPage.tsx
|
|
671
|
-
export function AnalyticsPage() { ... }
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
Lazy routes work seamlessly with nesting. Child routes inherit the lazy-loaded parent's components:
|
|
675
|
-
|
|
676
|
-
```tsx
|
|
677
|
-
const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
|
|
678
|
-
const settings = dashboard.route("/settings").component(Settings);
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
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.
|
|
682
|
-
|
|
683
|
-
See [Link preloading](#link-preloading) for ways to load these components before the user navigates.
|
|
684
|
-
|
|
685
|
-
---
|
|
686
|
-
|
|
687
|
-
## Error boundaries
|
|
688
|
-
|
|
689
|
-
Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
|
|
690
|
-
|
|
691
|
-
```tsx
|
|
692
|
-
const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
|
|
693
|
-
|
|
694
|
-
function ErrorFallback({ error }: { error: unknown }) {
|
|
695
|
-
return (
|
|
696
|
-
<div>
|
|
697
|
-
<h2>Something went wrong</h2>
|
|
698
|
-
<pre>{String(error)}</pre>
|
|
699
|
-
<button onClick={() => window.location.reload()}>Retry</button>
|
|
700
|
-
</div>
|
|
701
|
-
);
|
|
702
|
-
}
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
|
|
706
|
-
|
|
707
|
-
```tsx
|
|
708
|
-
const app = route("/").error(ErrorPage).component(AppLayout);
|
|
709
|
-
```
|
|
710
|
-
|
|
711
|
-
The error boundary automatically resets when navigation occurs, giving the new route a fresh start.
|
|
712
|
-
|
|
713
|
-
---
|
|
714
|
-
|
|
715
|
-
## Suspense boundaries
|
|
840
|
+
# Suspense boundaries
|
|
716
841
|
|
|
717
842
|
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()`:
|
|
718
843
|
|
|
@@ -741,7 +866,7 @@ Note: React 19 has a [known throttling behavior](https://github.com/facebook/rea
|
|
|
741
866
|
|
|
742
867
|
---
|
|
743
868
|
|
|
744
|
-
|
|
869
|
+
# Route handles
|
|
745
870
|
|
|
746
871
|
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.
|
|
747
872
|
|
|
@@ -767,8 +892,8 @@ function Breadcrumbs() {
|
|
|
767
892
|
<nav>
|
|
768
893
|
{handles.map((h, i) => (
|
|
769
894
|
<span key={i}>
|
|
895
|
+
{i !== 0 && " / "}
|
|
770
896
|
{h.title}
|
|
771
|
-
{i < handles.length - 1 && " / "}
|
|
772
897
|
</span>
|
|
773
898
|
))}
|
|
774
899
|
</nav>
|
|
@@ -791,7 +916,7 @@ declare module "waymark" {
|
|
|
791
916
|
|
|
792
917
|
---
|
|
793
918
|
|
|
794
|
-
|
|
919
|
+
# Route matching and ranking
|
|
795
920
|
|
|
796
921
|
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.
|
|
797
922
|
|
|
@@ -843,7 +968,7 @@ const routes = [
|
|
|
843
968
|
|
|
844
969
|
---
|
|
845
970
|
|
|
846
|
-
|
|
971
|
+
# History implementations
|
|
847
972
|
|
|
848
973
|
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.
|
|
849
974
|
|
|
@@ -865,7 +990,7 @@ import { HashHistory } from "waymark";
|
|
|
865
990
|
<RouterRoot routes={routes} history={new HashHistory()} />;
|
|
866
991
|
```
|
|
867
992
|
|
|
868
|
-
**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:
|
|
993
|
+
**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 (SSR), or embedded applications:
|
|
869
994
|
|
|
870
995
|
```tsx
|
|
871
996
|
import { MemoryHistory } from "waymark";
|
|
@@ -877,9 +1002,94 @@ All history implementations conform to the `HistoryLike` interface, so you can c
|
|
|
877
1002
|
|
|
878
1003
|
---
|
|
879
1004
|
|
|
880
|
-
|
|
1005
|
+
# Cookbook
|
|
1006
|
+
|
|
1007
|
+
## Quick start example
|
|
1008
|
+
|
|
1009
|
+
Here's a minimal but complete routing setup with a layout and two pages:
|
|
1010
|
+
|
|
1011
|
+
```tsx
|
|
1012
|
+
import { route, RouterRoot, Outlet, Link } from "waymark";
|
|
1013
|
+
|
|
1014
|
+
// Layout route
|
|
1015
|
+
const app = route("/").component(AppLayout);
|
|
1016
|
+
|
|
1017
|
+
function AppLayout() {
|
|
1018
|
+
return (
|
|
1019
|
+
<div>
|
|
1020
|
+
<nav>
|
|
1021
|
+
<Link to="/">Home</Link>
|
|
1022
|
+
<Link to="/about">About</Link>
|
|
1023
|
+
</nav>
|
|
1024
|
+
<main>
|
|
1025
|
+
<Outlet />
|
|
1026
|
+
</main>
|
|
1027
|
+
</div>
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Page routes
|
|
1032
|
+
const home = app.route("/").component(() => <h1>Welcome home</h1>);
|
|
1033
|
+
const about = app.route("/about").component(() => <h1>About us</h1>);
|
|
1034
|
+
|
|
1035
|
+
// Router setup
|
|
1036
|
+
const routes = [home, about];
|
|
1037
|
+
|
|
1038
|
+
export function App() {
|
|
1039
|
+
return <RouterRoot routes={routes} />;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
declare module "waymark" {
|
|
1043
|
+
interface Register {
|
|
1044
|
+
routes: typeof routes;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
## Server-side rendering (SSR)
|
|
1050
|
+
|
|
1051
|
+
Waymark supports server-side rendering using `MemoryHistory`. The key is to use `MemoryHistory` on the server (initialized with the request URL) and `BrowserHistory` on the client:
|
|
1052
|
+
|
|
1053
|
+
```tsx
|
|
1054
|
+
// server.tsx
|
|
1055
|
+
import { renderToString } from "react-dom/server";
|
|
1056
|
+
import { RouterRoot, MemoryHistory, type SSRContext } from "waymark";
|
|
1057
|
+
import { routes } from "./routes";
|
|
1058
|
+
|
|
1059
|
+
function handleRequest(req: Request) {
|
|
1060
|
+
const ssrContext: SSRContext = {};
|
|
1061
|
+
const html = renderToString(
|
|
1062
|
+
<RouterRoot
|
|
1063
|
+
routes={routes}
|
|
1064
|
+
history={new MemoryHistory(req.url)}
|
|
1065
|
+
ssrContext={ssrContext}
|
|
1066
|
+
/>
|
|
1067
|
+
);
|
|
1068
|
+
if (ssrContext.redirect) {
|
|
1069
|
+
return Response.redirect(ssrContext.redirect);
|
|
1070
|
+
}
|
|
1071
|
+
return new Response(html, {
|
|
1072
|
+
headers: { "Content-Type": "text/html" }
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
```
|
|
881
1076
|
|
|
882
|
-
|
|
1077
|
+
The `ssrContext` object captures information during server rendering. When a `Navigate` component renders on the server (typically from conditional logic), it populates `ssrContext.redirect` with the target URL. Your server can then return an HTTP redirect instead of the rendered HTML.
|
|
1078
|
+
|
|
1079
|
+
On the client, use the default (`BrowserHistory`) for hydration:
|
|
1080
|
+
|
|
1081
|
+
```tsx
|
|
1082
|
+
// client.tsx
|
|
1083
|
+
import { hydrateRoot } from "react-dom/client";
|
|
1084
|
+
import { RouterRoot } from "waymark";
|
|
1085
|
+
import { routes } from "./routes";
|
|
1086
|
+
|
|
1087
|
+
hydrateRoot(rootElement, <RouterRoot routes={routes} />);
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
|
|
1091
|
+
|
|
1092
|
+
## Scroll to top on navigation
|
|
883
1093
|
|
|
884
1094
|
Create a component that scrolls to top when the path changes and include it in your layout:
|
|
885
1095
|
|
|
@@ -904,15 +1114,57 @@ function AppLayout() {
|
|
|
904
1114
|
}
|
|
905
1115
|
```
|
|
906
1116
|
|
|
907
|
-
|
|
1117
|
+
## Matching a route anywhere
|
|
1118
|
+
|
|
1119
|
+
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`.
|
|
908
1120
|
|
|
909
|
-
|
|
1121
|
+
By default, `useMatch` uses loose matching where the current path only needs to start with the route's path. To require an exact match instead, pass `strict: true`:
|
|
1122
|
+
|
|
1123
|
+
```tsx
|
|
1124
|
+
import { useMatch } from "waymark";
|
|
1125
|
+
|
|
1126
|
+
const dashboard = route("/dashboard").component(Dashboard);
|
|
1127
|
+
const settings = route("/settings").component(Settings);
|
|
1128
|
+
|
|
1129
|
+
function Sidebar() {
|
|
1130
|
+
// Loose matching: matches /dashboard and /dashboard/literally/anything
|
|
1131
|
+
const dashboardMatch = useMatch({ from: "/dashboard" });
|
|
1132
|
+
|
|
1133
|
+
// Strict matching: matches only /settings
|
|
1134
|
+
const settingsMatch = useMatch({ from: settings, strict: true });
|
|
1135
|
+
|
|
1136
|
+
return (
|
|
1137
|
+
<nav>
|
|
1138
|
+
{dashboardMatch && <DashboardMenu />}
|
|
1139
|
+
{settingsMatch && <SettingsSubmenu />}
|
|
1140
|
+
</nav>
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
You can also filter by param values to match only specific instances:
|
|
1146
|
+
|
|
1147
|
+
```tsx
|
|
1148
|
+
const adminMatch = useMatch({
|
|
1149
|
+
from: "/users/:id",
|
|
1150
|
+
params: { id: "admin" }
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
if (adminMatch) {
|
|
1154
|
+
// Currently viewing the admin user
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
## Global link configuration
|
|
1159
|
+
|
|
1160
|
+
Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
|
|
910
1161
|
|
|
911
1162
|
```tsx
|
|
912
1163
|
<RouterRoot
|
|
913
1164
|
routes={routes}
|
|
914
1165
|
defaultLinkOptions={{
|
|
915
1166
|
preload: "intent",
|
|
1167
|
+
preloadDelay: 75,
|
|
916
1168
|
className: "app-link",
|
|
917
1169
|
activeClassName: "active"
|
|
918
1170
|
}}
|
|
@@ -921,7 +1173,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
|
921
1173
|
|
|
922
1174
|
Individual links can override any of these defaults by passing their own props.
|
|
923
1175
|
|
|
924
|
-
|
|
1176
|
+
## History middleware
|
|
925
1177
|
|
|
926
1178
|
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:
|
|
927
1179
|
|
|
@@ -960,9 +1212,9 @@ const router = new Router({
|
|
|
960
1212
|
});
|
|
961
1213
|
```
|
|
962
1214
|
|
|
963
|
-
|
|
1215
|
+
## View transitions
|
|
964
1216
|
|
|
965
|
-
|
|
1217
|
+
You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
|
|
966
1218
|
|
|
967
1219
|
```tsx
|
|
968
1220
|
import { flushSync } from "react-dom";
|
|
@@ -1000,132 +1252,37 @@ Add CSS to control the transition:
|
|
|
1000
1252
|
|
|
1001
1253
|
For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
|
|
1002
1254
|
|
|
1003
|
-
### Matching a route anywhere
|
|
1004
|
-
|
|
1005
|
-
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`.
|
|
1006
|
-
|
|
1007
|
-
```tsx
|
|
1008
|
-
import { useMatch } from "waymark";
|
|
1009
|
-
|
|
1010
|
-
const dashboard = route("/dashboard").component(Dashboard);
|
|
1011
|
-
const settings = route("/settings").component(Settings);
|
|
1012
|
-
|
|
1013
|
-
function Sidebar() {
|
|
1014
|
-
// Using route patterns
|
|
1015
|
-
const dashboardMatch = useMatch({ from: "/dashboard" });
|
|
1016
|
-
const settingsMatch = useMatch({ from: "/settings", strict: true });
|
|
1017
|
-
|
|
1018
|
-
// Using route objects
|
|
1019
|
-
const dashboardMatch = useMatch({ from: dashboard });
|
|
1020
|
-
const settingsMatch = useMatch({ from: settings, strict: true });
|
|
1021
|
-
|
|
1022
|
-
return (
|
|
1023
|
-
<nav>
|
|
1024
|
-
{dashboardMatch && <DashboardMenu />}
|
|
1025
|
-
{settingsMatch && <SettingsSubmenu />}
|
|
1026
|
-
</nav>
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
```
|
|
1030
|
-
|
|
1031
|
-
You can also filter by param values to match only specific instances:
|
|
1032
|
-
|
|
1033
|
-
```tsx
|
|
1034
|
-
const adminMatch = useMatch({
|
|
1035
|
-
from: "/users/:id",
|
|
1036
|
-
params: { id: "admin" }
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
if (adminMatch) {
|
|
1040
|
-
// Currently viewing the admin user
|
|
1041
|
-
}
|
|
1042
|
-
```
|
|
1043
|
-
|
|
1044
1255
|
---
|
|
1045
1256
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
### Types
|
|
1049
|
-
|
|
1050
|
-
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1051
|
-
|
|
1052
|
-
```tsx
|
|
1053
|
-
type NavigateOptions<P extends Pattern> = {
|
|
1054
|
-
to: P | Route<P>; // Route pattern or route object
|
|
1055
|
-
params?: Params<P>; // Required if route has dynamic segments
|
|
1056
|
-
search?: Search<P>; // Search params if route defines them
|
|
1057
|
-
replace?: boolean; // Replace history instead of push
|
|
1058
|
-
state?: any; // Arbitrary state to pass
|
|
1059
|
-
};
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
**`HistoryPushOptions`** is for untyped navigation:
|
|
1063
|
-
|
|
1064
|
-
```tsx
|
|
1065
|
-
interface HistoryPushOptions {
|
|
1066
|
-
url: string; // The URL to navigate to
|
|
1067
|
-
replace?: boolean; // Replace history instead of push
|
|
1068
|
-
state?: any; // Arbitrary state to pass
|
|
1069
|
-
}
|
|
1070
|
-
```
|
|
1071
|
-
|
|
1072
|
-
**`MatchOptions<P>`** is used for route matching:
|
|
1073
|
-
|
|
1074
|
-
```tsx
|
|
1075
|
-
type MatchOptions<P extends Pattern> = {
|
|
1076
|
-
from: P | Route<P>; // Route to match against
|
|
1077
|
-
strict?: boolean; // Require exact match (not just prefix)
|
|
1078
|
-
params?: Partial<Params<P>>; // Filter by specific param values
|
|
1079
|
-
};
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
**`Match<P>`** is the result of a successful match:
|
|
1257
|
+
# API reference
|
|
1083
1258
|
|
|
1084
|
-
|
|
1085
|
-
type Match<P extends Pattern> = {
|
|
1086
|
-
route: Route<P>; // The matched route
|
|
1087
|
-
params: Params<P>; // Extracted parameters
|
|
1088
|
-
};
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
**`LinkOptions`** controls link behavior and styling:
|
|
1092
|
-
|
|
1093
|
-
```tsx
|
|
1094
|
-
interface LinkOptions {
|
|
1095
|
-
strict?: boolean; // Strict active matching
|
|
1096
|
-
preload?: "intent" | "render" | "viewport" | false;
|
|
1097
|
-
style?: CSSProperties;
|
|
1098
|
-
className?: string;
|
|
1099
|
-
activeStyle?: CSSProperties;
|
|
1100
|
-
activeClassName?: string;
|
|
1101
|
-
}
|
|
1102
|
-
```
|
|
1103
|
-
|
|
1104
|
-
### Router class
|
|
1259
|
+
## Router class
|
|
1105
1260
|
|
|
1106
1261
|
The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
|
|
1107
1262
|
|
|
1108
|
-
**Constructor:**
|
|
1109
|
-
|
|
1110
|
-
```tsx
|
|
1111
|
-
const router = new Router({
|
|
1112
|
-
routes: Route[], // Required: array of routes
|
|
1113
|
-
basePath: string, // Optional: base path prefix (default: "/")
|
|
1114
|
-
history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
|
|
1115
|
-
defaultLinkOptions: LinkOptions // Optional: defaults for all Links
|
|
1116
|
-
});
|
|
1117
|
-
```
|
|
1118
|
-
|
|
1119
1263
|
**Properties:**
|
|
1120
1264
|
|
|
1121
1265
|
- `router.basePath` - The configured base path
|
|
1122
1266
|
- `router.routes` - The array of routes
|
|
1123
1267
|
- `router.history` - The history instance
|
|
1268
|
+
- `router.ssrContext` - The SSR context (if provided)
|
|
1124
1269
|
- `router.defaultLinkOptions` - Default link options
|
|
1125
1270
|
|
|
1126
|
-
|
|
1271
|
+
**`new Router(options)`** creates a new router.
|
|
1272
|
+
|
|
1273
|
+
- `options` - `RouterOptions` - Router configuration
|
|
1274
|
+
- Returns: `Router` - A new router instance
|
|
1275
|
+
|
|
1276
|
+
```tsx
|
|
1277
|
+
const router = new Router({ routes });
|
|
1278
|
+
const router = new Router({ routes, basePath: "/app" });
|
|
1279
|
+
const router = new Router({ routes, history: new HashHistory() });
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
**`router.navigate(options)`** navigates to a new location.
|
|
1127
1283
|
|
|
1128
|
-
`
|
|
1284
|
+
- `options` - `NavigateOptions | HistoryPushOptions | number` - Type-safe navigation options, untyped navigation options, or a history delta
|
|
1285
|
+
- Returns: `void`
|
|
1129
1286
|
|
|
1130
1287
|
```tsx
|
|
1131
1288
|
// Type-safe navigation
|
|
@@ -1139,103 +1296,64 @@ router.navigate(-1); // Back
|
|
|
1139
1296
|
router.navigate(1); // Forward
|
|
1140
1297
|
```
|
|
1141
1298
|
|
|
1142
|
-
|
|
1299
|
+
**`router.createUrl(options)`** builds a URL string.
|
|
1300
|
+
|
|
1301
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1302
|
+
- Returns: `string` - The constructed URL
|
|
1143
1303
|
|
|
1144
1304
|
```tsx
|
|
1145
1305
|
const url = router.createUrl({ to: userProfile, params: { id: "42" } });
|
|
1146
1306
|
// Returns "/users/42"
|
|
1147
1307
|
```
|
|
1148
1308
|
|
|
1149
|
-
|
|
1309
|
+
**`router.match(path, options)`** checks if a path matches a specific route.
|
|
1310
|
+
|
|
1311
|
+
- `path` - `string` - The path to match against
|
|
1312
|
+
- `options` - `MatchOptions` - Matching options
|
|
1313
|
+
- Returns: `Match | null` - The match result or null if no match
|
|
1150
1314
|
|
|
1151
1315
|
```tsx
|
|
1152
1316
|
const match = router.match("/users/42", { from: "/users/:id" });
|
|
1153
1317
|
// Returns { route, params: { id: "42" } } or null
|
|
1154
1318
|
```
|
|
1155
1319
|
|
|
1156
|
-
|
|
1320
|
+
**`router.matchAll(path)`** finds the best match from all registered routes.
|
|
1321
|
+
|
|
1322
|
+
- `path` - `string` - The path to match against
|
|
1323
|
+
- Returns: `Match | null` - The best match or null if no route matches
|
|
1157
1324
|
|
|
1158
1325
|
```tsx
|
|
1159
1326
|
const match = router.matchAll("/users/42");
|
|
1160
1327
|
// Returns the best match or null
|
|
1161
1328
|
```
|
|
1162
1329
|
|
|
1163
|
-
|
|
1330
|
+
**`router.getRoute(pattern)`** get a route by its pattern.
|
|
1164
1331
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
```
|
|
1168
|
-
|
|
1169
|
-
### History interface
|
|
1170
|
-
|
|
1171
|
-
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1172
|
-
|
|
1173
|
-
**Interface:**
|
|
1332
|
+
- `pattern` - `Pattern | Route` - A route pattern string or a route object
|
|
1333
|
+
- Returns: `Route` - The route object; throws if not found
|
|
1174
1334
|
|
|
1175
1335
|
```tsx
|
|
1176
|
-
|
|
1177
|
-
getPath: () => string;
|
|
1178
|
-
getSearch: () => Record<string, unknown>;
|
|
1179
|
-
getState: () => any;
|
|
1180
|
-
go: (delta: number) => void;
|
|
1181
|
-
push: (options: HistoryPushOptions) => void;
|
|
1182
|
-
subscribe: (listener: () => void) => () => void;
|
|
1183
|
-
}
|
|
1184
|
-
```
|
|
1185
|
-
|
|
1186
|
-
**Methods:**
|
|
1187
|
-
|
|
1188
|
-
`history.getPath()` returns the current pathname:
|
|
1189
|
-
|
|
1190
|
-
```tsx
|
|
1191
|
-
const path = history.getPath();
|
|
1192
|
-
// Returns "/users/42"
|
|
1193
|
-
```
|
|
1194
|
-
|
|
1195
|
-
`history.getSearch()` returns the current search parameters as a parsed object:
|
|
1196
|
-
|
|
1197
|
-
```tsx
|
|
1198
|
-
const search = history.getSearch();
|
|
1199
|
-
// Returns { tab: "posts", page: 2 }
|
|
1200
|
-
```
|
|
1201
|
-
|
|
1202
|
-
`history.getState()` returns the current history state:
|
|
1203
|
-
|
|
1204
|
-
```tsx
|
|
1205
|
-
const state = history.getState();
|
|
1206
|
-
// Returns any state passed during navigation
|
|
1336
|
+
const route = router.getRoute("/users/:id");
|
|
1207
1337
|
```
|
|
1208
1338
|
|
|
1209
|
-
|
|
1339
|
+
**`router.preload(options)`** triggers preloading for a route.
|
|
1210
1340
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
history.go(1); // Go forward
|
|
1214
|
-
history.go(-2); // Go back two steps
|
|
1215
|
-
```
|
|
1216
|
-
|
|
1217
|
-
`history.push(options)` pushes or replaces a history entry:
|
|
1341
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1342
|
+
- Returns: `Promise<void>` - Resolves when preloaded
|
|
1218
1343
|
|
|
1219
1344
|
```tsx
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
```
|
|
1223
|
-
|
|
1224
|
-
`history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
|
|
1225
|
-
|
|
1226
|
-
```tsx
|
|
1227
|
-
const unsubscribe = history.subscribe(() => {
|
|
1228
|
-
console.log("Navigation occurred");
|
|
1229
|
-
});
|
|
1230
|
-
|
|
1231
|
-
// Later: unsubscribe()
|
|
1345
|
+
await router.preload({ to: "/user/:id", params: { id: "42" } });
|
|
1346
|
+
await router.preload({ to: searchPage, search: { q: "test" } });
|
|
1232
1347
|
```
|
|
1233
1348
|
|
|
1234
|
-
|
|
1349
|
+
## Route class
|
|
1235
1350
|
|
|
1236
1351
|
Routes are created with the `route()` function and configured by chaining methods.
|
|
1237
1352
|
|
|
1238
|
-
**`route(pattern)`** creates a new route
|
|
1353
|
+
**`route(pattern)`** creates a new route.
|
|
1354
|
+
|
|
1355
|
+
- `pattern` - `string` - The route path pattern (e.g., `"/users"`, `"/users/:id"`, `"/*"`)
|
|
1356
|
+
- Returns: `Route` - A new route object
|
|
1239
1357
|
|
|
1240
1358
|
```tsx
|
|
1241
1359
|
const users = route("/users");
|
|
@@ -1243,38 +1361,62 @@ const user = route("/users/:id");
|
|
|
1243
1361
|
const catchAll = route("/*");
|
|
1244
1362
|
```
|
|
1245
1363
|
|
|
1246
|
-
**`.route(
|
|
1364
|
+
**`.route(pattern)`** creates a nested child route.
|
|
1365
|
+
|
|
1366
|
+
- `pattern` - `string` - The child path pattern to append
|
|
1367
|
+
- Returns: `Route` - A new route object
|
|
1247
1368
|
|
|
1248
1369
|
```tsx
|
|
1249
1370
|
const userSettings = user.route("/settings");
|
|
1250
1371
|
// Pattern becomes "/users/:id/settings"
|
|
1251
1372
|
```
|
|
1252
1373
|
|
|
1253
|
-
**`.component(component)`** adds a
|
|
1374
|
+
**`.component(component)`** adds a component to render when this route matches.
|
|
1375
|
+
|
|
1376
|
+
- `component` - `ComponentType` - A React component
|
|
1377
|
+
- Returns: `Route` - A new route object
|
|
1254
1378
|
|
|
1255
1379
|
```tsx
|
|
1256
1380
|
const users = route("/users").component(UsersPage);
|
|
1257
1381
|
```
|
|
1258
1382
|
|
|
1259
|
-
**`.lazy(loader)`** adds a lazy-loaded component to render
|
|
1383
|
+
**`.lazy(loader)`** adds a lazy-loaded component to render when this route matches.
|
|
1384
|
+
|
|
1385
|
+
- `loader` - `ComponentLoader` - A function returning a dynamic import promise
|
|
1386
|
+
- Returns: `Route` - A new route object
|
|
1260
1387
|
|
|
1261
1388
|
```tsx
|
|
1262
1389
|
const users = route("/users").lazy(() => import("./UsersPage"));
|
|
1390
|
+
const admin = route("/admin").lazy(() =>
|
|
1391
|
+
import("./Admin").then(m => m.AdminPage)
|
|
1392
|
+
);
|
|
1263
1393
|
```
|
|
1264
1394
|
|
|
1265
|
-
**`.search(
|
|
1395
|
+
**`.search(validate)`** adds search parameter validation.
|
|
1396
|
+
|
|
1397
|
+
- `validate` - `StandardSchema | ((search) => ValidatedSearch)` - A Standard Schema (like Zod) or a validation function
|
|
1398
|
+
- Returns: `Route` - A new route object
|
|
1266
1399
|
|
|
1267
1400
|
```tsx
|
|
1268
1401
|
const search = route("/search").search(z.object({ q: z.string() }));
|
|
1402
|
+
const filter = route("/filter").search(raw => ({
|
|
1403
|
+
term: String(raw.term ?? "")
|
|
1404
|
+
}));
|
|
1269
1405
|
```
|
|
1270
1406
|
|
|
1271
|
-
**`.handle(
|
|
1407
|
+
**`.handle(handle)`** attaches static metadata to the route.
|
|
1408
|
+
|
|
1409
|
+
- `handle` - `Handle` - Arbitrary metadata
|
|
1410
|
+
- Returns: `Route` - A new route object
|
|
1272
1411
|
|
|
1273
1412
|
```tsx
|
|
1274
1413
|
const admin = route("/admin").handle({ requiresAuth: true });
|
|
1275
1414
|
```
|
|
1276
1415
|
|
|
1277
|
-
**`.suspense(fallback)`** wraps
|
|
1416
|
+
**`.suspense(fallback)`** wraps nested content in a Suspense boundary.
|
|
1417
|
+
|
|
1418
|
+
- `fallback` - `ComponentType` - The fallback component to show while suspended
|
|
1419
|
+
- Returns: `Route` - A new route object
|
|
1278
1420
|
|
|
1279
1421
|
```tsx
|
|
1280
1422
|
const lazy = route("/lazy")
|
|
@@ -1282,35 +1424,42 @@ const lazy = route("/lazy")
|
|
|
1282
1424
|
.lazy(() => import("./Page"));
|
|
1283
1425
|
```
|
|
1284
1426
|
|
|
1285
|
-
**`.error(fallback)`** wraps
|
|
1427
|
+
**`.error(fallback)`** wraps nested content in an error boundary.
|
|
1428
|
+
|
|
1429
|
+
- `fallback` - `ComponentType<{ error: unknown }>` - The fallback component, receives the caught error as a prop
|
|
1430
|
+
- Returns: `Route` - A new route object
|
|
1286
1431
|
|
|
1287
1432
|
```tsx
|
|
1288
1433
|
const risky = route("/risky").error(ErrorPage).component(RiskyPage);
|
|
1289
1434
|
```
|
|
1290
1435
|
|
|
1291
|
-
**`.
|
|
1292
|
-
|
|
1293
|
-
```tsx
|
|
1294
|
-
const users = route("/users").preloader(async () => {
|
|
1295
|
-
await prefetchData();
|
|
1296
|
-
});
|
|
1297
|
-
```
|
|
1436
|
+
**`.preload(preload)`** registers a preload function for the route.
|
|
1298
1437
|
|
|
1299
|
-
|
|
1438
|
+
- `preload` - `(context: PreloadContext) => Promise<any>` - An async function receiving typed `params` and `search`
|
|
1439
|
+
- Returns: `Route` - A new route object
|
|
1300
1440
|
|
|
1301
1441
|
```tsx
|
|
1302
|
-
|
|
1442
|
+
const user = route("/users/:id")
|
|
1443
|
+
.search(z.object({ tab: z.string().catch("profile") }))
|
|
1444
|
+
.preload(async ({ params, search }) => {
|
|
1445
|
+
// params.id: string, search.tab: string - fully typed
|
|
1446
|
+
await prefetchUser(params.id, search.tab);
|
|
1447
|
+
});
|
|
1303
1448
|
```
|
|
1304
1449
|
|
|
1305
|
-
|
|
1450
|
+
## Hooks
|
|
1451
|
+
|
|
1452
|
+
**`useRouter()`** returns the Router instance from context.
|
|
1306
1453
|
|
|
1307
|
-
|
|
1454
|
+
- Returns: `Router` - The router instance
|
|
1308
1455
|
|
|
1309
1456
|
```tsx
|
|
1310
1457
|
const router = useRouter();
|
|
1311
1458
|
```
|
|
1312
1459
|
|
|
1313
|
-
**`useNavigate()`** returns a navigation function
|
|
1460
|
+
**`useNavigate()`** returns a navigation function.
|
|
1461
|
+
|
|
1462
|
+
- Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
|
|
1314
1463
|
|
|
1315
1464
|
```tsx
|
|
1316
1465
|
const navigate = useNavigate();
|
|
@@ -1318,34 +1467,47 @@ navigate({ to: "/home" });
|
|
|
1318
1467
|
navigate(-1);
|
|
1319
1468
|
```
|
|
1320
1469
|
|
|
1321
|
-
**`useLocation()`** returns the current location
|
|
1470
|
+
**`useLocation()`** returns the current location, subscribes to changes.
|
|
1471
|
+
|
|
1472
|
+
- Returns: `{ path: string, search: Record<string, unknown>, state: any }` - The current path, parsed search params, and history state
|
|
1322
1473
|
|
|
1323
1474
|
```tsx
|
|
1324
1475
|
const { path, search, state } = useLocation();
|
|
1325
|
-
// path: string, search: Record<string, unknown>, state: any
|
|
1326
1476
|
```
|
|
1327
1477
|
|
|
1328
|
-
**`useOutlet()`** returns the
|
|
1478
|
+
**`useOutlet()`** returns the child route content.
|
|
1479
|
+
|
|
1480
|
+
- Returns: `ReactNode` - The child route's content or null
|
|
1329
1481
|
|
|
1330
1482
|
```tsx
|
|
1331
1483
|
const outlet = useOutlet();
|
|
1332
1484
|
```
|
|
1333
1485
|
|
|
1334
|
-
**`useParams(route)`** returns typed
|
|
1486
|
+
**`useParams(route)`** returns typed path params for a route.
|
|
1487
|
+
|
|
1488
|
+
- `route` - `Pattern | Route` - A route pattern string or route object
|
|
1489
|
+
- Returns: `Params` - The extracted path params, fully typed
|
|
1335
1490
|
|
|
1336
1491
|
```tsx
|
|
1337
1492
|
const { id } = useParams(userRoute);
|
|
1338
1493
|
```
|
|
1339
1494
|
|
|
1340
|
-
**`useSearch(route)`** returns search params and a setter
|
|
1495
|
+
**`useSearch(route)`** returns validated search params and a setter function.
|
|
1496
|
+
|
|
1497
|
+
- `route` - `Pattern | Route` - A route pattern string or route object
|
|
1498
|
+
- Returns: `[Search, SetSearch]` - A tuple of the validated search params and a setter; the setter accepts a partial update or an updater function, with an optional second argument to replace instead of push
|
|
1341
1499
|
|
|
1342
1500
|
```tsx
|
|
1343
1501
|
const [search, setSearch] = useSearch(searchRoute);
|
|
1344
1502
|
setSearch({ page: 2 });
|
|
1345
1503
|
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1504
|
+
setSearch({ page: 1 }, true); // Replace instead of push
|
|
1346
1505
|
```
|
|
1347
1506
|
|
|
1348
|
-
**`useMatch(options)`** checks if a route matches the current path
|
|
1507
|
+
**`useMatch(options)`** checks if a route matches the current path.
|
|
1508
|
+
|
|
1509
|
+
- `options` - `MatchOptions` - Matching options
|
|
1510
|
+
- Returns: `Match | null` - The match result or null if no match
|
|
1349
1511
|
|
|
1350
1512
|
```tsx
|
|
1351
1513
|
const match = useMatch({ from: "/users/:id" });
|
|
@@ -1353,22 +1515,26 @@ const strictMatch = useMatch({ from: "/users", strict: true });
|
|
|
1353
1515
|
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1354
1516
|
```
|
|
1355
1517
|
|
|
1356
|
-
**`useHandles()`** returns
|
|
1518
|
+
**`useHandles()`** returns the handles from the matched route chain.
|
|
1519
|
+
|
|
1520
|
+
- Returns: `Handle[]` - Array of handles
|
|
1357
1521
|
|
|
1358
1522
|
```tsx
|
|
1359
1523
|
const handles = useHandles();
|
|
1360
1524
|
```
|
|
1361
1525
|
|
|
1362
|
-
|
|
1526
|
+
## Components
|
|
1527
|
+
|
|
1528
|
+
**`RouterRoot`** sets up routing context and renders your routes.
|
|
1363
1529
|
|
|
1364
|
-
|
|
1530
|
+
- `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
|
|
1365
1531
|
|
|
1366
1532
|
```tsx
|
|
1367
1533
|
<RouterRoot routes={routes} basePath="/app" history={history} />
|
|
1368
1534
|
<RouterRoot router={router} />
|
|
1369
1535
|
```
|
|
1370
1536
|
|
|
1371
|
-
**`Outlet`** renders child route content
|
|
1537
|
+
**`Outlet`** renders the child route content.
|
|
1372
1538
|
|
|
1373
1539
|
```tsx
|
|
1374
1540
|
function Layout() {
|
|
@@ -1380,7 +1546,9 @@ function Layout() {
|
|
|
1380
1546
|
}
|
|
1381
1547
|
```
|
|
1382
1548
|
|
|
1383
|
-
**`Link`**
|
|
1549
|
+
**`Link`** renders an anchor tag for navigation.
|
|
1550
|
+
|
|
1551
|
+
- `props` - `NavigateOptions & LinkOptions & { asChild?: boolean }` - Navigation options, link options, and optional `asChild` to use a child element as the anchor; other props are passed through
|
|
1384
1552
|
|
|
1385
1553
|
```tsx
|
|
1386
1554
|
<Link to="/path" params={...} search={...} replace strict preload="intent">
|
|
@@ -1388,23 +1556,187 @@ function Layout() {
|
|
|
1388
1556
|
</Link>
|
|
1389
1557
|
```
|
|
1390
1558
|
|
|
1391
|
-
**`Navigate`** redirects on render.
|
|
1559
|
+
**`Navigate`** redirects on render.
|
|
1560
|
+
|
|
1561
|
+
- `props` - `NavigateOptions` - The navigation target
|
|
1392
1562
|
|
|
1393
1563
|
```tsx
|
|
1394
1564
|
<Navigate to="/login" replace />
|
|
1395
1565
|
```
|
|
1396
1566
|
|
|
1397
|
-
|
|
1567
|
+
## History interface
|
|
1568
|
+
|
|
1569
|
+
The `HistoryLike` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1398
1570
|
|
|
1399
|
-
|
|
1571
|
+
**Available implementations:**
|
|
1572
|
+
|
|
1573
|
+
```tsx
|
|
1574
|
+
new BrowserHistory(); // Browser History API (/posts/123). Default.
|
|
1575
|
+
new HashHistory(); // URL hash (/#/posts/123).
|
|
1576
|
+
new MemoryHistory("/initial"); // In-memory only.
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
See [History implementations](#history-implementations) for detailed usage.
|
|
1580
|
+
|
|
1581
|
+
**`history.getPath()`** returns the current path.
|
|
1582
|
+
|
|
1583
|
+
- Returns: `string` - The current path
|
|
1584
|
+
|
|
1585
|
+
```tsx
|
|
1586
|
+
const path = history.getPath();
|
|
1587
|
+
// Returns "/users/42"
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
**`history.getSearch()`** returns the current search params as a parsed JSON object.
|
|
1591
|
+
|
|
1592
|
+
- Returns: `Record<string, unknown>` - The parsed search params
|
|
1593
|
+
|
|
1594
|
+
```tsx
|
|
1595
|
+
const search = history.getSearch();
|
|
1596
|
+
// Returns { tab: "posts", page: 2 }
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
**`history.getState()`** returns the current history state.
|
|
1600
|
+
|
|
1601
|
+
- Returns: `any` - The state passed during navigation, or undefined
|
|
1602
|
+
|
|
1603
|
+
```tsx
|
|
1604
|
+
const state = history.getState();
|
|
1605
|
+
// Returns any state passed during navigation
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
**`history.go(delta)`** navigates forward or back in history.
|
|
1609
|
+
|
|
1610
|
+
- `delta` - `number` - The number of entries to move
|
|
1611
|
+
- Returns: `void`
|
|
1612
|
+
|
|
1613
|
+
```tsx
|
|
1614
|
+
history.go(-1); // Go back
|
|
1615
|
+
history.go(1); // Go forward
|
|
1616
|
+
history.go(-2); // Go back two steps
|
|
1617
|
+
```
|
|
1618
|
+
|
|
1619
|
+
**`history.push(options)`** pushes or replaces a history entry.
|
|
1620
|
+
|
|
1621
|
+
- `options` - `HistoryPushOptions` - The URL to navigate to, with optional `replace` and `state`
|
|
1622
|
+
- Returns: `void`
|
|
1623
|
+
|
|
1624
|
+
```tsx
|
|
1625
|
+
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1626
|
+
history.push({ url: "/login", replace: true });
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
**`history.subscribe(listener)`** subscribes to navigation events.
|
|
1630
|
+
|
|
1631
|
+
- `listener` - `() => void` - Callback invoked when any navigation occurs
|
|
1632
|
+
- Returns: `() => void` - An unsubscribe function
|
|
1633
|
+
|
|
1634
|
+
```tsx
|
|
1635
|
+
const unsubscribe = history.subscribe(() => {
|
|
1636
|
+
console.log("Navigation occurred");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// Later: unsubscribe()
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
## Types
|
|
1643
|
+
|
|
1644
|
+
**`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
|
|
1645
|
+
|
|
1646
|
+
```tsx
|
|
1647
|
+
interface RouterOptions {
|
|
1648
|
+
routes: Route[]; // Array of navigable routes (required)
|
|
1649
|
+
basePath?: string; // Base path prefix (default: "/")
|
|
1650
|
+
history?: HistoryLike; // History implementation (default: BrowserHistory)
|
|
1651
|
+
ssrContext?: SSRContext; // Context for server-side rendering
|
|
1652
|
+
defaultLinkOptions?: LinkOptions; // Default options for all Link components
|
|
1653
|
+
}
|
|
1654
|
+
```
|
|
1655
|
+
|
|
1656
|
+
**`NavigateOptions`** are options for type-safe navigation.
|
|
1657
|
+
|
|
1658
|
+
```tsx
|
|
1659
|
+
type NavigateOptions = {
|
|
1660
|
+
to: Pattern | Route; // Route pattern string or route object
|
|
1661
|
+
params?: Params; // Path params
|
|
1662
|
+
search?: Search; // Search params
|
|
1663
|
+
replace?: boolean; // Replace history entry instead of pushing
|
|
1664
|
+
state?: any; // Arbitrary state to pass
|
|
1665
|
+
};
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
**`HistoryPushOptions`** are options for untyped navigation.
|
|
1669
|
+
|
|
1670
|
+
```tsx
|
|
1671
|
+
interface HistoryPushOptions {
|
|
1672
|
+
url: string; // The URL to navigate to
|
|
1673
|
+
replace?: boolean; // Replace history entry instead of pushing
|
|
1674
|
+
state?: any; // Arbitrary state to pass
|
|
1675
|
+
}
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
**`MatchOptions`** are options for route matching.
|
|
1679
|
+
|
|
1680
|
+
```tsx
|
|
1681
|
+
type MatchOptions = {
|
|
1682
|
+
from: Pattern | Route; // The route to match against
|
|
1683
|
+
strict?: boolean; // Require exact match (default: false, matches prefixes)
|
|
1684
|
+
params?: Partial<Params>; // Optional param values to filter by
|
|
1685
|
+
};
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
**`Match`** is the result of a successful route match.
|
|
1689
|
+
|
|
1690
|
+
```tsx
|
|
1691
|
+
type Match = {
|
|
1692
|
+
route: Route; // Matched route object
|
|
1693
|
+
params: Params; // Extracted path params
|
|
1694
|
+
};
|
|
1695
|
+
```
|
|
1696
|
+
|
|
1697
|
+
**`LinkOptions`** controls link behavior and styling.
|
|
1698
|
+
|
|
1699
|
+
```tsx
|
|
1700
|
+
interface LinkOptions {
|
|
1701
|
+
strict?: boolean; // Strict matching for active state detection
|
|
1702
|
+
preload?: "intent" | "render" | "viewport" | false; // When to trigger preloading
|
|
1703
|
+
preloadDelay?: number; // Delay in ms before preloading starts (default: 50)
|
|
1704
|
+
style?: CSSProperties; // Base styles for the link
|
|
1705
|
+
className?: string; // Base class name for the link
|
|
1706
|
+
activeStyle?: CSSProperties; // Additional styles when active
|
|
1707
|
+
activeClassName?: string; // Additional class name when active
|
|
1708
|
+
}
|
|
1709
|
+
```
|
|
1710
|
+
|
|
1711
|
+
**`SSRContext`** captures context during server-side rendering.
|
|
1712
|
+
|
|
1713
|
+
```tsx
|
|
1714
|
+
type SSRContext = {
|
|
1715
|
+
redirect?: string; // Set by Navigate component during SSR
|
|
1716
|
+
statusCode?: number; // Can be set manually for HTTP status
|
|
1717
|
+
};
|
|
1718
|
+
```
|
|
1719
|
+
|
|
1720
|
+
**`PreloadContext`** is the context passed to preload functions.
|
|
1721
|
+
|
|
1722
|
+
```tsx
|
|
1723
|
+
interface PreloadContext {
|
|
1724
|
+
params: Params; // Path params for the route
|
|
1725
|
+
search: Search; // Validated search params
|
|
1726
|
+
}
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
---
|
|
1400
1730
|
|
|
1401
|
-
|
|
1731
|
+
# Roadmap
|
|
1402
1732
|
|
|
1403
|
-
-
|
|
1404
|
-
-
|
|
1733
|
+
- Possibility to pass an arbitrary context to the Router instance for later use in preloads?
|
|
1734
|
+
- Relative path navigation? Not sure it's indispensable given that users can export/import route objects and pass them as navigation option.
|
|
1735
|
+
- Document usage in test environments
|
|
1736
|
+
- Open to suggestions, we can discuss them [here](https://github.com/strblr/waymark/discussions).
|
|
1405
1737
|
|
|
1406
1738
|
---
|
|
1407
1739
|
|
|
1408
|
-
|
|
1740
|
+
# License
|
|
1409
1741
|
|
|
1410
1742
|
MIT
|