waymark 0.2.3 → 0.3.1
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 +518 -274
- package/dist/index.d.ts +80 -79
- package/dist/index.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -7,11 +7,36 @@
|
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://www.npmjs.com/package/waymark"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
<a href="https://www.npmjs.com/package/waymark">
|
|
11
|
+
<img
|
|
12
|
+
src="https://img.shields.io/npm/v/waymark?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
13
|
+
alt="npm version"
|
|
14
|
+
/>
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://www.npmjs.com/package/waymark">
|
|
17
|
+
<img
|
|
18
|
+
src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
19
|
+
alt="gzip size"
|
|
20
|
+
/>
|
|
21
|
+
</a>
|
|
22
|
+
<a href="https://www.npmjs.com/package/waymark">
|
|
23
|
+
<img
|
|
24
|
+
src="https://img.shields.io/npm/dm/waymark?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
25
|
+
alt="downloads"
|
|
26
|
+
/>
|
|
27
|
+
</a>
|
|
28
|
+
<a href="https://github.com/strblr/waymark/blob/master/LICENSE">
|
|
29
|
+
<img
|
|
30
|
+
src="https://img.shields.io/npm/l/waymark?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
31
|
+
alt="license"
|
|
32
|
+
/>
|
|
33
|
+
</a>
|
|
34
|
+
<a href="https://github.com/sponsors/strblr">
|
|
35
|
+
<img
|
|
36
|
+
src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
|
|
37
|
+
alt="sponsors"
|
|
38
|
+
/>
|
|
39
|
+
</a>
|
|
15
40
|
</p>
|
|
16
41
|
|
|
17
42
|
<p align="center">
|
|
@@ -23,15 +48,16 @@
|
|
|
23
48
|
Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
|
|
24
49
|
|
|
25
50
|
- **Fully type-safe** - Complete TypeScript inference for routes, path params, and search params
|
|
26
|
-
- **Zero config** - No build plugins, no CLI
|
|
51
|
+
- **Zero config** - No build plugins, no CLI, no codegen, no config files, very low boilerplate
|
|
27
52
|
- **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
|
|
28
|
-
- **3.6kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so
|
|
53
|
+
- **3.6kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so ~4kB total
|
|
54
|
+
- **Feature packed** - Search param validation, lazy loading, data preloading, SSR, error boundaries, etc.
|
|
29
55
|
- **Not vibe-coded** - Built with careful design and attention to detail by a human
|
|
30
56
|
- **Just works** - Define routes, get autocomplete everywhere
|
|
31
57
|
|
|
32
58
|
---
|
|
33
59
|
|
|
34
|
-
|
|
60
|
+
# Table of contents
|
|
35
61
|
|
|
36
62
|
- [Showcase](#showcase)
|
|
37
63
|
- [Installation](#installation)
|
|
@@ -48,64 +74,71 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
48
74
|
- [Navigation](#navigation)
|
|
49
75
|
- [The Link component](#the-link-component)
|
|
50
76
|
- [Active state detection](#active-state-detection)
|
|
51
|
-
- [
|
|
77
|
+
- [Route preloading](#route-preloading)
|
|
52
78
|
- [Programmatic navigation](#programmatic-navigation)
|
|
53
79
|
- [Declarative navigation](#declarative-navigation)
|
|
54
80
|
- [Lazy loading](#lazy-loading)
|
|
81
|
+
- [Data preloading](#data-preloading)
|
|
55
82
|
- [Error boundaries](#error-boundaries)
|
|
56
83
|
- [Suspense boundaries](#suspense-boundaries)
|
|
57
84
|
- [Route handles](#route-handles)
|
|
58
85
|
- [Route matching and ranking](#route-matching-and-ranking)
|
|
59
86
|
- [History implementations](#history-implementations)
|
|
60
|
-
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
61
87
|
- [Cookbook](#cookbook)
|
|
88
|
+
- [Quick start example](#quick-start-example)
|
|
89
|
+
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
62
90
|
- [Scroll to top on navigation](#scroll-to-top-on-navigation)
|
|
91
|
+
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
63
92
|
- [Global link configuration](#global-link-configuration)
|
|
64
93
|
- [History middleware](#history-middleware)
|
|
65
94
|
- [View transitions](#view-transitions)
|
|
66
|
-
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
67
95
|
- [API reference](#api-reference)
|
|
68
96
|
- [Router class](#router-class)
|
|
69
97
|
- [Route class](#route-class)
|
|
70
|
-
- [History interface](#history-interface)
|
|
71
98
|
- [Hooks](#hooks)
|
|
72
99
|
- [Components](#components)
|
|
100
|
+
- [History interface](#history-interface)
|
|
73
101
|
- [Types](#types)
|
|
74
102
|
- [Roadmap](#roadmap)
|
|
75
103
|
- [License](#license)
|
|
76
104
|
|
|
77
105
|
---
|
|
78
106
|
|
|
79
|
-
|
|
107
|
+
# Showcase
|
|
80
108
|
|
|
81
|
-
Here's what
|
|
109
|
+
Here's what routing looks like with Waymark:
|
|
82
110
|
|
|
83
111
|
```tsx
|
|
84
|
-
import { route, RouterRoot, Link, useParams } from "waymark";
|
|
112
|
+
import { route, RouterRoot, Outlet, Link, useParams } from "waymark";
|
|
85
113
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
114
|
+
// Layout
|
|
115
|
+
const layout = route("/").component(() => (
|
|
116
|
+
<div>
|
|
117
|
+
<nav>
|
|
118
|
+
<Link to="/">Home</Link>
|
|
119
|
+
<Link to="/users/:id" params={{ id: "42" }}>
|
|
120
|
+
User
|
|
121
|
+
</Link>
|
|
122
|
+
</nav>
|
|
123
|
+
<Outlet />
|
|
124
|
+
</div>
|
|
125
|
+
));
|
|
88
126
|
|
|
89
|
-
|
|
127
|
+
// Pages
|
|
128
|
+
const home = layout.route("/").component(() => <h1>Home</h1>);
|
|
90
129
|
|
|
91
|
-
function UserPage() {
|
|
130
|
+
const user = layout.route("/users/:id").component(function UserPage() {
|
|
92
131
|
const { id } = useParams(user); // Fully typed
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
<h1>User {id}</h1>
|
|
96
|
-
<Link to="/">Back to home</Link> {/* Also fully typed */}
|
|
97
|
-
</div>
|
|
98
|
-
);
|
|
99
|
-
}
|
|
132
|
+
return <h1>User {id}</h1>;
|
|
133
|
+
});
|
|
100
134
|
|
|
101
|
-
//
|
|
135
|
+
// Setup
|
|
102
136
|
const routes = [home, user];
|
|
103
137
|
|
|
104
138
|
function App() {
|
|
105
139
|
return <RouterRoot routes={routes} />;
|
|
106
140
|
}
|
|
107
141
|
|
|
108
|
-
// Register for type safety
|
|
109
142
|
declare module "waymark" {
|
|
110
143
|
interface Register {
|
|
111
144
|
routes: typeof routes;
|
|
@@ -113,11 +146,11 @@ declare module "waymark" {
|
|
|
113
146
|
}
|
|
114
147
|
```
|
|
115
148
|
|
|
116
|
-
|
|
149
|
+
Everything autocompletes and type-checks automatically. No heavy setup, no magic, just a simple API that gets out of your way.
|
|
117
150
|
|
|
118
151
|
---
|
|
119
152
|
|
|
120
|
-
|
|
153
|
+
# Installation
|
|
121
154
|
|
|
122
155
|
```bash
|
|
123
156
|
npm install waymark
|
|
@@ -127,7 +160,7 @@ Waymark requires React 18 or higher.
|
|
|
127
160
|
|
|
128
161
|
---
|
|
129
162
|
|
|
130
|
-
|
|
163
|
+
# Defining routes
|
|
131
164
|
|
|
132
165
|
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.
|
|
133
166
|
|
|
@@ -161,11 +194,11 @@ Route building is immutable: every method on a route returns a new route instanc
|
|
|
161
194
|
|
|
162
195
|
---
|
|
163
196
|
|
|
164
|
-
|
|
197
|
+
# Nested routes and layouts
|
|
165
198
|
|
|
166
|
-
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,
|
|
199
|
+
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.
|
|
167
200
|
|
|
168
|
-
Here's how it works.
|
|
201
|
+
Here's how it works. Start with any route:
|
|
169
202
|
|
|
170
203
|
```tsx
|
|
171
204
|
const dashboard = route("/dashboard").component(DashboardLayout);
|
|
@@ -181,7 +214,7 @@ const profile = dashboard.route("/profile").component(Profile);
|
|
|
181
214
|
|
|
182
215
|
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`.
|
|
183
216
|
|
|
184
|
-
|
|
217
|
+
The parent component must render an `<Outlet />` where child routes should appear:
|
|
185
218
|
|
|
186
219
|
```tsx
|
|
187
220
|
function DashboardLayout() {
|
|
@@ -220,7 +253,7 @@ Each level must include an `<Outlet />` to render the next level.
|
|
|
220
253
|
|
|
221
254
|
---
|
|
222
255
|
|
|
223
|
-
|
|
256
|
+
# Setting up the router
|
|
224
257
|
|
|
225
258
|
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:
|
|
226
259
|
|
|
@@ -236,7 +269,7 @@ const about = layout.route("/about").component(About);
|
|
|
236
269
|
const routes = [home, about]; // ✅ Don't include `layout`
|
|
237
270
|
```
|
|
238
271
|
|
|
239
|
-
This
|
|
272
|
+
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.
|
|
240
273
|
|
|
241
274
|
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.
|
|
242
275
|
|
|
@@ -258,7 +291,7 @@ You can also pass a `basePath` if your app lives under a subpath:
|
|
|
258
291
|
<RouterRoot routes={routes} basePath="/my-app" />
|
|
259
292
|
```
|
|
260
293
|
|
|
261
|
-
The second approach is to create a `Router` instance outside of React. This
|
|
294
|
+
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):
|
|
262
295
|
|
|
263
296
|
```tsx
|
|
264
297
|
import { Router, RouterRoot } from "waymark";
|
|
@@ -288,7 +321,7 @@ With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs
|
|
|
288
321
|
|
|
289
322
|
---
|
|
290
323
|
|
|
291
|
-
|
|
324
|
+
# Code organization
|
|
292
325
|
|
|
293
326
|
There's no prescribed way to organize your routing code. Since Waymark isn't file-based routing, the structure is entirely up to you.
|
|
294
327
|
|
|
@@ -337,11 +370,11 @@ declare module "waymark" {
|
|
|
337
370
|
}
|
|
338
371
|
```
|
|
339
372
|
|
|
340
|
-
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
|
|
373
|
+
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.
|
|
341
374
|
|
|
342
375
|
---
|
|
343
376
|
|
|
344
|
-
|
|
377
|
+
# Path params
|
|
345
378
|
|
|
346
379
|
Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
|
|
347
380
|
|
|
@@ -383,11 +416,11 @@ function FileBrowser() {
|
|
|
383
416
|
|
|
384
417
|
---
|
|
385
418
|
|
|
386
|
-
|
|
419
|
+
# Search params
|
|
387
420
|
|
|
388
|
-
|
|
421
|
+
## Basic usage
|
|
389
422
|
|
|
390
|
-
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
|
|
423
|
+
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.
|
|
391
424
|
|
|
392
425
|
With Zod:
|
|
393
426
|
|
|
@@ -415,7 +448,9 @@ const searchPage = route("/search")
|
|
|
415
448
|
.component(SearchPage);
|
|
416
449
|
```
|
|
417
450
|
|
|
418
|
-
|
|
451
|
+
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.
|
|
452
|
+
|
|
453
|
+
Access validated search params with `useSearch`, which returns a tuple of the current values and a setter function:
|
|
419
454
|
|
|
420
455
|
```tsx
|
|
421
456
|
function SearchPage() {
|
|
@@ -438,12 +473,12 @@ Pass `true` as the second argument to replace the history entry instead of pushi
|
|
|
438
473
|
setSearch({ page: 1 }, true);
|
|
439
474
|
```
|
|
440
475
|
|
|
441
|
-
|
|
476
|
+
## JSON-first approach
|
|
442
477
|
|
|
443
478
|
Waymark uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
444
479
|
|
|
445
|
-
- Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
|
|
446
|
-
- Everything else is JSON-encoded (
|
|
480
|
+
- Plain strings that aren't valid JSON are kept as-is (and URL-encoded): `"John"` → `?name=John` → `"John"`
|
|
481
|
+
- Everything else is JSON-encoded (then URL-encoded):
|
|
447
482
|
- `true` → `?enabled=true` → `true`
|
|
448
483
|
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
449
484
|
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
@@ -451,9 +486,9 @@ Waymark uses a JSON-first approach for search params, similar to TanStack Router
|
|
|
451
486
|
|
|
452
487
|
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.
|
|
453
488
|
|
|
454
|
-
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
|
|
489
|
+
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.
|
|
455
490
|
|
|
456
|
-
|
|
491
|
+
## Inheritance
|
|
457
492
|
|
|
458
493
|
When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing.
|
|
459
494
|
|
|
@@ -480,7 +515,7 @@ function ProjectsPage() {
|
|
|
480
515
|
}
|
|
481
516
|
```
|
|
482
517
|
|
|
483
|
-
If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL
|
|
518
|
+
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.
|
|
484
519
|
|
|
485
520
|
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:
|
|
486
521
|
|
|
@@ -501,7 +536,7 @@ function ProjectsPage() {
|
|
|
501
536
|
}
|
|
502
537
|
```
|
|
503
538
|
|
|
504
|
-
|
|
539
|
+
## Idempotency requirement
|
|
505
540
|
|
|
506
541
|
The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
|
|
507
542
|
|
|
@@ -509,9 +544,9 @@ When you read search params, the values are passed through your validator. When
|
|
|
509
544
|
|
|
510
545
|
---
|
|
511
546
|
|
|
512
|
-
|
|
547
|
+
# Navigation
|
|
513
548
|
|
|
514
|
-
|
|
549
|
+
## The Link component
|
|
515
550
|
|
|
516
551
|
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:
|
|
517
552
|
|
|
@@ -560,7 +595,7 @@ The `asChild` prop lets you use your own component while keeping Link's behavior
|
|
|
560
595
|
</Link>
|
|
561
596
|
```
|
|
562
597
|
|
|
563
|
-
|
|
598
|
+
## Active state detection
|
|
564
599
|
|
|
565
600
|
Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
|
|
566
601
|
|
|
@@ -594,11 +629,13 @@ Or use the `activeClassName` and `activeStyle` props directly:
|
|
|
594
629
|
</Link>
|
|
595
630
|
```
|
|
596
631
|
|
|
597
|
-
|
|
632
|
+
## Route preloading
|
|
598
633
|
|
|
599
|
-
|
|
634
|
+
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.
|
|
600
635
|
|
|
601
|
-
|
|
636
|
+
The `preload` prop controls when preloading happens:
|
|
637
|
+
|
|
638
|
+
**`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:
|
|
602
639
|
|
|
603
640
|
```tsx
|
|
604
641
|
<Link to="/heavy-page" preload="intent">
|
|
@@ -624,15 +661,26 @@ When a route has preloaders, e.g. when using lazy-loaded routes, you can preload
|
|
|
624
661
|
|
|
625
662
|
**`preload={false}`** disables preloading entirely. This is the default.
|
|
626
663
|
|
|
627
|
-
|
|
664
|
+
To prevent unwanted preloads from quick hover/focus interactions, Link waits 50ms before triggering. You can customize this with `preloadDelay`:
|
|
628
665
|
|
|
629
666
|
```tsx
|
|
630
|
-
|
|
667
|
+
<Link to="/heavy-page" preload="intent" preloadDelay={100}>
|
|
668
|
+
Heavy page
|
|
669
|
+
</Link>
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
You can also preload programmatically using `router.preload()`:
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
const router = useRouter();
|
|
676
|
+
router.preload({ to: userProfile, params: { id: "42" } });
|
|
631
677
|
```
|
|
632
678
|
|
|
633
|
-
|
|
679
|
+
To set a preload strategy globally for all links in your app, see [Global link configuration](#global-link-configuration).
|
|
680
|
+
|
|
681
|
+
## Programmatic navigation
|
|
634
682
|
|
|
635
|
-
For navigation triggered by code rather than user clicks, use the `useNavigate` hook
|
|
683
|
+
For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
|
|
636
684
|
|
|
637
685
|
```tsx
|
|
638
686
|
import { useNavigate } from "waymark";
|
|
@@ -665,20 +713,24 @@ navigate(1); // Go forward
|
|
|
665
713
|
navigate(-2); // Go back two steps
|
|
666
714
|
```
|
|
667
715
|
|
|
668
|
-
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
|
|
716
|
+
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:
|
|
717
|
+
|
|
718
|
+
```tsx
|
|
719
|
+
router.navigate({ to: "/login" });
|
|
720
|
+
```
|
|
669
721
|
|
|
670
|
-
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
|
|
722
|
+
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):
|
|
671
723
|
|
|
672
724
|
```tsx
|
|
673
725
|
// Type-safe navigation
|
|
674
726
|
navigate({ to: userProfile, params: { id: "42" } });
|
|
675
727
|
|
|
676
728
|
// Unsafe navigation - no type checking
|
|
677
|
-
navigate({ url: "/some/
|
|
729
|
+
navigate({ url: "/some/path?tab=settings" });
|
|
678
730
|
navigate({ url: "/callback", replace: true, state: { data: 123 } });
|
|
679
731
|
```
|
|
680
732
|
|
|
681
|
-
|
|
733
|
+
## Declarative navigation
|
|
682
734
|
|
|
683
735
|
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:
|
|
684
736
|
|
|
@@ -696,7 +748,7 @@ function ProtectedPage() {
|
|
|
696
748
|
}
|
|
697
749
|
```
|
|
698
750
|
|
|
699
|
-
The `Navigate` component accepts the same navigation props as the `Link` component
|
|
751
|
+
The `Navigate` component accepts the same navigation props as the `Link` component:
|
|
700
752
|
|
|
701
753
|
```tsx
|
|
702
754
|
<Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
|
|
@@ -708,7 +760,7 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
|
|
|
708
760
|
|
|
709
761
|
---
|
|
710
762
|
|
|
711
|
-
|
|
763
|
+
# Lazy loading
|
|
712
764
|
|
|
713
765
|
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
714
766
|
|
|
@@ -743,11 +795,52 @@ const settings = dashboard.route("/settings").component(Settings);
|
|
|
743
795
|
|
|
744
796
|
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.
|
|
745
797
|
|
|
746
|
-
See [
|
|
798
|
+
See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
|
|
747
799
|
|
|
748
800
|
---
|
|
749
801
|
|
|
750
|
-
|
|
802
|
+
# Data preloading
|
|
803
|
+
|
|
804
|
+
Use `.preload()` to run logic before navigation occurs, typically to prefetch data. Preload functions receive the target route's typed params and search values:
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
const userProfile = route("/users/:id")
|
|
808
|
+
.search(z.object({ tab: z.enum(["posts", "comments"]).catch("posts") }))
|
|
809
|
+
.preload(async ({ params, search }) => {
|
|
810
|
+
await queryClient.prefetchQuery({
|
|
811
|
+
queryKey: ["user", params.id, search.tab],
|
|
812
|
+
queryFn: () => fetchUser(params.id, search.tab)
|
|
813
|
+
});
|
|
814
|
+
})
|
|
815
|
+
.component(UserProfile);
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
See [Route preloading](#route-preloading) for how to trigger preload functions.
|
|
819
|
+
|
|
820
|
+
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:
|
|
821
|
+
|
|
822
|
+
```tsx
|
|
823
|
+
await queryClient.prefetchQuery({
|
|
824
|
+
queryKey: ["user", params.id],
|
|
825
|
+
queryFn: () => fetchUser(params.id),
|
|
826
|
+
staleTime: 60_000 // No refetch within 60s
|
|
827
|
+
});
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
Preload functions inherit to child routes:
|
|
831
|
+
|
|
832
|
+
```tsx
|
|
833
|
+
const dashboard = route("/dashboard")
|
|
834
|
+
.preload(prefetchDashboardData)
|
|
835
|
+
.component(DashboardLayout);
|
|
836
|
+
|
|
837
|
+
const settings = dashboard.route("/settings").component(Settings);
|
|
838
|
+
// Preloading /dashboard/settings runs prefetchDashboardData
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
---
|
|
842
|
+
|
|
843
|
+
# Error boundaries
|
|
751
844
|
|
|
752
845
|
Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
|
|
753
846
|
|
|
@@ -771,11 +864,11 @@ Error boundaries catch errors from all nested content. A common pattern is to pl
|
|
|
771
864
|
const app = route("/").error(ErrorPage).component(AppLayout);
|
|
772
865
|
```
|
|
773
866
|
|
|
774
|
-
|
|
867
|
+
To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
|
|
775
868
|
|
|
776
869
|
---
|
|
777
870
|
|
|
778
|
-
|
|
871
|
+
# Suspense boundaries
|
|
779
872
|
|
|
780
873
|
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()`:
|
|
781
874
|
|
|
@@ -804,7 +897,7 @@ Note: React 19 has a [known throttling behavior](https://github.com/facebook/rea
|
|
|
804
897
|
|
|
805
898
|
---
|
|
806
899
|
|
|
807
|
-
|
|
900
|
+
# Route handles
|
|
808
901
|
|
|
809
902
|
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.
|
|
810
903
|
|
|
@@ -830,8 +923,8 @@ function Breadcrumbs() {
|
|
|
830
923
|
<nav>
|
|
831
924
|
{handles.map((h, i) => (
|
|
832
925
|
<span key={i}>
|
|
926
|
+
{i !== 0 && " / "}
|
|
833
927
|
{h.title}
|
|
834
|
-
{i < handles.length - 1 && " / "}
|
|
835
928
|
</span>
|
|
836
929
|
))}
|
|
837
930
|
</nav>
|
|
@@ -854,7 +947,7 @@ declare module "waymark" {
|
|
|
854
947
|
|
|
855
948
|
---
|
|
856
949
|
|
|
857
|
-
|
|
950
|
+
# Route matching and ranking
|
|
858
951
|
|
|
859
952
|
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.
|
|
860
953
|
|
|
@@ -906,7 +999,7 @@ const routes = [
|
|
|
906
999
|
|
|
907
1000
|
---
|
|
908
1001
|
|
|
909
|
-
|
|
1002
|
+
# History implementations
|
|
910
1003
|
|
|
911
1004
|
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.
|
|
912
1005
|
|
|
@@ -940,11 +1033,53 @@ All history implementations conform to the `HistoryLike` interface, so you can c
|
|
|
940
1033
|
|
|
941
1034
|
---
|
|
942
1035
|
|
|
943
|
-
|
|
1036
|
+
# Cookbook
|
|
944
1037
|
|
|
945
|
-
|
|
1038
|
+
## Quick start example
|
|
946
1039
|
|
|
947
|
-
|
|
1040
|
+
Here's a minimal but complete routing setup with a layout and two pages:
|
|
1041
|
+
|
|
1042
|
+
```tsx
|
|
1043
|
+
import { route, RouterRoot, Outlet, Link } from "waymark";
|
|
1044
|
+
|
|
1045
|
+
// Layout route
|
|
1046
|
+
const app = route("/").component(AppLayout);
|
|
1047
|
+
|
|
1048
|
+
function AppLayout() {
|
|
1049
|
+
return (
|
|
1050
|
+
<div>
|
|
1051
|
+
<nav>
|
|
1052
|
+
<Link to="/">Home</Link>
|
|
1053
|
+
<Link to="/about">About</Link>
|
|
1054
|
+
</nav>
|
|
1055
|
+
<main>
|
|
1056
|
+
<Outlet />
|
|
1057
|
+
</main>
|
|
1058
|
+
</div>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Page routes
|
|
1063
|
+
const home = app.route("/").component(() => <h1>Welcome home</h1>);
|
|
1064
|
+
const about = app.route("/about").component(() => <h1>About us</h1>);
|
|
1065
|
+
|
|
1066
|
+
// Router setup
|
|
1067
|
+
const routes = [home, about];
|
|
1068
|
+
|
|
1069
|
+
export function App() {
|
|
1070
|
+
return <RouterRoot routes={routes} />;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
declare module "waymark" {
|
|
1074
|
+
interface Register {
|
|
1075
|
+
routes: typeof routes;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
## Server-side rendering (SSR)
|
|
1081
|
+
|
|
1082
|
+
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:
|
|
948
1083
|
|
|
949
1084
|
```tsx
|
|
950
1085
|
// server.tsx
|
|
@@ -980,16 +1115,12 @@ import { hydrateRoot } from "react-dom/client";
|
|
|
980
1115
|
import { RouterRoot } from "waymark";
|
|
981
1116
|
import { routes } from "./routes";
|
|
982
1117
|
|
|
983
|
-
hydrateRoot(
|
|
1118
|
+
hydrateRoot(rootElement, <RouterRoot routes={routes} />);
|
|
984
1119
|
```
|
|
985
1120
|
|
|
986
1121
|
You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
|
|
987
1122
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
## Cookbook
|
|
991
|
-
|
|
992
|
-
### Scroll to top on navigation
|
|
1123
|
+
## Scroll to top on navigation
|
|
993
1124
|
|
|
994
1125
|
Create a component that scrolls to top when the path changes and include it in your layout:
|
|
995
1126
|
|
|
@@ -1014,7 +1145,48 @@ function AppLayout() {
|
|
|
1014
1145
|
}
|
|
1015
1146
|
```
|
|
1016
1147
|
|
|
1017
|
-
|
|
1148
|
+
## Matching a route anywhere
|
|
1149
|
+
|
|
1150
|
+
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`.
|
|
1151
|
+
|
|
1152
|
+
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`:
|
|
1153
|
+
|
|
1154
|
+
```tsx
|
|
1155
|
+
import { useMatch } from "waymark";
|
|
1156
|
+
|
|
1157
|
+
const dashboard = route("/dashboard").component(Dashboard);
|
|
1158
|
+
const settings = route("/settings").component(Settings);
|
|
1159
|
+
|
|
1160
|
+
function Sidebar() {
|
|
1161
|
+
// Loose matching: matches /dashboard and /dashboard/literally/anything
|
|
1162
|
+
const dashboardMatch = useMatch({ from: "/dashboard" });
|
|
1163
|
+
|
|
1164
|
+
// Strict matching: matches only /settings
|
|
1165
|
+
const settingsMatch = useMatch({ from: settings, strict: true });
|
|
1166
|
+
|
|
1167
|
+
return (
|
|
1168
|
+
<nav>
|
|
1169
|
+
{dashboardMatch && <DashboardMenu />}
|
|
1170
|
+
{settingsMatch && <SettingsSubmenu />}
|
|
1171
|
+
</nav>
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
You can also filter by param values to match only specific instances:
|
|
1177
|
+
|
|
1178
|
+
```tsx
|
|
1179
|
+
const adminMatch = useMatch({
|
|
1180
|
+
from: "/users/:id",
|
|
1181
|
+
params: { id: "admin" }
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
if (adminMatch) {
|
|
1185
|
+
// Currently viewing the admin user
|
|
1186
|
+
}
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
## Global link configuration
|
|
1018
1190
|
|
|
1019
1191
|
Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
|
|
1020
1192
|
|
|
@@ -1023,6 +1195,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
|
1023
1195
|
routes={routes}
|
|
1024
1196
|
defaultLinkOptions={{
|
|
1025
1197
|
preload: "intent",
|
|
1198
|
+
preloadDelay: 75,
|
|
1026
1199
|
className: "app-link",
|
|
1027
1200
|
activeClassName: "active"
|
|
1028
1201
|
}}
|
|
@@ -1031,7 +1204,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
|
1031
1204
|
|
|
1032
1205
|
Individual links can override any of these defaults by passing their own props.
|
|
1033
1206
|
|
|
1034
|
-
|
|
1207
|
+
## History middleware
|
|
1035
1208
|
|
|
1036
1209
|
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:
|
|
1037
1210
|
|
|
@@ -1070,7 +1243,7 @@ const router = new Router({
|
|
|
1070
1243
|
});
|
|
1071
1244
|
```
|
|
1072
1245
|
|
|
1073
|
-
|
|
1246
|
+
## View transitions
|
|
1074
1247
|
|
|
1075
1248
|
You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
|
|
1076
1249
|
|
|
@@ -1110,67 +1283,14 @@ Add CSS to control the transition:
|
|
|
1110
1283
|
|
|
1111
1284
|
For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
|
|
1112
1285
|
|
|
1113
|
-
### Matching a route anywhere
|
|
1114
|
-
|
|
1115
|
-
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`.
|
|
1116
|
-
|
|
1117
|
-
```tsx
|
|
1118
|
-
import { useMatch } from "waymark";
|
|
1119
|
-
|
|
1120
|
-
const dashboard = route("/dashboard").component(Dashboard);
|
|
1121
|
-
const settings = route("/settings").component(Settings);
|
|
1122
|
-
|
|
1123
|
-
function Sidebar() {
|
|
1124
|
-
// Using route patterns
|
|
1125
|
-
const dashboardMatch = useMatch({ from: "/dashboard" });
|
|
1126
|
-
const settingsMatch = useMatch({ from: "/settings", strict: true });
|
|
1127
|
-
|
|
1128
|
-
// Using route objects
|
|
1129
|
-
const dashboardMatch = useMatch({ from: dashboard });
|
|
1130
|
-
const settingsMatch = useMatch({ from: settings, strict: true });
|
|
1131
|
-
|
|
1132
|
-
return (
|
|
1133
|
-
<nav>
|
|
1134
|
-
{dashboardMatch && <DashboardMenu />}
|
|
1135
|
-
{settingsMatch && <SettingsSubmenu />}
|
|
1136
|
-
</nav>
|
|
1137
|
-
);
|
|
1138
|
-
}
|
|
1139
|
-
```
|
|
1140
|
-
|
|
1141
|
-
You can also filter by param values to match only specific instances:
|
|
1142
|
-
|
|
1143
|
-
```tsx
|
|
1144
|
-
const adminMatch = useMatch({
|
|
1145
|
-
from: "/users/:id",
|
|
1146
|
-
params: { id: "admin" }
|
|
1147
|
-
});
|
|
1148
|
-
|
|
1149
|
-
if (adminMatch) {
|
|
1150
|
-
// Currently viewing the admin user
|
|
1151
|
-
}
|
|
1152
|
-
```
|
|
1153
|
-
|
|
1154
1286
|
---
|
|
1155
1287
|
|
|
1156
|
-
|
|
1288
|
+
# API reference
|
|
1157
1289
|
|
|
1158
|
-
|
|
1290
|
+
## Router class
|
|
1159
1291
|
|
|
1160
1292
|
The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
|
|
1161
1293
|
|
|
1162
|
-
**Constructor:**
|
|
1163
|
-
|
|
1164
|
-
```tsx
|
|
1165
|
-
const router = new Router({
|
|
1166
|
-
basePath: string, // Optional: base path prefix (default: "/")
|
|
1167
|
-
routes: Route[], // Required: array of routes
|
|
1168
|
-
history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
|
|
1169
|
-
ssrContext: SSRContext, // Optional: SSR context
|
|
1170
|
-
defaultLinkOptions: LinkOptions // Optional: defaults for all Links
|
|
1171
|
-
});
|
|
1172
|
-
```
|
|
1173
|
-
|
|
1174
1294
|
**Properties:**
|
|
1175
1295
|
|
|
1176
1296
|
- `router.basePath` - The configured base path
|
|
@@ -1179,9 +1299,21 @@ const router = new Router({
|
|
|
1179
1299
|
- `router.ssrContext` - The SSR context (if provided)
|
|
1180
1300
|
- `router.defaultLinkOptions` - Default link options
|
|
1181
1301
|
|
|
1182
|
-
|
|
1302
|
+
**`new Router(options)`** creates a new router.
|
|
1303
|
+
|
|
1304
|
+
- `options` - `RouterOptions` - Router configuration
|
|
1305
|
+
- Returns: `Router` - A new router instance
|
|
1306
|
+
|
|
1307
|
+
```tsx
|
|
1308
|
+
const router = new Router({ routes });
|
|
1309
|
+
const router = new Router({ routes, basePath: "/app" });
|
|
1310
|
+
const router = new Router({ routes, history: new HashHistory() });
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
**`router.navigate(options)`** navigates to a new location.
|
|
1183
1314
|
|
|
1184
|
-
`
|
|
1315
|
+
- `options` - `NavigateOptions | HistoryPushOptions | number` - Type-safe navigation options, untyped navigation options, or a history delta
|
|
1316
|
+
- Returns: `void`
|
|
1185
1317
|
|
|
1186
1318
|
```tsx
|
|
1187
1319
|
// Type-safe navigation
|
|
@@ -1195,38 +1327,64 @@ router.navigate(-1); // Back
|
|
|
1195
1327
|
router.navigate(1); // Forward
|
|
1196
1328
|
```
|
|
1197
1329
|
|
|
1198
|
-
|
|
1330
|
+
**`router.createUrl(options)`** builds a URL string.
|
|
1331
|
+
|
|
1332
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1333
|
+
- Returns: `string` - The constructed URL
|
|
1199
1334
|
|
|
1200
1335
|
```tsx
|
|
1201
1336
|
const url = router.createUrl({ to: userProfile, params: { id: "42" } });
|
|
1202
1337
|
// Returns "/users/42"
|
|
1203
1338
|
```
|
|
1204
1339
|
|
|
1205
|
-
|
|
1340
|
+
**`router.match(path, options)`** checks if a path matches a specific route.
|
|
1341
|
+
|
|
1342
|
+
- `path` - `string` - The path to match against
|
|
1343
|
+
- `options` - `MatchOptions` - Matching options
|
|
1344
|
+
- Returns: `Match | null` - The match result or null if no match
|
|
1206
1345
|
|
|
1207
1346
|
```tsx
|
|
1208
1347
|
const match = router.match("/users/42", { from: "/users/:id" });
|
|
1209
1348
|
// Returns { route, params: { id: "42" } } or null
|
|
1210
1349
|
```
|
|
1211
1350
|
|
|
1212
|
-
|
|
1351
|
+
**`router.matchAll(path)`** finds the best match from all registered routes.
|
|
1352
|
+
|
|
1353
|
+
- `path` - `string` - The path to match against
|
|
1354
|
+
- Returns: `Match | null` - The best match or null if no route matches
|
|
1213
1355
|
|
|
1214
1356
|
```tsx
|
|
1215
1357
|
const match = router.matchAll("/users/42");
|
|
1216
1358
|
// Returns the best match or null
|
|
1217
1359
|
```
|
|
1218
1360
|
|
|
1219
|
-
|
|
1361
|
+
**`router.getRoute(pattern)`** get a route by its pattern.
|
|
1362
|
+
|
|
1363
|
+
- `pattern` - `Pattern | Route` - A route pattern string or a route object
|
|
1364
|
+
- Returns: `Route` - The route object; throws if not found
|
|
1220
1365
|
|
|
1221
1366
|
```tsx
|
|
1222
1367
|
const route = router.getRoute("/users/:id");
|
|
1223
1368
|
```
|
|
1224
1369
|
|
|
1225
|
-
|
|
1370
|
+
**`router.preload(options)`** triggers preloading for a route.
|
|
1371
|
+
|
|
1372
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1373
|
+
- Returns: `Promise<void>` - Resolves when preloaded
|
|
1374
|
+
|
|
1375
|
+
```tsx
|
|
1376
|
+
await router.preload({ to: "/user/:id", params: { id: "42" } });
|
|
1377
|
+
await router.preload({ to: searchPage, search: { q: "test" } });
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
## Route class
|
|
1226
1381
|
|
|
1227
1382
|
Routes are created with the `route()` function and configured by chaining methods.
|
|
1228
1383
|
|
|
1229
|
-
**`route(pattern)`** creates a new route
|
|
1384
|
+
**`route(pattern)`** creates a new route.
|
|
1385
|
+
|
|
1386
|
+
- `pattern` - `string` - The route path pattern (e.g., `"/users"`, `"/users/:id"`, `"/*"`)
|
|
1387
|
+
- Returns: `Route` - A new route object
|
|
1230
1388
|
|
|
1231
1389
|
```tsx
|
|
1232
1390
|
const users = route("/users");
|
|
@@ -1234,38 +1392,62 @@ const user = route("/users/:id");
|
|
|
1234
1392
|
const catchAll = route("/*");
|
|
1235
1393
|
```
|
|
1236
1394
|
|
|
1237
|
-
**`.route(
|
|
1395
|
+
**`.route(pattern)`** creates a nested child route.
|
|
1396
|
+
|
|
1397
|
+
- `pattern` - `string` - The child path pattern to append
|
|
1398
|
+
- Returns: `Route` - A new route object
|
|
1238
1399
|
|
|
1239
1400
|
```tsx
|
|
1240
1401
|
const userSettings = user.route("/settings");
|
|
1241
1402
|
// Pattern becomes "/users/:id/settings"
|
|
1242
1403
|
```
|
|
1243
1404
|
|
|
1244
|
-
**`.component(component)`** adds a
|
|
1405
|
+
**`.component(component)`** adds a component to render when this route matches.
|
|
1406
|
+
|
|
1407
|
+
- `component` - `ComponentType` - A React component
|
|
1408
|
+
- Returns: `Route` - A new route object
|
|
1245
1409
|
|
|
1246
1410
|
```tsx
|
|
1247
1411
|
const users = route("/users").component(UsersPage);
|
|
1248
1412
|
```
|
|
1249
1413
|
|
|
1250
|
-
**`.lazy(loader)`** adds a lazy-loaded component to render
|
|
1414
|
+
**`.lazy(loader)`** adds a lazy-loaded component to render when this route matches.
|
|
1415
|
+
|
|
1416
|
+
- `loader` - `ComponentLoader` - A function returning a dynamic import promise
|
|
1417
|
+
- Returns: `Route` - A new route object
|
|
1251
1418
|
|
|
1252
1419
|
```tsx
|
|
1253
1420
|
const users = route("/users").lazy(() => import("./UsersPage"));
|
|
1421
|
+
const admin = route("/admin").lazy(() =>
|
|
1422
|
+
import("./Admin").then(m => m.AdminPage)
|
|
1423
|
+
);
|
|
1254
1424
|
```
|
|
1255
1425
|
|
|
1256
|
-
**`.search(
|
|
1426
|
+
**`.search(validate)`** adds search parameter validation.
|
|
1427
|
+
|
|
1428
|
+
- `validate` - `StandardSchema | ((search) => ValidatedSearch)` - A Standard Schema (like Zod) or a validation function
|
|
1429
|
+
- Returns: `Route` - A new route object
|
|
1257
1430
|
|
|
1258
1431
|
```tsx
|
|
1259
1432
|
const search = route("/search").search(z.object({ q: z.string() }));
|
|
1433
|
+
const filter = route("/filter").search(raw => ({
|
|
1434
|
+
term: String(raw.term ?? "")
|
|
1435
|
+
}));
|
|
1260
1436
|
```
|
|
1261
1437
|
|
|
1262
|
-
**`.handle(
|
|
1438
|
+
**`.handle(handle)`** attaches static metadata to the route.
|
|
1439
|
+
|
|
1440
|
+
- `handle` - `Handle` - Arbitrary metadata
|
|
1441
|
+
- Returns: `Route` - A new route object
|
|
1263
1442
|
|
|
1264
1443
|
```tsx
|
|
1265
1444
|
const admin = route("/admin").handle({ requiresAuth: true });
|
|
1266
1445
|
```
|
|
1267
1446
|
|
|
1268
|
-
**`.suspense(fallback)`** wraps
|
|
1447
|
+
**`.suspense(fallback)`** wraps nested content in a Suspense boundary.
|
|
1448
|
+
|
|
1449
|
+
- `fallback` - `ComponentType` - The fallback component to show while suspended
|
|
1450
|
+
- Returns: `Route` - A new route object
|
|
1269
1451
|
|
|
1270
1452
|
```tsx
|
|
1271
1453
|
const lazy = route("/lazy")
|
|
@@ -1273,258 +1455,320 @@ const lazy = route("/lazy")
|
|
|
1273
1455
|
.lazy(() => import("./Page"));
|
|
1274
1456
|
```
|
|
1275
1457
|
|
|
1276
|
-
**`.error(fallback)`** wraps
|
|
1458
|
+
**`.error(fallback)`** wraps nested content in an error boundary.
|
|
1459
|
+
|
|
1460
|
+
- `fallback` - `ComponentType<{ error: unknown }>` - The fallback component, receives the caught error as a prop
|
|
1461
|
+
- Returns: `Route` - A new route object
|
|
1277
1462
|
|
|
1278
1463
|
```tsx
|
|
1279
1464
|
const risky = route("/risky").error(ErrorPage).component(RiskyPage);
|
|
1280
1465
|
```
|
|
1281
1466
|
|
|
1282
|
-
**`.
|
|
1467
|
+
**`.preload(preload)`** registers a preload function for the route.
|
|
1468
|
+
|
|
1469
|
+
- `preload` - `(context: PreloadContext) => Promise<any>` - An async function receiving typed `params` and `search`
|
|
1470
|
+
- Returns: `Route` - A new route object
|
|
1283
1471
|
|
|
1284
1472
|
```tsx
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
})
|
|
1473
|
+
const user = route("/users/:id")
|
|
1474
|
+
.search(z.object({ tab: z.string().catch("profile") }))
|
|
1475
|
+
.preload(async ({ params, search }) => {
|
|
1476
|
+
// params.id: string, search.tab: string - fully typed
|
|
1477
|
+
await prefetchUser(params.id, search.tab);
|
|
1478
|
+
});
|
|
1288
1479
|
```
|
|
1289
1480
|
|
|
1290
|
-
|
|
1481
|
+
## Hooks
|
|
1482
|
+
|
|
1483
|
+
**`useRouter()`** returns the Router instance from context.
|
|
1484
|
+
|
|
1485
|
+
- Returns: `Router` - The router instance
|
|
1291
1486
|
|
|
1292
1487
|
```tsx
|
|
1293
|
-
|
|
1488
|
+
const router = useRouter();
|
|
1294
1489
|
```
|
|
1295
1490
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1491
|
+
**`useNavigate()`** returns a navigation function.
|
|
1299
1492
|
|
|
1300
|
-
|
|
1493
|
+
- Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
|
|
1301
1494
|
|
|
1302
1495
|
```tsx
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
getState: () => any;
|
|
1307
|
-
go: (delta: number) => void;
|
|
1308
|
-
push: (options: HistoryPushOptions) => void;
|
|
1309
|
-
subscribe: (listener: () => void) => () => void;
|
|
1310
|
-
}
|
|
1496
|
+
const navigate = useNavigate();
|
|
1497
|
+
navigate({ to: "/home" });
|
|
1498
|
+
navigate(-1);
|
|
1311
1499
|
```
|
|
1312
1500
|
|
|
1313
|
-
|
|
1501
|
+
**`useLocation()`** returns the current location, subscribes to changes.
|
|
1314
1502
|
|
|
1315
|
-
`
|
|
1503
|
+
- Returns: `{ path: string, search: Record<string, unknown>, state: any }` - The current path, parsed search params, and history state
|
|
1316
1504
|
|
|
1317
1505
|
```tsx
|
|
1318
|
-
const path =
|
|
1319
|
-
// Returns "/users/42"
|
|
1506
|
+
const { path, search, state } = useLocation();
|
|
1320
1507
|
```
|
|
1321
1508
|
|
|
1322
|
-
|
|
1509
|
+
**`useOutlet()`** returns the child route content.
|
|
1510
|
+
|
|
1511
|
+
- Returns: `ReactNode` - The child route's content or null
|
|
1323
1512
|
|
|
1324
1513
|
```tsx
|
|
1325
|
-
const
|
|
1326
|
-
// Returns { tab: "posts", page: 2 }
|
|
1514
|
+
const outlet = useOutlet();
|
|
1327
1515
|
```
|
|
1328
1516
|
|
|
1329
|
-
|
|
1517
|
+
**`useParams(route)`** returns typed path params for a route.
|
|
1518
|
+
|
|
1519
|
+
- `route` - `Pattern | Route` - A route pattern string or route object
|
|
1520
|
+
- Returns: `Params` - The extracted path params, fully typed
|
|
1330
1521
|
|
|
1331
1522
|
```tsx
|
|
1332
|
-
const
|
|
1333
|
-
// Returns any state passed during navigation
|
|
1523
|
+
const { id } = useParams(userRoute);
|
|
1334
1524
|
```
|
|
1335
1525
|
|
|
1336
|
-
|
|
1526
|
+
**`useSearch(route)`** returns validated search params and a setter function.
|
|
1527
|
+
|
|
1528
|
+
- `route` - `Pattern | Route` - A route pattern string or route object
|
|
1529
|
+
- 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
|
|
1337
1530
|
|
|
1338
1531
|
```tsx
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1532
|
+
const [search, setSearch] = useSearch(searchRoute);
|
|
1533
|
+
setSearch({ page: 2 });
|
|
1534
|
+
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1535
|
+
setSearch({ page: 1 }, true); // Replace instead of push
|
|
1342
1536
|
```
|
|
1343
1537
|
|
|
1344
|
-
|
|
1538
|
+
**`useMatch(options)`** checks if a route matches the current path.
|
|
1539
|
+
|
|
1540
|
+
- `options` - `MatchOptions` - Matching options
|
|
1541
|
+
- Returns: `Match | null` - The match result or null if no match
|
|
1345
1542
|
|
|
1346
1543
|
```tsx
|
|
1347
|
-
|
|
1348
|
-
|
|
1544
|
+
const match = useMatch({ from: "/users/:id" });
|
|
1545
|
+
const strictMatch = useMatch({ from: "/users", strict: true });
|
|
1546
|
+
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1349
1547
|
```
|
|
1350
1548
|
|
|
1351
|
-
|
|
1549
|
+
**`useHandles()`** returns the handles from the matched route chain.
|
|
1352
1550
|
|
|
1353
|
-
|
|
1354
|
-
const unsubscribe = history.subscribe(() => {
|
|
1355
|
-
console.log("Navigation occurred");
|
|
1356
|
-
});
|
|
1551
|
+
- Returns: `Handle[]` - Array of handles
|
|
1357
1552
|
|
|
1358
|
-
|
|
1553
|
+
```tsx
|
|
1554
|
+
const handles = useHandles();
|
|
1359
1555
|
```
|
|
1360
1556
|
|
|
1361
|
-
|
|
1557
|
+
## Components
|
|
1362
1558
|
|
|
1363
|
-
**`
|
|
1559
|
+
**`RouterRoot`** sets up routing context and renders your routes.
|
|
1560
|
+
|
|
1561
|
+
- `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
|
|
1364
1562
|
|
|
1365
1563
|
```tsx
|
|
1366
|
-
|
|
1564
|
+
<RouterRoot routes={routes} basePath="/app" history={history} />
|
|
1565
|
+
<RouterRoot router={router} />
|
|
1367
1566
|
```
|
|
1368
1567
|
|
|
1369
|
-
**`
|
|
1568
|
+
**`Outlet`** renders the child route content.
|
|
1370
1569
|
|
|
1371
1570
|
```tsx
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1571
|
+
function Layout() {
|
|
1572
|
+
return (
|
|
1573
|
+
<div>
|
|
1574
|
+
<Outlet />
|
|
1575
|
+
</div>
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1375
1578
|
```
|
|
1376
1579
|
|
|
1377
|
-
**`
|
|
1580
|
+
**`Link`** renders an anchor tag for navigation.
|
|
1581
|
+
|
|
1582
|
+
- `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
|
|
1378
1583
|
|
|
1379
1584
|
```tsx
|
|
1380
|
-
|
|
1381
|
-
|
|
1585
|
+
<Link to="/path" params={...} search={...} replace strict preload="intent">
|
|
1586
|
+
Click me
|
|
1587
|
+
</Link>
|
|
1382
1588
|
```
|
|
1383
1589
|
|
|
1384
|
-
**`
|
|
1590
|
+
**`Navigate`** redirects on render.
|
|
1591
|
+
|
|
1592
|
+
- `props` - `NavigateOptions` - The navigation target
|
|
1385
1593
|
|
|
1386
1594
|
```tsx
|
|
1387
|
-
|
|
1595
|
+
<Navigate to="/login" replace />
|
|
1388
1596
|
```
|
|
1389
1597
|
|
|
1390
|
-
|
|
1598
|
+
## History interface
|
|
1599
|
+
|
|
1600
|
+
The `HistoryLike` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1601
|
+
|
|
1602
|
+
**Available implementations:**
|
|
1391
1603
|
|
|
1392
1604
|
```tsx
|
|
1393
|
-
|
|
1605
|
+
new BrowserHistory(); // Browser History API (/posts/123). Default.
|
|
1606
|
+
new HashHistory(); // URL hash (/#/posts/123).
|
|
1607
|
+
new MemoryHistory("/initial"); // In-memory only.
|
|
1394
1608
|
```
|
|
1395
1609
|
|
|
1396
|
-
|
|
1610
|
+
See [History implementations](#history-implementations) for detailed usage.
|
|
1611
|
+
|
|
1612
|
+
**`history.getPath()`** returns the current path.
|
|
1613
|
+
|
|
1614
|
+
- Returns: `string` - The current path
|
|
1397
1615
|
|
|
1398
1616
|
```tsx
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1617
|
+
const path = history.getPath();
|
|
1618
|
+
// Returns "/users/42"
|
|
1402
1619
|
```
|
|
1403
1620
|
|
|
1404
|
-
**`
|
|
1621
|
+
**`history.getSearch()`** returns the current search params as a parsed JSON object.
|
|
1622
|
+
|
|
1623
|
+
- Returns: `Record<string, unknown>` - The parsed search params
|
|
1405
1624
|
|
|
1406
1625
|
```tsx
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1626
|
+
const search = history.getSearch();
|
|
1627
|
+
// Returns { tab: "posts", page: 2 }
|
|
1410
1628
|
```
|
|
1411
1629
|
|
|
1412
|
-
**`
|
|
1630
|
+
**`history.getState()`** returns the current history state.
|
|
1631
|
+
|
|
1632
|
+
- Returns: `any` - The state passed during navigation, or undefined
|
|
1413
1633
|
|
|
1414
1634
|
```tsx
|
|
1415
|
-
const
|
|
1635
|
+
const state = history.getState();
|
|
1636
|
+
// Returns any state passed during navigation
|
|
1416
1637
|
```
|
|
1417
1638
|
|
|
1418
|
-
|
|
1639
|
+
**`history.go(delta)`** navigates forward or back in history.
|
|
1419
1640
|
|
|
1420
|
-
|
|
1641
|
+
- `delta` - `number` - The number of entries to move
|
|
1642
|
+
- Returns: `void`
|
|
1421
1643
|
|
|
1422
1644
|
```tsx
|
|
1423
|
-
|
|
1424
|
-
|
|
1645
|
+
history.go(-1); // Go back
|
|
1646
|
+
history.go(1); // Go forward
|
|
1647
|
+
history.go(-2); // Go back two steps
|
|
1425
1648
|
```
|
|
1426
1649
|
|
|
1427
|
-
**`
|
|
1650
|
+
**`history.push(options)`** pushes or replaces a history entry.
|
|
1651
|
+
|
|
1652
|
+
- `options` - `HistoryPushOptions` - The URL to navigate to, with optional `replace` and `state`
|
|
1653
|
+
- Returns: `void`
|
|
1428
1654
|
|
|
1429
1655
|
```tsx
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
<div>
|
|
1433
|
-
<Outlet />
|
|
1434
|
-
</div>
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1656
|
+
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1657
|
+
history.push({ url: "/login", replace: true });
|
|
1437
1658
|
```
|
|
1438
1659
|
|
|
1439
|
-
**`
|
|
1660
|
+
**`history.subscribe(listener)`** subscribes to navigation events.
|
|
1661
|
+
|
|
1662
|
+
- `listener` - `() => void` - Callback invoked when any navigation occurs
|
|
1663
|
+
- Returns: `() => void` - An unsubscribe function
|
|
1440
1664
|
|
|
1441
1665
|
```tsx
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1666
|
+
const unsubscribe = history.subscribe(() => {
|
|
1667
|
+
console.log("Navigation occurred");
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
// Later: unsubscribe()
|
|
1445
1671
|
```
|
|
1446
1672
|
|
|
1447
|
-
|
|
1673
|
+
## Types
|
|
1674
|
+
|
|
1675
|
+
**`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
|
|
1448
1676
|
|
|
1449
1677
|
```tsx
|
|
1450
|
-
|
|
1678
|
+
interface RouterOptions {
|
|
1679
|
+
routes: Route[]; // Array of navigable routes (required)
|
|
1680
|
+
basePath?: string; // Base path prefix (default: "/")
|
|
1681
|
+
history?: HistoryLike; // History implementation (default: BrowserHistory)
|
|
1682
|
+
ssrContext?: SSRContext; // Context for server-side rendering
|
|
1683
|
+
defaultLinkOptions?: LinkOptions; // Default options for all Link components
|
|
1684
|
+
}
|
|
1451
1685
|
```
|
|
1452
1686
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1687
|
+
**`NavigateOptions`** are options for type-safe navigation.
|
|
1456
1688
|
|
|
1457
1689
|
```tsx
|
|
1458
|
-
type NavigateOptions
|
|
1459
|
-
to:
|
|
1460
|
-
params?: Params
|
|
1461
|
-
search?: Search
|
|
1462
|
-
replace?: boolean; // Replace history instead of
|
|
1690
|
+
type NavigateOptions = {
|
|
1691
|
+
to: Pattern | Route; // Route pattern string or route object
|
|
1692
|
+
params?: Params; // Path params
|
|
1693
|
+
search?: Search; // Search params
|
|
1694
|
+
replace?: boolean; // Replace history entry instead of pushing
|
|
1463
1695
|
state?: any; // Arbitrary state to pass
|
|
1464
1696
|
};
|
|
1465
1697
|
```
|
|
1466
1698
|
|
|
1467
|
-
**`HistoryPushOptions`**
|
|
1699
|
+
**`HistoryPushOptions`** are options for untyped navigation.
|
|
1468
1700
|
|
|
1469
1701
|
```tsx
|
|
1470
1702
|
interface HistoryPushOptions {
|
|
1471
1703
|
url: string; // The URL to navigate to
|
|
1472
|
-
replace?: boolean; // Replace history instead of
|
|
1704
|
+
replace?: boolean; // Replace history entry instead of pushing
|
|
1473
1705
|
state?: any; // Arbitrary state to pass
|
|
1474
1706
|
}
|
|
1475
1707
|
```
|
|
1476
1708
|
|
|
1477
|
-
**`MatchOptions
|
|
1709
|
+
**`MatchOptions`** are options for route matching.
|
|
1478
1710
|
|
|
1479
1711
|
```tsx
|
|
1480
|
-
type MatchOptions
|
|
1481
|
-
from:
|
|
1482
|
-
strict?: boolean; // Require exact match (
|
|
1483
|
-
params?: Partial<Params
|
|
1712
|
+
type MatchOptions = {
|
|
1713
|
+
from: Pattern | Route; // The route to match against
|
|
1714
|
+
strict?: boolean; // Require exact match (default: false, matches prefixes)
|
|
1715
|
+
params?: Partial<Params>; // Optional param values to filter by
|
|
1484
1716
|
};
|
|
1485
1717
|
```
|
|
1486
1718
|
|
|
1487
|
-
**`Match
|
|
1719
|
+
**`Match`** is the result of a successful route match.
|
|
1488
1720
|
|
|
1489
1721
|
```tsx
|
|
1490
|
-
type Match
|
|
1491
|
-
route: Route
|
|
1492
|
-
params: Params
|
|
1722
|
+
type Match = {
|
|
1723
|
+
route: Route; // Matched route object
|
|
1724
|
+
params: Params; // Extracted path params
|
|
1493
1725
|
};
|
|
1494
1726
|
```
|
|
1495
1727
|
|
|
1496
|
-
**`LinkOptions`** controls link behavior and styling
|
|
1728
|
+
**`LinkOptions`** controls link behavior and styling.
|
|
1497
1729
|
|
|
1498
1730
|
```tsx
|
|
1499
1731
|
interface LinkOptions {
|
|
1500
|
-
strict?: boolean; // Strict active
|
|
1501
|
-
preload?: "intent" | "render" | "viewport" | false;
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1732
|
+
strict?: boolean; // Strict matching for active state detection
|
|
1733
|
+
preload?: "intent" | "render" | "viewport" | false; // When to trigger preloading
|
|
1734
|
+
preloadDelay?: number; // Delay in ms before preloading starts (default: 50)
|
|
1735
|
+
style?: CSSProperties; // Base styles for the link
|
|
1736
|
+
className?: string; // Base class name for the link
|
|
1737
|
+
activeStyle?: CSSProperties; // Additional styles when active
|
|
1738
|
+
activeClassName?: string; // Additional class name when active
|
|
1506
1739
|
}
|
|
1507
1740
|
```
|
|
1508
1741
|
|
|
1509
|
-
**`SSRContext`** captures context during
|
|
1742
|
+
**`SSRContext`** captures context during server-side rendering.
|
|
1510
1743
|
|
|
1511
1744
|
```tsx
|
|
1512
1745
|
type SSRContext = {
|
|
1513
1746
|
redirect?: string; // Set by Navigate component during SSR
|
|
1514
|
-
statusCode?: number; // Can be set manually
|
|
1747
|
+
statusCode?: number; // Can be set manually for HTTP status
|
|
1515
1748
|
};
|
|
1516
1749
|
```
|
|
1517
1750
|
|
|
1518
|
-
|
|
1751
|
+
**`PreloadContext`** is the context passed to preload functions.
|
|
1519
1752
|
|
|
1520
|
-
|
|
1753
|
+
```tsx
|
|
1754
|
+
interface PreloadContext {
|
|
1755
|
+
params: Params; // Path params for the route
|
|
1756
|
+
search: Search; // Validated search params
|
|
1757
|
+
}
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
---
|
|
1521
1761
|
|
|
1522
|
-
|
|
1762
|
+
# Roadmap
|
|
1523
1763
|
|
|
1524
|
-
-
|
|
1764
|
+
- Possibility to pass an arbitrary context to the Router instance for later use in preloads?
|
|
1765
|
+
- Relative path navigation? Not sure it's indispensable given that users can export/import route objects and pass them as navigation option.
|
|
1766
|
+
- Document usage in test environments
|
|
1767
|
+
- Devtools? Let me know if needed.
|
|
1768
|
+
- Open to suggestions, we can discuss them [here](https://github.com/strblr/waymark/discussions).
|
|
1525
1769
|
|
|
1526
1770
|
---
|
|
1527
1771
|
|
|
1528
|
-
|
|
1772
|
+
# License
|
|
1529
1773
|
|
|
1530
1774
|
MIT
|