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 CHANGED
@@ -7,11 +7,36 @@
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
- <a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/v/waymark?style=flat-square&color=000&labelColor=000" alt="npm version" /></a>
11
- <a href="https://www.npmjs.com/package/waymark"><img src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=000&labelColor=000" alt="gzip size" /></a>
12
- <a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/dm/waymark?style=flat-square&color=000&labelColor=000" alt="downloads" /></a>
13
- <a href="https://github.com/strblr/waymark/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/waymark?style=flat-square&color=000&labelColor=000" alt="license" /></a>
14
- <a href="https://github.com/sponsors/strblr"><img src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=000&labelColor=000" alt="sponsors" /></a>
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 tools, no configuration files, very low boilerplate
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 around 4kB total
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
- ## Table of contents
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
- - [Link preloading](#link-preloading)
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
- ## Showcase
107
+ # Showcase
80
108
 
81
- Here's what a small routing setup looks like. Define some routes, render them, and get full type safety:
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
- // Define routes
87
- const home = route("/").component(() => <h1>Home</h1>);
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
- const user = route("/users/:id").component(UserPage);
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
- <div>
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
- // Render
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
- Links, navigation, path params, search params - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
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
- ## Installation
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
- ## Defining routes
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
- ## Nested routes and layouts
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, its handles, and its search mappers.
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. Let's start with a layout route:
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
- For this to work, the parent component must render an `<Outlet />` where these children should appear:
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
- ## Setting up the router
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 keeps your route list clean and 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.
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 is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context:
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
- ## Code organization
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 route objects come from or how you structure your files.
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
- ## Path params
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
- ## Search params
419
+ # Search params
387
420
 
388
- ### Basic usage
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 mapping function.
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
- Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
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
- ### JSON-first approach
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 (and URL-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 with Zod or a mapping function is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
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
- ### Inheritance
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 along 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.
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
- ### Idempotency requirement
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
- ## Navigation
547
+ # Navigation
513
548
 
514
- ### The Link component
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
- ### Active state detection
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
- ### Link preloading
632
+ ## Route preloading
598
633
 
599
- When a route has preloaders, e.g. when using lazy-loaded routes, you can preload them before the user actually navigates. This makes the subsequent navigation instant. The `preload` prop controls when preloading happens:
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
- **`preload="intent"`** triggers preloading when the user shows intent to navigate by hovering over the link or focusing it. This is the most common choice as it balances eager loading with not wasting bandwidth:
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
- You can also preload routes programmatically by calling the route's `.preload()` method:
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
- userProfile.preload();
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
- ### Programmatic navigation
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 (or `router.navigate`):
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. external redirects):
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/unknown/path" });
729
+ navigate({ url: "/some/path?tab=settings" });
678
730
  navigate({ url: "/callback", replace: true, state: { data: 123 } });
679
731
  ```
680
732
 
681
- ### Declarative navigation
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. You can pass route patterns, path params, search params, and state:
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
- ## Lazy loading
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 [Link preloading](#link-preloading) for ways to load these components before the user navigates.
798
+ See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
747
799
 
748
800
  ---
749
801
 
750
- ## Error boundaries
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
- The error boundary automatically resets when navigation occurs, giving the new route a fresh start.
867
+ To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
775
868
 
776
869
  ---
777
870
 
778
- ## Suspense boundaries
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
- ## Route handles
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
- ## Route matching and ranking
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
- ## History implementations
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
- ## Server-side rendering (SSR)
1036
+ # Cookbook
944
1037
 
945
- 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.
1038
+ ## Quick start example
946
1039
 
947
- On the server, create a router with `MemoryHistory` initialized to the request URL:
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(document.getElementById("root")!, <RouterRoot routes={routes} />);
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
- ### Global link configuration
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
- ### History middleware
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
- ### View transitions
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
- ## API reference
1288
+ # API reference
1157
1289
 
1158
- ### Router class
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
- **Methods:**
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
- `router.navigate(options)` navigates to a new location:
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
- `router.createUrl(options)` builds a URL string without navigating:
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
- `router.match(path, options)` checks if a path matches a specific route:
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
- `router.matchAll(path)` finds the best matching route from all registered routes:
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
- `router.getRoute(pattern)` retrieves a route by its pattern:
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
- ### Route class
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(subPattern)`** creates a nested child 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 React component to render:
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(validator)`** adds search parameter validation:
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(data)`** attaches static metadata:
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 children in a suspense boundary:
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 children in an error boundary:
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
- **`.preloader(loader)`** registers a preloader function that will be called when `.preload()` is invoked or when a `Link` with a preload strategy triggers it:
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 users = route("/users").preloader(async () => {
1286
- await prefetchData();
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
- **`.preload()`** manually triggers all registered preloaders (including lazy component loading):
1481
+ ## Hooks
1482
+
1483
+ **`useRouter()`** returns the Router instance from context.
1484
+
1485
+ - Returns: `Router` - The router instance
1291
1486
 
1292
1487
  ```tsx
1293
- await userProfile.preload();
1488
+ const router = useRouter();
1294
1489
  ```
1295
1490
 
1296
- ### History interface
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
- **Interface:**
1493
+ - Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
1301
1494
 
1302
1495
  ```tsx
1303
- interface HistoryLike {
1304
- getPath: () => string;
1305
- getSearch: () => Record<string, unknown>;
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
- **Methods:**
1501
+ **`useLocation()`** returns the current location, subscribes to changes.
1314
1502
 
1315
- `history.getPath()` returns the current pathname:
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 = history.getPath();
1319
- // Returns "/users/42"
1506
+ const { path, search, state } = useLocation();
1320
1507
  ```
1321
1508
 
1322
- `history.getSearch()` returns the current search params as a parsed object:
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 search = history.getSearch();
1326
- // Returns { tab: "posts", page: 2 }
1514
+ const outlet = useOutlet();
1327
1515
  ```
1328
1516
 
1329
- `history.getState()` returns the current history state:
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 state = history.getState();
1333
- // Returns any state passed during navigation
1523
+ const { id } = useParams(userRoute);
1334
1524
  ```
1335
1525
 
1336
- `history.go(delta)` navigates forward or back in history:
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
- history.go(-1); // Go back
1340
- history.go(1); // Go forward
1341
- history.go(-2); // Go back two steps
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
- `history.push(options)` pushes or replaces a history entry:
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
- history.push({ url: "/users/42", state: { from: "list" } });
1348
- history.push({ url: "/login", replace: true });
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
- `history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
1549
+ **`useHandles()`** returns the handles from the matched route chain.
1352
1550
 
1353
- ```tsx
1354
- const unsubscribe = history.subscribe(() => {
1355
- console.log("Navigation occurred");
1356
- });
1551
+ - Returns: `Handle[]` - Array of handles
1357
1552
 
1358
- // Later: unsubscribe()
1553
+ ```tsx
1554
+ const handles = useHandles();
1359
1555
  ```
1360
1556
 
1361
- ### Hooks
1557
+ ## Components
1362
1558
 
1363
- **`useRouter()`** returns the Router instance:
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
- const router = useRouter();
1564
+ <RouterRoot routes={routes} basePath="/app" history={history} />
1565
+ <RouterRoot router={router} />
1367
1566
  ```
1368
1567
 
1369
- **`useNavigate()`** returns a navigation function:
1568
+ **`Outlet`** renders the child route content.
1370
1569
 
1371
1570
  ```tsx
1372
- const navigate = useNavigate();
1373
- navigate({ to: "/home" });
1374
- navigate(-1);
1571
+ function Layout() {
1572
+ return (
1573
+ <div>
1574
+ <Outlet />
1575
+ </div>
1576
+ );
1577
+ }
1375
1578
  ```
1376
1579
 
1377
- **`useLocation()`** returns the current location:
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
- const { path, search, state } = useLocation();
1381
- // path: string, search: Record<string, unknown>, state: any
1585
+ <Link to="/path" params={...} search={...} replace strict preload="intent">
1586
+ Click me
1587
+ </Link>
1382
1588
  ```
1383
1589
 
1384
- **`useOutlet()`** returns the nested route content (used internally by `Outlet`):
1590
+ **`Navigate`** redirects on render.
1591
+
1592
+ - `props` - `NavigateOptions` - The navigation target
1385
1593
 
1386
1594
  ```tsx
1387
- const outlet = useOutlet();
1595
+ <Navigate to="/login" replace />
1388
1596
  ```
1389
1597
 
1390
- **`useParams(route)`** returns typed parameters for a route:
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
- const { id } = useParams(userRoute);
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
- **`useSearch(route)`** returns search params and a setter:
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 [search, setSearch] = useSearch(searchRoute);
1400
- setSearch({ page: 2 });
1401
- setSearch(prev => ({ page: prev.page + 1 }));
1617
+ const path = history.getPath();
1618
+ // Returns "/users/42"
1402
1619
  ```
1403
1620
 
1404
- **`useMatch(options)`** checks if a route matches the current path:
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 match = useMatch({ from: "/users/:id" });
1408
- const strictMatch = useMatch({ from: "/users", strict: true });
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
- **`useHandles()`** returns all handles from the matched route chain in order:
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 handles = useHandles();
1635
+ const state = history.getState();
1636
+ // Returns any state passed during navigation
1416
1637
  ```
1417
1638
 
1418
- ### Components
1639
+ **`history.go(delta)`** navigates forward or back in history.
1419
1640
 
1420
- **`RouterRoot`** is the root provider. Pass either router options or a router instance:
1641
+ - `delta` - `number` - The number of entries to move
1642
+ - Returns: `void`
1421
1643
 
1422
1644
  ```tsx
1423
- <RouterRoot routes={routes} basePath="/app" history={history} />
1424
- <RouterRoot router={router} />
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
- **`Outlet`** renders child route content:
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
- function Layout() {
1431
- return (
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
- **`Link`** navigates on click. Props extend `NavigateOptions` and `LinkOptions`:
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
- <Link to="/path" params={...} search={...} replace strict preload="intent">
1443
- Click me
1444
- </Link>
1666
+ const unsubscribe = history.subscribe(() => {
1667
+ console.log("Navigation occurred");
1668
+ });
1669
+
1670
+ // Later: unsubscribe()
1445
1671
  ```
1446
1672
 
1447
- **`Navigate`** redirects on render. Props are `NavigateOptions`:
1673
+ ## Types
1674
+
1675
+ **`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
1448
1676
 
1449
1677
  ```tsx
1450
- <Navigate to="/login" replace />
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
- ### Types
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<P extends Pattern> = {
1459
- to: P | Route<P>; // Route pattern or route object
1460
- params?: Params<P>; // Required if route has dynamic segments
1461
- search?: Search<P>; // Search params if route defines them
1462
- replace?: boolean; // Replace history instead of push
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`** is for untyped navigation:
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 push
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<P>`** is used for route matching:
1709
+ **`MatchOptions`** are options for route matching.
1478
1710
 
1479
1711
  ```tsx
1480
- type MatchOptions<P extends Pattern> = {
1481
- from: P | Route<P>; // Route to match against
1482
- strict?: boolean; // Require exact match (not just prefix)
1483
- params?: Partial<Params<P>>; // Match by specific param values
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<P>`** is the result of a successful match:
1719
+ **`Match`** is the result of a successful route match.
1488
1720
 
1489
1721
  ```tsx
1490
- type Match<P extends Pattern> = {
1491
- route: Route<P>; // The matched route
1492
- params: Params<P>; // Extracted parameters
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 matching
1501
- preload?: "intent" | "render" | "viewport" | false;
1502
- style?: CSSProperties;
1503
- className?: string;
1504
- activeStyle?: CSSProperties;
1505
- activeClassName?: string;
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 SSR (like redirects):
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 in your components during SSR
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
- ## Roadmap
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
- Future improvements planned for Waymark:
1762
+ # Roadmap
1523
1763
 
1524
- - **Preloader context** - Pass path params and search params to preloader functions, enabling loading logic based on the target route's dynamic data
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
- ## License
1772
+ # License
1529
1773
 
1530
1774
  MIT