waymark 0.2.3 → 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 +463 -251
- package/dist/index.d.ts +80 -79
- package/dist/index.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# Table of contents
|
|
35
35
|
|
|
36
36
|
- [Showcase](#showcase)
|
|
37
37
|
- [Installation](#installation)
|
|
@@ -48,35 +48,37 @@ Waymark is a routing library for React built around three core ideas: **type saf
|
|
|
48
48
|
- [Navigation](#navigation)
|
|
49
49
|
- [The Link component](#the-link-component)
|
|
50
50
|
- [Active state detection](#active-state-detection)
|
|
51
|
-
- [
|
|
51
|
+
- [Route preloading](#route-preloading)
|
|
52
52
|
- [Programmatic navigation](#programmatic-navigation)
|
|
53
53
|
- [Declarative navigation](#declarative-navigation)
|
|
54
54
|
- [Lazy loading](#lazy-loading)
|
|
55
|
+
- [Data preloading](#data-preloading)
|
|
55
56
|
- [Error boundaries](#error-boundaries)
|
|
56
57
|
- [Suspense boundaries](#suspense-boundaries)
|
|
57
58
|
- [Route handles](#route-handles)
|
|
58
59
|
- [Route matching and ranking](#route-matching-and-ranking)
|
|
59
60
|
- [History implementations](#history-implementations)
|
|
60
|
-
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
61
61
|
- [Cookbook](#cookbook)
|
|
62
|
+
- [Quick start example](#quick-start-example)
|
|
63
|
+
- [Server-side rendering (SSR)](#server-side-rendering-ssr)
|
|
62
64
|
- [Scroll to top on navigation](#scroll-to-top-on-navigation)
|
|
65
|
+
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
63
66
|
- [Global link configuration](#global-link-configuration)
|
|
64
67
|
- [History middleware](#history-middleware)
|
|
65
68
|
- [View transitions](#view-transitions)
|
|
66
|
-
- [Matching a route anywhere](#matching-a-route-anywhere)
|
|
67
69
|
- [API reference](#api-reference)
|
|
68
70
|
- [Router class](#router-class)
|
|
69
71
|
- [Route class](#route-class)
|
|
70
|
-
- [History interface](#history-interface)
|
|
71
72
|
- [Hooks](#hooks)
|
|
72
73
|
- [Components](#components)
|
|
74
|
+
- [History interface](#history-interface)
|
|
73
75
|
- [Types](#types)
|
|
74
76
|
- [Roadmap](#roadmap)
|
|
75
77
|
- [License](#license)
|
|
76
78
|
|
|
77
79
|
---
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
# Showcase
|
|
80
82
|
|
|
81
83
|
Here's what a small routing setup looks like. Define some routes, render them, and get full type safety:
|
|
82
84
|
|
|
@@ -117,7 +119,7 @@ Links, navigation, path params, search params - everything autocompletes and typ
|
|
|
117
119
|
|
|
118
120
|
---
|
|
119
121
|
|
|
120
|
-
|
|
122
|
+
# Installation
|
|
121
123
|
|
|
122
124
|
```bash
|
|
123
125
|
npm install waymark
|
|
@@ -127,7 +129,7 @@ Waymark requires React 18 or higher.
|
|
|
127
129
|
|
|
128
130
|
---
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
# Defining routes
|
|
131
133
|
|
|
132
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.
|
|
133
135
|
|
|
@@ -161,11 +163,11 @@ Route building is immutable: every method on a route returns a new route instanc
|
|
|
161
163
|
|
|
162
164
|
---
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
# Nested routes and layouts
|
|
165
167
|
|
|
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,
|
|
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.
|
|
167
169
|
|
|
168
|
-
Here's how it works.
|
|
170
|
+
Here's how it works. Start with any route:
|
|
169
171
|
|
|
170
172
|
```tsx
|
|
171
173
|
const dashboard = route("/dashboard").component(DashboardLayout);
|
|
@@ -181,7 +183,7 @@ const profile = dashboard.route("/profile").component(Profile);
|
|
|
181
183
|
|
|
182
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`.
|
|
183
185
|
|
|
184
|
-
|
|
186
|
+
The parent component must render an `<Outlet />` where child routes should appear:
|
|
185
187
|
|
|
186
188
|
```tsx
|
|
187
189
|
function DashboardLayout() {
|
|
@@ -220,7 +222,7 @@ Each level must include an `<Outlet />` to render the next level.
|
|
|
220
222
|
|
|
221
223
|
---
|
|
222
224
|
|
|
223
|
-
|
|
225
|
+
# Setting up the router
|
|
224
226
|
|
|
225
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:
|
|
226
228
|
|
|
@@ -236,7 +238,7 @@ const about = layout.route("/about").component(About);
|
|
|
236
238
|
const routes = [home, about]; // ✅ Don't include `layout`
|
|
237
239
|
```
|
|
238
240
|
|
|
239
|
-
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.
|
|
240
242
|
|
|
241
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.
|
|
242
244
|
|
|
@@ -258,7 +260,7 @@ You can also pass a `basePath` if your app lives under a subpath:
|
|
|
258
260
|
<RouterRoot routes={routes} basePath="/my-app" />
|
|
259
261
|
```
|
|
260
262
|
|
|
261
|
-
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):
|
|
262
264
|
|
|
263
265
|
```tsx
|
|
264
266
|
import { Router, RouterRoot } from "waymark";
|
|
@@ -288,7 +290,7 @@ With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs
|
|
|
288
290
|
|
|
289
291
|
---
|
|
290
292
|
|
|
291
|
-
|
|
293
|
+
# Code organization
|
|
292
294
|
|
|
293
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.
|
|
294
296
|
|
|
@@ -337,11 +339,11 @@ declare module "waymark" {
|
|
|
337
339
|
}
|
|
338
340
|
```
|
|
339
341
|
|
|
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
|
|
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.
|
|
341
343
|
|
|
342
344
|
---
|
|
343
345
|
|
|
344
|
-
|
|
346
|
+
# Path params
|
|
345
347
|
|
|
346
348
|
Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
|
|
347
349
|
|
|
@@ -383,11 +385,11 @@ function FileBrowser() {
|
|
|
383
385
|
|
|
384
386
|
---
|
|
385
387
|
|
|
386
|
-
|
|
388
|
+
# Search params
|
|
387
389
|
|
|
388
|
-
|
|
390
|
+
## Basic usage
|
|
389
391
|
|
|
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
|
|
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.
|
|
391
393
|
|
|
392
394
|
With Zod:
|
|
393
395
|
|
|
@@ -415,7 +417,9 @@ const searchPage = route("/search")
|
|
|
415
417
|
.component(SearchPage);
|
|
416
418
|
```
|
|
417
419
|
|
|
418
|
-
|
|
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:
|
|
419
423
|
|
|
420
424
|
```tsx
|
|
421
425
|
function SearchPage() {
|
|
@@ -438,12 +442,12 @@ Pass `true` as the second argument to replace the history entry instead of pushi
|
|
|
438
442
|
setSearch({ page: 1 }, true);
|
|
439
443
|
```
|
|
440
444
|
|
|
441
|
-
|
|
445
|
+
## JSON-first approach
|
|
442
446
|
|
|
443
447
|
Waymark uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
|
|
444
448
|
|
|
445
|
-
- Plain strings that aren't valid JSON are kept as-is: `"John"` → `?name=John` → `"John"`
|
|
446
|
-
- Everything else is JSON-encoded (
|
|
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):
|
|
447
451
|
- `true` → `?enabled=true` → `true`
|
|
448
452
|
- `"true"` → `?enabled=%22true%22` → `"true"`
|
|
449
453
|
- `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
|
|
@@ -451,9 +455,9 @@ Waymark uses a JSON-first approach for search params, similar to TanStack Router
|
|
|
451
455
|
|
|
452
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.
|
|
453
457
|
|
|
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
|
|
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.
|
|
455
459
|
|
|
456
|
-
|
|
460
|
+
## Inheritance
|
|
457
461
|
|
|
458
462
|
When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing.
|
|
459
463
|
|
|
@@ -480,7 +484,7 @@ function ProjectsPage() {
|
|
|
480
484
|
}
|
|
481
485
|
```
|
|
482
486
|
|
|
483
|
-
If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL
|
|
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.
|
|
484
488
|
|
|
485
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:
|
|
486
490
|
|
|
@@ -501,7 +505,7 @@ function ProjectsPage() {
|
|
|
501
505
|
}
|
|
502
506
|
```
|
|
503
507
|
|
|
504
|
-
|
|
508
|
+
## Idempotency requirement
|
|
505
509
|
|
|
506
510
|
The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
|
|
507
511
|
|
|
@@ -509,9 +513,9 @@ When you read search params, the values are passed through your validator. When
|
|
|
509
513
|
|
|
510
514
|
---
|
|
511
515
|
|
|
512
|
-
|
|
516
|
+
# Navigation
|
|
513
517
|
|
|
514
|
-
|
|
518
|
+
## The Link component
|
|
515
519
|
|
|
516
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:
|
|
517
521
|
|
|
@@ -560,7 +564,7 @@ The `asChild` prop lets you use your own component while keeping Link's behavior
|
|
|
560
564
|
</Link>
|
|
561
565
|
```
|
|
562
566
|
|
|
563
|
-
|
|
567
|
+
## Active state detection
|
|
564
568
|
|
|
565
569
|
Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
|
|
566
570
|
|
|
@@ -594,11 +598,13 @@ Or use the `activeClassName` and `activeStyle` props directly:
|
|
|
594
598
|
</Link>
|
|
595
599
|
```
|
|
596
600
|
|
|
597
|
-
|
|
601
|
+
## Route preloading
|
|
598
602
|
|
|
599
|
-
|
|
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.
|
|
600
604
|
|
|
601
|
-
|
|
605
|
+
The `preload` prop controls when preloading happens:
|
|
606
|
+
|
|
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:
|
|
602
608
|
|
|
603
609
|
```tsx
|
|
604
610
|
<Link to="/heavy-page" preload="intent">
|
|
@@ -624,15 +630,26 @@ When a route has preloaders, e.g. when using lazy-loaded routes, you can preload
|
|
|
624
630
|
|
|
625
631
|
**`preload={false}`** disables preloading entirely. This is the default.
|
|
626
632
|
|
|
627
|
-
|
|
633
|
+
To prevent unwanted preloads from quick hover/focus interactions, Link waits 50ms before triggering. You can customize this with `preloadDelay`:
|
|
628
634
|
|
|
629
635
|
```tsx
|
|
630
|
-
|
|
636
|
+
<Link to="/heavy-page" preload="intent" preloadDelay={100}>
|
|
637
|
+
Heavy page
|
|
638
|
+
</Link>
|
|
631
639
|
```
|
|
632
640
|
|
|
633
|
-
|
|
641
|
+
You can also preload programmatically using `router.preload()`:
|
|
634
642
|
|
|
635
|
-
|
|
643
|
+
```tsx
|
|
644
|
+
const router = useRouter();
|
|
645
|
+
router.preload({ to: userProfile, params: { id: "42" } });
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
To set a preload strategy globally for all links in your app, see [Global link configuration](#global-link-configuration).
|
|
649
|
+
|
|
650
|
+
## Programmatic navigation
|
|
651
|
+
|
|
652
|
+
For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
|
|
636
653
|
|
|
637
654
|
```tsx
|
|
638
655
|
import { useNavigate } from "waymark";
|
|
@@ -665,20 +682,24 @@ navigate(1); // Go forward
|
|
|
665
682
|
navigate(-2); // Go back two steps
|
|
666
683
|
```
|
|
667
684
|
|
|
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
|
|
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:
|
|
669
686
|
|
|
670
|
-
|
|
687
|
+
```tsx
|
|
688
|
+
router.navigate({ to: "/login" });
|
|
689
|
+
```
|
|
690
|
+
|
|
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):
|
|
671
692
|
|
|
672
693
|
```tsx
|
|
673
694
|
// Type-safe navigation
|
|
674
695
|
navigate({ to: userProfile, params: { id: "42" } });
|
|
675
696
|
|
|
676
697
|
// Unsafe navigation - no type checking
|
|
677
|
-
navigate({ url: "/some/
|
|
698
|
+
navigate({ url: "/some/path?tab=settings" });
|
|
678
699
|
navigate({ url: "/callback", replace: true, state: { data: 123 } });
|
|
679
700
|
```
|
|
680
701
|
|
|
681
|
-
|
|
702
|
+
## Declarative navigation
|
|
682
703
|
|
|
683
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:
|
|
684
705
|
|
|
@@ -696,7 +717,7 @@ function ProtectedPage() {
|
|
|
696
717
|
}
|
|
697
718
|
```
|
|
698
719
|
|
|
699
|
-
The `Navigate` component accepts the same navigation props as the `Link` component
|
|
720
|
+
The `Navigate` component accepts the same navigation props as the `Link` component:
|
|
700
721
|
|
|
701
722
|
```tsx
|
|
702
723
|
<Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
|
|
@@ -708,7 +729,7 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
|
|
|
708
729
|
|
|
709
730
|
---
|
|
710
731
|
|
|
711
|
-
|
|
732
|
+
# Lazy loading
|
|
712
733
|
|
|
713
734
|
Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
|
|
714
735
|
|
|
@@ -743,11 +764,52 @@ const settings = dashboard.route("/settings").component(Settings);
|
|
|
743
764
|
|
|
744
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.
|
|
745
766
|
|
|
746
|
-
See [
|
|
767
|
+
See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
# Data preloading
|
|
772
|
+
|
|
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:
|
|
774
|
+
|
|
775
|
+
```tsx
|
|
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);
|
|
785
|
+
```
|
|
786
|
+
|
|
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:
|
|
790
|
+
|
|
791
|
+
```tsx
|
|
792
|
+
await queryClient.prefetchQuery({
|
|
793
|
+
queryKey: ["user", params.id],
|
|
794
|
+
queryFn: () => fetchUser(params.id),
|
|
795
|
+
staleTime: 60_000 // No refetch within 60s
|
|
796
|
+
});
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Preload functions inherit to child routes:
|
|
800
|
+
|
|
801
|
+
```tsx
|
|
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
|
|
808
|
+
```
|
|
747
809
|
|
|
748
810
|
---
|
|
749
811
|
|
|
750
|
-
|
|
812
|
+
# Error boundaries
|
|
751
813
|
|
|
752
814
|
Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
|
|
753
815
|
|
|
@@ -771,11 +833,11 @@ Error boundaries catch errors from all nested content. A common pattern is to pl
|
|
|
771
833
|
const app = route("/").error(ErrorPage).component(AppLayout);
|
|
772
834
|
```
|
|
773
835
|
|
|
774
|
-
|
|
836
|
+
To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
|
|
775
837
|
|
|
776
838
|
---
|
|
777
839
|
|
|
778
|
-
|
|
840
|
+
# Suspense boundaries
|
|
779
841
|
|
|
780
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()`:
|
|
781
843
|
|
|
@@ -804,7 +866,7 @@ Note: React 19 has a [known throttling behavior](https://github.com/facebook/rea
|
|
|
804
866
|
|
|
805
867
|
---
|
|
806
868
|
|
|
807
|
-
|
|
869
|
+
# Route handles
|
|
808
870
|
|
|
809
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.
|
|
810
872
|
|
|
@@ -830,8 +892,8 @@ function Breadcrumbs() {
|
|
|
830
892
|
<nav>
|
|
831
893
|
{handles.map((h, i) => (
|
|
832
894
|
<span key={i}>
|
|
895
|
+
{i !== 0 && " / "}
|
|
833
896
|
{h.title}
|
|
834
|
-
{i < handles.length - 1 && " / "}
|
|
835
897
|
</span>
|
|
836
898
|
))}
|
|
837
899
|
</nav>
|
|
@@ -854,7 +916,7 @@ declare module "waymark" {
|
|
|
854
916
|
|
|
855
917
|
---
|
|
856
918
|
|
|
857
|
-
|
|
919
|
+
# Route matching and ranking
|
|
858
920
|
|
|
859
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.
|
|
860
922
|
|
|
@@ -906,7 +968,7 @@ const routes = [
|
|
|
906
968
|
|
|
907
969
|
---
|
|
908
970
|
|
|
909
|
-
|
|
971
|
+
# History implementations
|
|
910
972
|
|
|
911
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.
|
|
912
974
|
|
|
@@ -940,11 +1002,53 @@ All history implementations conform to the `HistoryLike` interface, so you can c
|
|
|
940
1002
|
|
|
941
1003
|
---
|
|
942
1004
|
|
|
943
|
-
|
|
1005
|
+
# Cookbook
|
|
944
1006
|
|
|
945
|
-
|
|
1007
|
+
## Quick start example
|
|
946
1008
|
|
|
947
|
-
|
|
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:
|
|
948
1052
|
|
|
949
1053
|
```tsx
|
|
950
1054
|
// server.tsx
|
|
@@ -980,16 +1084,12 @@ import { hydrateRoot } from "react-dom/client";
|
|
|
980
1084
|
import { RouterRoot } from "waymark";
|
|
981
1085
|
import { routes } from "./routes";
|
|
982
1086
|
|
|
983
|
-
hydrateRoot(
|
|
1087
|
+
hydrateRoot(rootElement, <RouterRoot routes={routes} />);
|
|
984
1088
|
```
|
|
985
1089
|
|
|
986
1090
|
You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
|
|
987
1091
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
## Cookbook
|
|
991
|
-
|
|
992
|
-
### Scroll to top on navigation
|
|
1092
|
+
## Scroll to top on navigation
|
|
993
1093
|
|
|
994
1094
|
Create a component that scrolls to top when the path changes and include it in your layout:
|
|
995
1095
|
|
|
@@ -1014,7 +1114,48 @@ function AppLayout() {
|
|
|
1014
1114
|
}
|
|
1015
1115
|
```
|
|
1016
1116
|
|
|
1017
|
-
|
|
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`.
|
|
1120
|
+
|
|
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
|
|
1018
1159
|
|
|
1019
1160
|
Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
|
|
1020
1161
|
|
|
@@ -1023,6 +1164,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
|
1023
1164
|
routes={routes}
|
|
1024
1165
|
defaultLinkOptions={{
|
|
1025
1166
|
preload: "intent",
|
|
1167
|
+
preloadDelay: 75,
|
|
1026
1168
|
className: "app-link",
|
|
1027
1169
|
activeClassName: "active"
|
|
1028
1170
|
}}
|
|
@@ -1031,7 +1173,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
|
|
|
1031
1173
|
|
|
1032
1174
|
Individual links can override any of these defaults by passing their own props.
|
|
1033
1175
|
|
|
1034
|
-
|
|
1176
|
+
## History middleware
|
|
1035
1177
|
|
|
1036
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:
|
|
1037
1179
|
|
|
@@ -1070,7 +1212,7 @@ const router = new Router({
|
|
|
1070
1212
|
});
|
|
1071
1213
|
```
|
|
1072
1214
|
|
|
1073
|
-
|
|
1215
|
+
## View transitions
|
|
1074
1216
|
|
|
1075
1217
|
You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
|
|
1076
1218
|
|
|
@@ -1110,67 +1252,14 @@ Add CSS to control the transition:
|
|
|
1110
1252
|
|
|
1111
1253
|
For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
|
|
1112
1254
|
|
|
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
1255
|
---
|
|
1155
1256
|
|
|
1156
|
-
|
|
1257
|
+
# API reference
|
|
1157
1258
|
|
|
1158
|
-
|
|
1259
|
+
## Router class
|
|
1159
1260
|
|
|
1160
1261
|
The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
|
|
1161
1262
|
|
|
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
1263
|
**Properties:**
|
|
1175
1264
|
|
|
1176
1265
|
- `router.basePath` - The configured base path
|
|
@@ -1179,9 +1268,21 @@ const router = new Router({
|
|
|
1179
1268
|
- `router.ssrContext` - The SSR context (if provided)
|
|
1180
1269
|
- `router.defaultLinkOptions` - Default link options
|
|
1181
1270
|
|
|
1182
|
-
|
|
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.
|
|
1183
1283
|
|
|
1184
|
-
`
|
|
1284
|
+
- `options` - `NavigateOptions | HistoryPushOptions | number` - Type-safe navigation options, untyped navigation options, or a history delta
|
|
1285
|
+
- Returns: `void`
|
|
1185
1286
|
|
|
1186
1287
|
```tsx
|
|
1187
1288
|
// Type-safe navigation
|
|
@@ -1195,38 +1296,64 @@ router.navigate(-1); // Back
|
|
|
1195
1296
|
router.navigate(1); // Forward
|
|
1196
1297
|
```
|
|
1197
1298
|
|
|
1198
|
-
|
|
1299
|
+
**`router.createUrl(options)`** builds a URL string.
|
|
1300
|
+
|
|
1301
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1302
|
+
- Returns: `string` - The constructed URL
|
|
1199
1303
|
|
|
1200
1304
|
```tsx
|
|
1201
1305
|
const url = router.createUrl({ to: userProfile, params: { id: "42" } });
|
|
1202
1306
|
// Returns "/users/42"
|
|
1203
1307
|
```
|
|
1204
1308
|
|
|
1205
|
-
|
|
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
|
|
1206
1314
|
|
|
1207
1315
|
```tsx
|
|
1208
1316
|
const match = router.match("/users/42", { from: "/users/:id" });
|
|
1209
1317
|
// Returns { route, params: { id: "42" } } or null
|
|
1210
1318
|
```
|
|
1211
1319
|
|
|
1212
|
-
|
|
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
|
|
1213
1324
|
|
|
1214
1325
|
```tsx
|
|
1215
1326
|
const match = router.matchAll("/users/42");
|
|
1216
1327
|
// Returns the best match or null
|
|
1217
1328
|
```
|
|
1218
1329
|
|
|
1219
|
-
|
|
1330
|
+
**`router.getRoute(pattern)`** get a route by its pattern.
|
|
1331
|
+
|
|
1332
|
+
- `pattern` - `Pattern | Route` - A route pattern string or a route object
|
|
1333
|
+
- Returns: `Route` - The route object; throws if not found
|
|
1220
1334
|
|
|
1221
1335
|
```tsx
|
|
1222
1336
|
const route = router.getRoute("/users/:id");
|
|
1223
1337
|
```
|
|
1224
1338
|
|
|
1225
|
-
|
|
1339
|
+
**`router.preload(options)`** triggers preloading for a route.
|
|
1340
|
+
|
|
1341
|
+
- `options` - `NavigateOptions` - Type-safe navigation options
|
|
1342
|
+
- Returns: `Promise<void>` - Resolves when preloaded
|
|
1343
|
+
|
|
1344
|
+
```tsx
|
|
1345
|
+
await router.preload({ to: "/user/:id", params: { id: "42" } });
|
|
1346
|
+
await router.preload({ to: searchPage, search: { q: "test" } });
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
## Route class
|
|
1226
1350
|
|
|
1227
1351
|
Routes are created with the `route()` function and configured by chaining methods.
|
|
1228
1352
|
|
|
1229
|
-
**`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
|
|
1230
1357
|
|
|
1231
1358
|
```tsx
|
|
1232
1359
|
const users = route("/users");
|
|
@@ -1234,38 +1361,62 @@ const user = route("/users/:id");
|
|
|
1234
1361
|
const catchAll = route("/*");
|
|
1235
1362
|
```
|
|
1236
1363
|
|
|
1237
|
-
**`.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
|
|
1238
1368
|
|
|
1239
1369
|
```tsx
|
|
1240
1370
|
const userSettings = user.route("/settings");
|
|
1241
1371
|
// Pattern becomes "/users/:id/settings"
|
|
1242
1372
|
```
|
|
1243
1373
|
|
|
1244
|
-
**`.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
|
|
1245
1378
|
|
|
1246
1379
|
```tsx
|
|
1247
1380
|
const users = route("/users").component(UsersPage);
|
|
1248
1381
|
```
|
|
1249
1382
|
|
|
1250
|
-
**`.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
|
|
1251
1387
|
|
|
1252
1388
|
```tsx
|
|
1253
1389
|
const users = route("/users").lazy(() => import("./UsersPage"));
|
|
1390
|
+
const admin = route("/admin").lazy(() =>
|
|
1391
|
+
import("./Admin").then(m => m.AdminPage)
|
|
1392
|
+
);
|
|
1254
1393
|
```
|
|
1255
1394
|
|
|
1256
|
-
**`.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
|
|
1257
1399
|
|
|
1258
1400
|
```tsx
|
|
1259
1401
|
const search = route("/search").search(z.object({ q: z.string() }));
|
|
1402
|
+
const filter = route("/filter").search(raw => ({
|
|
1403
|
+
term: String(raw.term ?? "")
|
|
1404
|
+
}));
|
|
1260
1405
|
```
|
|
1261
1406
|
|
|
1262
|
-
**`.handle(
|
|
1407
|
+
**`.handle(handle)`** attaches static metadata to the route.
|
|
1408
|
+
|
|
1409
|
+
- `handle` - `Handle` - Arbitrary metadata
|
|
1410
|
+
- Returns: `Route` - A new route object
|
|
1263
1411
|
|
|
1264
1412
|
```tsx
|
|
1265
1413
|
const admin = route("/admin").handle({ requiresAuth: true });
|
|
1266
1414
|
```
|
|
1267
1415
|
|
|
1268
|
-
**`.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
|
|
1269
1420
|
|
|
1270
1421
|
```tsx
|
|
1271
1422
|
const lazy = route("/lazy")
|
|
@@ -1273,258 +1424,319 @@ const lazy = route("/lazy")
|
|
|
1273
1424
|
.lazy(() => import("./Page"));
|
|
1274
1425
|
```
|
|
1275
1426
|
|
|
1276
|
-
**`.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
|
|
1277
1431
|
|
|
1278
1432
|
```tsx
|
|
1279
1433
|
const risky = route("/risky").error(ErrorPage).component(RiskyPage);
|
|
1280
1434
|
```
|
|
1281
1435
|
|
|
1282
|
-
**`.
|
|
1436
|
+
**`.preload(preload)`** registers a preload function for the route.
|
|
1437
|
+
|
|
1438
|
+
- `preload` - `(context: PreloadContext) => Promise<any>` - An async function receiving typed `params` and `search`
|
|
1439
|
+
- Returns: `Route` - A new route object
|
|
1283
1440
|
|
|
1284
1441
|
```tsx
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
})
|
|
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
|
+
});
|
|
1288
1448
|
```
|
|
1289
1449
|
|
|
1290
|
-
|
|
1450
|
+
## Hooks
|
|
1451
|
+
|
|
1452
|
+
**`useRouter()`** returns the Router instance from context.
|
|
1453
|
+
|
|
1454
|
+
- Returns: `Router` - The router instance
|
|
1291
1455
|
|
|
1292
1456
|
```tsx
|
|
1293
|
-
|
|
1457
|
+
const router = useRouter();
|
|
1294
1458
|
```
|
|
1295
1459
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1460
|
+
**`useNavigate()`** returns a navigation function.
|
|
1299
1461
|
|
|
1300
|
-
|
|
1462
|
+
- Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
|
|
1301
1463
|
|
|
1302
1464
|
```tsx
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
getState: () => any;
|
|
1307
|
-
go: (delta: number) => void;
|
|
1308
|
-
push: (options: HistoryPushOptions) => void;
|
|
1309
|
-
subscribe: (listener: () => void) => () => void;
|
|
1310
|
-
}
|
|
1465
|
+
const navigate = useNavigate();
|
|
1466
|
+
navigate({ to: "/home" });
|
|
1467
|
+
navigate(-1);
|
|
1311
1468
|
```
|
|
1312
1469
|
|
|
1313
|
-
|
|
1470
|
+
**`useLocation()`** returns the current location, subscribes to changes.
|
|
1314
1471
|
|
|
1315
|
-
`
|
|
1472
|
+
- Returns: `{ path: string, search: Record<string, unknown>, state: any }` - The current path, parsed search params, and history state
|
|
1316
1473
|
|
|
1317
1474
|
```tsx
|
|
1318
|
-
const path =
|
|
1319
|
-
// Returns "/users/42"
|
|
1475
|
+
const { path, search, state } = useLocation();
|
|
1320
1476
|
```
|
|
1321
1477
|
|
|
1322
|
-
|
|
1478
|
+
**`useOutlet()`** returns the child route content.
|
|
1479
|
+
|
|
1480
|
+
- Returns: `ReactNode` - The child route's content or null
|
|
1323
1481
|
|
|
1324
1482
|
```tsx
|
|
1325
|
-
const
|
|
1326
|
-
// Returns { tab: "posts", page: 2 }
|
|
1483
|
+
const outlet = useOutlet();
|
|
1327
1484
|
```
|
|
1328
1485
|
|
|
1329
|
-
|
|
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
|
|
1330
1490
|
|
|
1331
1491
|
```tsx
|
|
1332
|
-
const
|
|
1333
|
-
// Returns any state passed during navigation
|
|
1492
|
+
const { id } = useParams(userRoute);
|
|
1334
1493
|
```
|
|
1335
1494
|
|
|
1336
|
-
|
|
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
|
|
1337
1499
|
|
|
1338
1500
|
```tsx
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1501
|
+
const [search, setSearch] = useSearch(searchRoute);
|
|
1502
|
+
setSearch({ page: 2 });
|
|
1503
|
+
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1504
|
+
setSearch({ page: 1 }, true); // Replace instead of push
|
|
1342
1505
|
```
|
|
1343
1506
|
|
|
1344
|
-
|
|
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
|
|
1345
1511
|
|
|
1346
1512
|
```tsx
|
|
1347
|
-
|
|
1348
|
-
|
|
1513
|
+
const match = useMatch({ from: "/users/:id" });
|
|
1514
|
+
const strictMatch = useMatch({ from: "/users", strict: true });
|
|
1515
|
+
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1349
1516
|
```
|
|
1350
1517
|
|
|
1351
|
-
|
|
1518
|
+
**`useHandles()`** returns the handles from the matched route chain.
|
|
1352
1519
|
|
|
1353
|
-
|
|
1354
|
-
const unsubscribe = history.subscribe(() => {
|
|
1355
|
-
console.log("Navigation occurred");
|
|
1356
|
-
});
|
|
1520
|
+
- Returns: `Handle[]` - Array of handles
|
|
1357
1521
|
|
|
1358
|
-
|
|
1522
|
+
```tsx
|
|
1523
|
+
const handles = useHandles();
|
|
1359
1524
|
```
|
|
1360
1525
|
|
|
1361
|
-
|
|
1526
|
+
## Components
|
|
1527
|
+
|
|
1528
|
+
**`RouterRoot`** sets up routing context and renders your routes.
|
|
1362
1529
|
|
|
1363
|
-
|
|
1530
|
+
- `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
|
|
1364
1531
|
|
|
1365
1532
|
```tsx
|
|
1366
|
-
|
|
1533
|
+
<RouterRoot routes={routes} basePath="/app" history={history} />
|
|
1534
|
+
<RouterRoot router={router} />
|
|
1367
1535
|
```
|
|
1368
1536
|
|
|
1369
|
-
**`
|
|
1537
|
+
**`Outlet`** renders the child route content.
|
|
1370
1538
|
|
|
1371
1539
|
```tsx
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1540
|
+
function Layout() {
|
|
1541
|
+
return (
|
|
1542
|
+
<div>
|
|
1543
|
+
<Outlet />
|
|
1544
|
+
</div>
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1375
1547
|
```
|
|
1376
1548
|
|
|
1377
|
-
**`
|
|
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
|
|
1378
1552
|
|
|
1379
1553
|
```tsx
|
|
1380
|
-
|
|
1381
|
-
|
|
1554
|
+
<Link to="/path" params={...} search={...} replace strict preload="intent">
|
|
1555
|
+
Click me
|
|
1556
|
+
</Link>
|
|
1382
1557
|
```
|
|
1383
1558
|
|
|
1384
|
-
**`
|
|
1559
|
+
**`Navigate`** redirects on render.
|
|
1560
|
+
|
|
1561
|
+
- `props` - `NavigateOptions` - The navigation target
|
|
1385
1562
|
|
|
1386
1563
|
```tsx
|
|
1387
|
-
|
|
1564
|
+
<Navigate to="/login" replace />
|
|
1388
1565
|
```
|
|
1389
1566
|
|
|
1390
|
-
|
|
1567
|
+
## History interface
|
|
1568
|
+
|
|
1569
|
+
The `HistoryLike` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
|
|
1570
|
+
|
|
1571
|
+
**Available implementations:**
|
|
1391
1572
|
|
|
1392
1573
|
```tsx
|
|
1393
|
-
|
|
1574
|
+
new BrowserHistory(); // Browser History API (/posts/123). Default.
|
|
1575
|
+
new HashHistory(); // URL hash (/#/posts/123).
|
|
1576
|
+
new MemoryHistory("/initial"); // In-memory only.
|
|
1394
1577
|
```
|
|
1395
1578
|
|
|
1396
|
-
|
|
1579
|
+
See [History implementations](#history-implementations) for detailed usage.
|
|
1580
|
+
|
|
1581
|
+
**`history.getPath()`** returns the current path.
|
|
1582
|
+
|
|
1583
|
+
- Returns: `string` - The current path
|
|
1397
1584
|
|
|
1398
1585
|
```tsx
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
setSearch(prev => ({ page: prev.page + 1 }));
|
|
1586
|
+
const path = history.getPath();
|
|
1587
|
+
// Returns "/users/42"
|
|
1402
1588
|
```
|
|
1403
1589
|
|
|
1404
|
-
**`
|
|
1590
|
+
**`history.getSearch()`** returns the current search params as a parsed JSON object.
|
|
1591
|
+
|
|
1592
|
+
- Returns: `Record<string, unknown>` - The parsed search params
|
|
1405
1593
|
|
|
1406
1594
|
```tsx
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
|
|
1595
|
+
const search = history.getSearch();
|
|
1596
|
+
// Returns { tab: "posts", page: 2 }
|
|
1410
1597
|
```
|
|
1411
1598
|
|
|
1412
|
-
**`
|
|
1599
|
+
**`history.getState()`** returns the current history state.
|
|
1600
|
+
|
|
1601
|
+
- Returns: `any` - The state passed during navigation, or undefined
|
|
1413
1602
|
|
|
1414
1603
|
```tsx
|
|
1415
|
-
const
|
|
1604
|
+
const state = history.getState();
|
|
1605
|
+
// Returns any state passed during navigation
|
|
1416
1606
|
```
|
|
1417
1607
|
|
|
1418
|
-
|
|
1608
|
+
**`history.go(delta)`** navigates forward or back in history.
|
|
1419
1609
|
|
|
1420
|
-
|
|
1610
|
+
- `delta` - `number` - The number of entries to move
|
|
1611
|
+
- Returns: `void`
|
|
1421
1612
|
|
|
1422
1613
|
```tsx
|
|
1423
|
-
|
|
1424
|
-
|
|
1614
|
+
history.go(-1); // Go back
|
|
1615
|
+
history.go(1); // Go forward
|
|
1616
|
+
history.go(-2); // Go back two steps
|
|
1425
1617
|
```
|
|
1426
1618
|
|
|
1427
|
-
**`
|
|
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`
|
|
1428
1623
|
|
|
1429
1624
|
```tsx
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
<div>
|
|
1433
|
-
<Outlet />
|
|
1434
|
-
</div>
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1625
|
+
history.push({ url: "/users/42", state: { from: "list" } });
|
|
1626
|
+
history.push({ url: "/login", replace: true });
|
|
1437
1627
|
```
|
|
1438
1628
|
|
|
1439
|
-
**`
|
|
1629
|
+
**`history.subscribe(listener)`** subscribes to navigation events.
|
|
1630
|
+
|
|
1631
|
+
- `listener` - `() => void` - Callback invoked when any navigation occurs
|
|
1632
|
+
- Returns: `() => void` - An unsubscribe function
|
|
1440
1633
|
|
|
1441
1634
|
```tsx
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1635
|
+
const unsubscribe = history.subscribe(() => {
|
|
1636
|
+
console.log("Navigation occurred");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// Later: unsubscribe()
|
|
1445
1640
|
```
|
|
1446
1641
|
|
|
1447
|
-
|
|
1642
|
+
## Types
|
|
1643
|
+
|
|
1644
|
+
**`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
|
|
1448
1645
|
|
|
1449
1646
|
```tsx
|
|
1450
|
-
|
|
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
|
+
}
|
|
1451
1654
|
```
|
|
1452
1655
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
**`NavigateOptions<P>`** is the main type for type-safe navigation:
|
|
1656
|
+
**`NavigateOptions`** are options for type-safe navigation.
|
|
1456
1657
|
|
|
1457
1658
|
```tsx
|
|
1458
|
-
type NavigateOptions
|
|
1459
|
-
to:
|
|
1460
|
-
params?: Params
|
|
1461
|
-
search?: Search
|
|
1462
|
-
replace?: boolean; // Replace history instead of
|
|
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
|
|
1463
1664
|
state?: any; // Arbitrary state to pass
|
|
1464
1665
|
};
|
|
1465
1666
|
```
|
|
1466
1667
|
|
|
1467
|
-
**`HistoryPushOptions`**
|
|
1668
|
+
**`HistoryPushOptions`** are options for untyped navigation.
|
|
1468
1669
|
|
|
1469
1670
|
```tsx
|
|
1470
1671
|
interface HistoryPushOptions {
|
|
1471
1672
|
url: string; // The URL to navigate to
|
|
1472
|
-
replace?: boolean; // Replace history instead of
|
|
1673
|
+
replace?: boolean; // Replace history entry instead of pushing
|
|
1473
1674
|
state?: any; // Arbitrary state to pass
|
|
1474
1675
|
}
|
|
1475
1676
|
```
|
|
1476
1677
|
|
|
1477
|
-
**`MatchOptions
|
|
1678
|
+
**`MatchOptions`** are options for route matching.
|
|
1478
1679
|
|
|
1479
1680
|
```tsx
|
|
1480
|
-
type MatchOptions
|
|
1481
|
-
from:
|
|
1482
|
-
strict?: boolean; // Require exact match (
|
|
1483
|
-
params?: Partial<Params
|
|
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
|
|
1484
1685
|
};
|
|
1485
1686
|
```
|
|
1486
1687
|
|
|
1487
|
-
**`Match
|
|
1688
|
+
**`Match`** is the result of a successful route match.
|
|
1488
1689
|
|
|
1489
1690
|
```tsx
|
|
1490
|
-
type Match
|
|
1491
|
-
route: Route
|
|
1492
|
-
params: Params
|
|
1691
|
+
type Match = {
|
|
1692
|
+
route: Route; // Matched route object
|
|
1693
|
+
params: Params; // Extracted path params
|
|
1493
1694
|
};
|
|
1494
1695
|
```
|
|
1495
1696
|
|
|
1496
|
-
**`LinkOptions`** controls link behavior and styling
|
|
1697
|
+
**`LinkOptions`** controls link behavior and styling.
|
|
1497
1698
|
|
|
1498
1699
|
```tsx
|
|
1499
1700
|
interface LinkOptions {
|
|
1500
|
-
strict?: boolean; // Strict active
|
|
1501
|
-
preload?: "intent" | "render" | "viewport" | false;
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
|
1506
1708
|
}
|
|
1507
1709
|
```
|
|
1508
1710
|
|
|
1509
|
-
**`SSRContext`** captures context during
|
|
1711
|
+
**`SSRContext`** captures context during server-side rendering.
|
|
1510
1712
|
|
|
1511
1713
|
```tsx
|
|
1512
1714
|
type SSRContext = {
|
|
1513
1715
|
redirect?: string; // Set by Navigate component during SSR
|
|
1514
|
-
statusCode?: number; // Can be set manually
|
|
1716
|
+
statusCode?: number; // Can be set manually for HTTP status
|
|
1515
1717
|
};
|
|
1516
1718
|
```
|
|
1517
1719
|
|
|
1518
|
-
|
|
1720
|
+
**`PreloadContext`** is the context passed to preload functions.
|
|
1519
1721
|
|
|
1520
|
-
|
|
1722
|
+
```tsx
|
|
1723
|
+
interface PreloadContext {
|
|
1724
|
+
params: Params; // Path params for the route
|
|
1725
|
+
search: Search; // Validated search params
|
|
1726
|
+
}
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
---
|
|
1521
1730
|
|
|
1522
|
-
|
|
1731
|
+
# Roadmap
|
|
1523
1732
|
|
|
1524
|
-
-
|
|
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).
|
|
1525
1737
|
|
|
1526
1738
|
---
|
|
1527
1739
|
|
|
1528
|
-
|
|
1740
|
+
# License
|
|
1529
1741
|
|
|
1530
1742
|
MIT
|