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 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
- ## Table of contents
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
- - [Link preloading](#link-preloading)
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
- ## Showcase
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
- ## Installation
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
- ## Defining routes
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
- ## Nested routes and layouts
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, its handles, and its search mappers.
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. Let's start with a layout route:
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
- For this to work, the parent component must render an `<Outlet />` where these children should appear:
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
- ## Setting up the router
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 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.
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 is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context:
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
- ## Code organization
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 route objects come from or how you structure your files.
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
- ## Path params
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
- ## Search params
388
+ # Search params
387
389
 
388
- ### Basic usage
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 mapping function.
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
- Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
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
- ### JSON-first approach
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 (and URL-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 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.
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
- ### Inheritance
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 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.
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
- ### Idempotency requirement
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
- ## Navigation
516
+ # Navigation
513
517
 
514
- ### The Link component
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
- ### Active state detection
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
- ### Link preloading
601
+ ## Route preloading
598
602
 
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:
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
- **`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:
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
- You can also preload routes programmatically by calling the route's `.preload()` method:
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
- userProfile.preload();
636
+ <Link to="/heavy-page" preload="intent" preloadDelay={100}>
637
+ Heavy page
638
+ </Link>
631
639
  ```
632
640
 
633
- ### Programmatic navigation
641
+ You can also preload programmatically using `router.preload()`:
634
642
 
635
- For navigation triggered by code rather than user clicks, use the `useNavigate` hook (or `router.navigate`):
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
- 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):
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/unknown/path" });
698
+ navigate({ url: "/some/path?tab=settings" });
678
699
  navigate({ url: "/callback", replace: true, state: { data: 123 } });
679
700
  ```
680
701
 
681
- ### Declarative navigation
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. You can pass route patterns, path params, search params, and state:
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
- ## Lazy loading
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 [Link preloading](#link-preloading) for ways to load these components before the user navigates.
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
- ## Error boundaries
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
- The error boundary automatically resets when navigation occurs, giving the new route a fresh start.
836
+ To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
775
837
 
776
838
  ---
777
839
 
778
- ## Suspense boundaries
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
- ## Route handles
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
- ## Route matching and ranking
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
- ## History implementations
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
- ## Server-side rendering (SSR)
1005
+ # Cookbook
944
1006
 
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.
1007
+ ## Quick start example
946
1008
 
947
- On the server, create a router with `MemoryHistory` initialized to the request URL:
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(document.getElementById("root")!, <RouterRoot routes={routes} />);
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
- ### Global link configuration
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
- ### History middleware
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
- ### View transitions
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
- ## API reference
1257
+ # API reference
1157
1258
 
1158
- ### Router class
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
- **Methods:**
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
- `router.navigate(options)` navigates to a new location:
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
- `router.createUrl(options)` builds a URL string without navigating:
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
- `router.match(path, options)` checks if a path matches a specific route:
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
- `router.matchAll(path)` finds the best matching route from all registered routes:
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
- `router.getRoute(pattern)` retrieves a route by its pattern:
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
- ### Route class
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(subPattern)`** creates a nested child 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 React component to render:
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(validator)`** adds search parameter validation:
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(data)`** attaches static metadata:
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 children in a suspense boundary:
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 children in an error boundary:
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
- **`.preloader(loader)`** registers a preloader function that will be called when `.preload()` is invoked or when a `Link` with a preload strategy triggers it:
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 users = route("/users").preloader(async () => {
1286
- await prefetchData();
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
- **`.preload()`** manually triggers all registered preloaders (including lazy component loading):
1450
+ ## Hooks
1451
+
1452
+ **`useRouter()`** returns the Router instance from context.
1453
+
1454
+ - Returns: `Router` - The router instance
1291
1455
 
1292
1456
  ```tsx
1293
- await userProfile.preload();
1457
+ const router = useRouter();
1294
1458
  ```
1295
1459
 
1296
- ### History interface
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
- **Interface:**
1462
+ - Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
1301
1463
 
1302
1464
  ```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
- }
1465
+ const navigate = useNavigate();
1466
+ navigate({ to: "/home" });
1467
+ navigate(-1);
1311
1468
  ```
1312
1469
 
1313
- **Methods:**
1470
+ **`useLocation()`** returns the current location, subscribes to changes.
1314
1471
 
1315
- `history.getPath()` returns the current pathname:
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 = history.getPath();
1319
- // Returns "/users/42"
1475
+ const { path, search, state } = useLocation();
1320
1476
  ```
1321
1477
 
1322
- `history.getSearch()` returns the current search params as a parsed object:
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 search = history.getSearch();
1326
- // Returns { tab: "posts", page: 2 }
1483
+ const outlet = useOutlet();
1327
1484
  ```
1328
1485
 
1329
- `history.getState()` returns the current history state:
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 state = history.getState();
1333
- // Returns any state passed during navigation
1492
+ const { id } = useParams(userRoute);
1334
1493
  ```
1335
1494
 
1336
- `history.go(delta)` navigates forward or back in history:
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
- history.go(-1); // Go back
1340
- history.go(1); // Go forward
1341
- history.go(-2); // Go back two steps
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
- `history.push(options)` pushes or replaces a history entry:
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
- history.push({ url: "/users/42", state: { from: "list" } });
1348
- history.push({ url: "/login", replace: true });
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
- `history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
1518
+ **`useHandles()`** returns the handles from the matched route chain.
1352
1519
 
1353
- ```tsx
1354
- const unsubscribe = history.subscribe(() => {
1355
- console.log("Navigation occurred");
1356
- });
1520
+ - Returns: `Handle[]` - Array of handles
1357
1521
 
1358
- // Later: unsubscribe()
1522
+ ```tsx
1523
+ const handles = useHandles();
1359
1524
  ```
1360
1525
 
1361
- ### Hooks
1526
+ ## Components
1527
+
1528
+ **`RouterRoot`** sets up routing context and renders your routes.
1362
1529
 
1363
- **`useRouter()`** returns the Router instance:
1530
+ - `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
1364
1531
 
1365
1532
  ```tsx
1366
- const router = useRouter();
1533
+ <RouterRoot routes={routes} basePath="/app" history={history} />
1534
+ <RouterRoot router={router} />
1367
1535
  ```
1368
1536
 
1369
- **`useNavigate()`** returns a navigation function:
1537
+ **`Outlet`** renders the child route content.
1370
1538
 
1371
1539
  ```tsx
1372
- const navigate = useNavigate();
1373
- navigate({ to: "/home" });
1374
- navigate(-1);
1540
+ function Layout() {
1541
+ return (
1542
+ <div>
1543
+ <Outlet />
1544
+ </div>
1545
+ );
1546
+ }
1375
1547
  ```
1376
1548
 
1377
- **`useLocation()`** returns the current location:
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
- const { path, search, state } = useLocation();
1381
- // path: string, search: Record<string, unknown>, state: any
1554
+ <Link to="/path" params={...} search={...} replace strict preload="intent">
1555
+ Click me
1556
+ </Link>
1382
1557
  ```
1383
1558
 
1384
- **`useOutlet()`** returns the nested route content (used internally by `Outlet`):
1559
+ **`Navigate`** redirects on render.
1560
+
1561
+ - `props` - `NavigateOptions` - The navigation target
1385
1562
 
1386
1563
  ```tsx
1387
- const outlet = useOutlet();
1564
+ <Navigate to="/login" replace />
1388
1565
  ```
1389
1566
 
1390
- **`useParams(route)`** returns typed parameters for a route:
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
- const { id } = useParams(userRoute);
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
- **`useSearch(route)`** returns search params and a setter:
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 [search, setSearch] = useSearch(searchRoute);
1400
- setSearch({ page: 2 });
1401
- setSearch(prev => ({ page: prev.page + 1 }));
1586
+ const path = history.getPath();
1587
+ // Returns "/users/42"
1402
1588
  ```
1403
1589
 
1404
- **`useMatch(options)`** checks if a route matches the current path:
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 match = useMatch({ from: "/users/:id" });
1408
- const strictMatch = useMatch({ from: "/users", strict: true });
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
- **`useHandles()`** returns all handles from the matched route chain in order:
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 handles = useHandles();
1604
+ const state = history.getState();
1605
+ // Returns any state passed during navigation
1416
1606
  ```
1417
1607
 
1418
- ### Components
1608
+ **`history.go(delta)`** navigates forward or back in history.
1419
1609
 
1420
- **`RouterRoot`** is the root provider. Pass either router options or a router instance:
1610
+ - `delta` - `number` - The number of entries to move
1611
+ - Returns: `void`
1421
1612
 
1422
1613
  ```tsx
1423
- <RouterRoot routes={routes} basePath="/app" history={history} />
1424
- <RouterRoot router={router} />
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
- **`Outlet`** renders child route content:
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
- function Layout() {
1431
- return (
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
- **`Link`** navigates on click. Props extend `NavigateOptions` and `LinkOptions`:
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
- <Link to="/path" params={...} search={...} replace strict preload="intent">
1443
- Click me
1444
- </Link>
1635
+ const unsubscribe = history.subscribe(() => {
1636
+ console.log("Navigation occurred");
1637
+ });
1638
+
1639
+ // Later: unsubscribe()
1445
1640
  ```
1446
1641
 
1447
- **`Navigate`** redirects on render. Props are `NavigateOptions`:
1642
+ ## Types
1643
+
1644
+ **`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
1448
1645
 
1449
1646
  ```tsx
1450
- <Navigate to="/login" replace />
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
- ### Types
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<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
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`** is for untyped navigation:
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 push
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<P>`** is used for route matching:
1678
+ **`MatchOptions`** are options for route matching.
1478
1679
 
1479
1680
  ```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
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<P>`** is the result of a successful match:
1688
+ **`Match`** is the result of a successful route match.
1488
1689
 
1489
1690
  ```tsx
1490
- type Match<P extends Pattern> = {
1491
- route: Route<P>; // The matched route
1492
- params: Params<P>; // Extracted parameters
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 matching
1501
- preload?: "intent" | "render" | "viewport" | false;
1502
- style?: CSSProperties;
1503
- className?: string;
1504
- activeStyle?: CSSProperties;
1505
- activeClassName?: string;
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 SSR (like redirects):
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 in your components during SSR
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
- ## Roadmap
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
- Future improvements planned for Waymark:
1731
+ # Roadmap
1523
1732
 
1524
- - **Preloader context** - Pass path params and search params to preloader functions, enabling loading logic based on the target route's dynamic data
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
- ## License
1740
+ # License
1529
1741
 
1530
1742
  MIT