@plumile/router 0.1.9 → 0.1.12

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.
Files changed (114) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +682 -1
  3. package/lib/esm/ResourcePage.d.ts.map +1 -1
  4. package/lib/esm/ResourcePage.js +1 -1
  5. package/lib/esm/builder.d.ts.map +1 -1
  6. package/lib/esm/builder.js +10 -3
  7. package/lib/esm/errors/HttpRedirect.d.ts.map +1 -1
  8. package/lib/esm/errors/HttpRedirect.js +1 -1
  9. package/lib/esm/errors/index.d.ts.map +1 -1
  10. package/lib/esm/errors/index.js +1 -1
  11. package/lib/esm/eslint-rules/index.d.ts +2 -0
  12. package/lib/esm/eslint-rules/index.d.ts.map +1 -0
  13. package/lib/esm/eslint-rules/index.js +2 -0
  14. package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts +4 -0
  15. package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
  16. package/lib/esm/eslint-rules/no-direct-window-location-search.js +48 -0
  17. package/lib/esm/history/BrowserHistory.d.ts.map +1 -1
  18. package/lib/esm/history/BrowserHistory.js +4 -2
  19. package/lib/esm/history/index.d.ts.map +1 -1
  20. package/lib/esm/history/index.js +1 -1
  21. package/lib/esm/history/types.d.ts.map +1 -1
  22. package/lib/esm/history/types.js +1 -1
  23. package/lib/esm/index.d.ts +1 -0
  24. package/lib/esm/index.d.ts.map +1 -1
  25. package/lib/esm/index.js +2 -1
  26. package/lib/esm/routing/Link.d.ts +1 -0
  27. package/lib/esm/routing/Link.d.ts.map +1 -1
  28. package/lib/esm/routing/Link.js +35 -4
  29. package/lib/esm/routing/RouteComponentWrapper.d.ts.map +1 -1
  30. package/lib/esm/routing/RouteComponentWrapper.js +7 -2
  31. package/lib/esm/routing/RouterRenderer.d.ts.map +1 -1
  32. package/lib/esm/routing/RouterRenderer.js +1 -1
  33. package/lib/esm/routing/createRouter.d.ts +7 -1
  34. package/lib/esm/routing/createRouter.d.ts.map +1 -1
  35. package/lib/esm/routing/createRouter.js +494 -11
  36. package/lib/esm/routing/index.d.ts +4 -0
  37. package/lib/esm/routing/index.d.ts.map +1 -1
  38. package/lib/esm/routing/index.js +5 -1
  39. package/lib/esm/routing/useNavigate.d.ts +6 -0
  40. package/lib/esm/routing/useNavigate.d.ts.map +1 -0
  41. package/lib/esm/routing/useNavigate.js +11 -0
  42. package/lib/esm/routing/useQuery.d.ts +2 -0
  43. package/lib/esm/routing/useQuery.d.ts.map +1 -0
  44. package/lib/esm/routing/useQuery.js +9 -0
  45. package/lib/esm/routing/useQueryState.d.ts +13 -0
  46. package/lib/esm/routing/useQueryState.d.ts.map +1 -0
  47. package/lib/esm/routing/useQueryState.js +80 -0
  48. package/lib/esm/routing/useTypedQuery.d.ts +2 -0
  49. package/lib/esm/routing/useTypedQuery.d.ts.map +1 -0
  50. package/lib/esm/routing/useTypedQuery.js +36 -0
  51. package/lib/esm/tools/buildSearch.d.ts +6 -0
  52. package/lib/esm/tools/buildSearch.d.ts.map +1 -0
  53. package/lib/esm/tools/buildSearch.js +60 -0
  54. package/lib/esm/tools/index.d.ts.map +1 -1
  55. package/lib/esm/tools/index.js +1 -1
  56. package/lib/esm/tools/query-dsl.d.ts +28 -0
  57. package/lib/esm/tools/query-dsl.d.ts.map +1 -0
  58. package/lib/esm/tools/query-dsl.js +250 -0
  59. package/lib/esm/tools/query.d.ts +2 -0
  60. package/lib/esm/tools/query.d.ts.map +1 -0
  61. package/lib/esm/tools/query.js +43 -0
  62. package/lib/esm/tools.d.ts +2 -2
  63. package/lib/esm/tools.d.ts.map +1 -1
  64. package/lib/esm/tools.js +3 -2
  65. package/lib/esm/type-tests/query-infer.test-d.d.ts +2 -0
  66. package/lib/esm/type-tests/query-infer.test-d.d.ts.map +1 -0
  67. package/lib/esm/type-tests/query-infer.test-d.js +49 -0
  68. package/lib/esm/types.d.ts +28 -4
  69. package/lib/esm/types.d.ts.map +1 -1
  70. package/lib/esm/types.js +1 -1
  71. package/lib/tsconfig.esm.tsbuildinfo +1 -1
  72. package/lib/types/ResourcePage.d.ts.map +1 -1
  73. package/lib/types/builder.d.ts.map +1 -1
  74. package/lib/types/errors/HttpRedirect.d.ts.map +1 -1
  75. package/lib/types/errors/index.d.ts.map +1 -1
  76. package/lib/types/eslint-rules/index.d.ts +2 -0
  77. package/lib/types/eslint-rules/index.d.ts.map +1 -0
  78. package/lib/types/eslint-rules/no-direct-window-location-search.d.ts +4 -0
  79. package/lib/types/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
  80. package/lib/types/history/BrowserHistory.d.ts.map +1 -1
  81. package/lib/types/history/index.d.ts.map +1 -1
  82. package/lib/types/history/types.d.ts.map +1 -1
  83. package/lib/types/index.d.ts +1 -0
  84. package/lib/types/index.d.ts.map +1 -1
  85. package/lib/types/routing/Link.d.ts +1 -0
  86. package/lib/types/routing/Link.d.ts.map +1 -1
  87. package/lib/types/routing/RouteComponentWrapper.d.ts.map +1 -1
  88. package/lib/types/routing/RouterRenderer.d.ts.map +1 -1
  89. package/lib/types/routing/createRouter.d.ts +7 -1
  90. package/lib/types/routing/createRouter.d.ts.map +1 -1
  91. package/lib/types/routing/index.d.ts +4 -0
  92. package/lib/types/routing/index.d.ts.map +1 -1
  93. package/lib/types/routing/useNavigate.d.ts +6 -0
  94. package/lib/types/routing/useNavigate.d.ts.map +1 -0
  95. package/lib/types/routing/useQuery.d.ts +2 -0
  96. package/lib/types/routing/useQuery.d.ts.map +1 -0
  97. package/lib/types/routing/useQueryState.d.ts +13 -0
  98. package/lib/types/routing/useQueryState.d.ts.map +1 -0
  99. package/lib/types/routing/useTypedQuery.d.ts +2 -0
  100. package/lib/types/routing/useTypedQuery.d.ts.map +1 -0
  101. package/lib/types/tools/buildSearch.d.ts +6 -0
  102. package/lib/types/tools/buildSearch.d.ts.map +1 -0
  103. package/lib/types/tools/index.d.ts.map +1 -1
  104. package/lib/types/tools/query-dsl.d.ts +28 -0
  105. package/lib/types/tools/query-dsl.d.ts.map +1 -0
  106. package/lib/types/tools/query.d.ts +2 -0
  107. package/lib/types/tools/query.d.ts.map +1 -0
  108. package/lib/types/tools.d.ts +2 -2
  109. package/lib/types/tools.d.ts.map +1 -1
  110. package/lib/types/type-tests/query-infer.test-d.d.ts +2 -0
  111. package/lib/types/type-tests/query-infer.test-d.d.ts.map +1 -0
  112. package/lib/types/types.d.ts +28 -4
  113. package/lib/types/types.d.ts.map +1 -1
  114. package/package.json +12 -12
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) Plumile/Pounjs and its affiliates.
3
+ Copyright (c) Plumile and its affiliates.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1 +1,682 @@
1
- # Pounjs router
1
+ # @plumile/router
2
+
3
+ A modern, type-safe React router built with TypeScript that supports code splitting, data preloading, and React Suspense integration.
4
+
5
+ ## Features
6
+
7
+ - **Type-safe routing** with full TypeScript support
8
+ - **Code splitting** with dynamic imports and lazy loading
9
+ - **Data preloading** for faster navigation
10
+ - **React Suspense** integration for smooth loading states
11
+ - **Browser history** management with push/replace state
12
+ - **Nested routing** support
13
+ - **Route-based redirects**
14
+ - **Active link detection** with exact/partial matching
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @plumile/router
20
+ ```
21
+
22
+ ## Peer Dependencies
23
+
24
+ This package requires the following peer dependencies:
25
+
26
+ ```bash
27
+ npm install react react-dom react-relay relay-runtime @types/react @types/react-dom @types/react-relay
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ### 1. Define Your Routes
33
+
34
+ ```typescript
35
+ import { Route, getResourcePage } from '@plumile/router';
36
+
37
+ const routes: Route<any, any>[] = [
38
+ {
39
+ path: '/',
40
+ resourcePage: getResourcePage('Home', () => import('./pages/Home')),
41
+ },
42
+ {
43
+ path: '/about',
44
+ resourcePage: getResourcePage('About', () => import('./pages/About')),
45
+ },
46
+ {
47
+ path: '/users',
48
+ children: [
49
+ {
50
+ path: '/:id',
51
+ resourcePage: getResourcePage(
52
+ 'UserProfile',
53
+ () => import('./pages/UserProfile'),
54
+ ),
55
+ prepare: ({ variables }) => {
56
+ // Preload user data
57
+ return { userId: variables.id };
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ ];
63
+ ```
64
+
65
+ ### 2. Create the Router
66
+
67
+ ```typescript
68
+ import createRouter from '@plumile/router';
69
+
70
+ const { context, cleanup } = createRouter(routes);
71
+ ```
72
+
73
+ ### 3. Provide Router Context
74
+
75
+ ```tsx
76
+ import React from 'react';
77
+ import { RoutingContext, RouterRenderer } from '@plumile/router';
78
+
79
+ function App() {
80
+ return (
81
+ <RoutingContext.Provider value={context}>
82
+ <RouterRenderer fallback={<div>Loading...</div>} />
83
+ </RoutingContext.Provider>
84
+ );
85
+ }
86
+ ```
87
+
88
+ ### 4. Navigation with Links
89
+
90
+ ```tsx
91
+ import { Link } from '@plumile/router';
92
+
93
+ function Navigation() {
94
+ return (
95
+ <nav>
96
+ <Link to="/" exact activeClassName="active">
97
+ Home
98
+ </Link>
99
+ <Link to="/about" activeClassName="active">
100
+ About
101
+ </Link>
102
+ <Link to="/users/123" activeClassName="active">
103
+ User Profile
104
+ </Link>
105
+ </nav>
106
+ );
107
+ }
108
+ ```
109
+
110
+ ## API Reference
111
+
112
+ ### Core Components
113
+
114
+ #### `createRouter(routes: Route[], options?)`
115
+
116
+ Creates a router instance with the given route configuration.
117
+
118
+ **Parameters:**
119
+
120
+ - `routes`: Array of route definitions
121
+ - `options?`: Optional object
122
+ - `devtools?: boolean` Force enable/disable the global inspector. Defaults to enabled when `NODE_ENV !== 'production'`, disabled otherwise.
123
+
124
+ **Returns:**
125
+
126
+ - `context`: Router context object for the React Context Provider
127
+ - `cleanup`: Function to clean up router listeners
128
+
129
+ #### `RouterRenderer`
130
+
131
+ Renders the matched route component with Suspense support.
132
+
133
+ **Props:**
134
+
135
+ - `fallback?`: ReactNode - Fallback UI while loading components
136
+ - `enableTransition?`: boolean - Enable React 18 transitions
137
+ - `pending?`: ReactNode - UI to show during transitions
138
+
139
+ #### `Link`
140
+
141
+ Navigation component that handles client-side routing.
142
+
143
+ **Props:**
144
+
145
+ - `to`: string - Destination path
146
+ - `exact?`: boolean - Exact path matching for active state
147
+ - `activeClassName?`: string - CSS class when link is active
148
+ - `className?`: string - Base CSS class
149
+ - `preload?`: boolean - Preload route on hover
150
+ - `replace?`: boolean - Replace current history entry
151
+
152
+ #### `RoutingContext`
153
+
154
+ React context that provides router functionality to components.
155
+
156
+ ### Route Configuration
157
+
158
+ #### `Route<TPrepared, TVariables>`
159
+
160
+ Route definition interface.
161
+
162
+ **Properties:**
163
+
164
+ - `path?`: string - URL path pattern
165
+ - `children?`: Route[] | Redirect[] - Nested routes
166
+ - `resourcePage?`: ResourcePage - Lazy-loaded component
167
+ - `prepare?`: Function to preload data
168
+ - `render?`: Custom render function
169
+
170
+ #### `Redirect`
171
+
172
+ Redirect configuration.
173
+
174
+ **Properties:**
175
+
176
+ - `path?`: string - Source path
177
+ - `to`: string - Destination path
178
+ - `status?`: 301 | 302 - HTTP status code
179
+
180
+ ### Resource Management
181
+
182
+ #### `getResourcePage(moduleId: string, loader: ResourcePageLoader)`
183
+
184
+ Creates a resource for lazy-loading components.
185
+
186
+ **Parameters:**
187
+
188
+ - `moduleId`: Unique identifier for caching
189
+ - `loader`: Function that returns dynamic import
190
+
191
+ **Returns:**
192
+
193
+ - `ResourcePage` instance
194
+
195
+ #### `ResourcePage`
196
+
197
+ Manages lazy-loaded components with Suspense integration.
198
+
199
+ **Methods:**
200
+
201
+ - `load()`: Promise - Load the component
202
+ - `get()`: Component | undefined - Get loaded component
203
+ - `read()`: Component - Read with Suspense (throws Promise if loading)
204
+
205
+ ### History Management
206
+
207
+ #### `BrowserHistory`
208
+
209
+ Browser history implementation.
210
+
211
+ **Methods:**
212
+
213
+ - `push(location)`: Navigate to new location
214
+ - `set(location)`: Replace current location
215
+ - `subscribe(listener)`: Listen for navigation changes
216
+
217
+ ### Utilities
218
+
219
+ #### `getMatchedRoute(routes, location)`
220
+
221
+ Finds the matching route for a given location.
222
+
223
+ #### `prepareMatch(match)`
224
+
225
+ Prepares route data and components for rendering.
226
+
227
+ #### `r<TPrepared, TVariables>(route)`
228
+
229
+ Type helper for strongly-typed route definitions.
230
+
231
+ ## Advanced Usage
232
+
233
+ ### Typed Query Parameters (DSL)
234
+
235
+ The router provides a lightweight schema DSL for parsing, typing, normalizing and serializing query strings.
236
+
237
+ #### 1. Define a schema on the deepest route
238
+
239
+ ```ts
240
+ import { q, r } from '@plumile/router';
241
+
242
+ const routes = [
243
+ r({
244
+ path: '/items',
245
+ // Schema: page = number (default 1), tag(s) = array of strings, flag = optional boolean
246
+ query: {
247
+ page: q.default(q.number(), 1),
248
+ tags: q.array(q.string()),
249
+ flag: q.optional(q.boolean()),
250
+ },
251
+ prepare: ({ query }) => {
252
+ // query is typed: { page: number; tags: string[]; flag?: boolean }
253
+ return { page: query.page };
254
+ },
255
+ render: () => null,
256
+ }),
257
+ ];
258
+ ```
259
+
260
+ #### 2. Access parsed & typed queries
261
+
262
+ ```tsx
263
+ import { useQuery, useTypedQuery } from '@plumile/router';
264
+
265
+ function List() {
266
+ const raw = useQuery(); // Record<string, string | string[]>
267
+ const typed = useTypedQuery(); // Auto‑inferred from deepest route schema (no generic needed)
268
+ const [page, setPage] = useQueryState<number>('page'); // Bidirectional state ↔ URL for one param
269
+ return <pre>{JSON.stringify({ raw, typed }, null, 2)}</pre>;
270
+ }
271
+ ```
272
+
273
+ Referential stability: both `raw` (from `useQuery()`) and `typed` (from `useTypedQuery()`) are memoized per canonical search string. If the URL search part doesn't change semantically (including key order canonicalization), the hook returns the exact same object reference. You can safely list either in React dependency arrays without extra `useMemo` or a hypothetical `useStableQuery` helper (not needed).
274
+
275
+ Type inference:
276
+
277
+ - If the matched deepest route has a `query` schema, `useTypedQuery()` returns the inferred `InferQuery<typeof schema>` shape automatically.
278
+ - If no schema exists, it returns the raw parsed object (record of string | string[]) so you can still read values safely.
279
+ - You can still supply a generic manually (`useTypedQuery<MyShape>()`) in edge cases (e.g. incremental migration) but it is usually unnecessary now.
280
+
281
+ #### 3. Programmatic navigation with typed query
282
+
283
+ ```tsx
284
+ import { useNavigate } from '@plumile/router';
285
+
286
+ function Pager({ page }: { page: number }) {
287
+ const navigate = useNavigate();
288
+ return (
289
+ <button
290
+ onClick={() => {
291
+ navigate({ query: { page: page + 1 } });
292
+ }}
293
+ >
294
+ Next page
295
+ </button>
296
+ );
297
+ }
298
+ ```
299
+
300
+ #### 4. Link component with query
301
+
302
+ ```tsx
303
+ import { Link } from '@plumile/router';
304
+
305
+ <Link to="/items" query={{ page: 2, tags: ['a', 'b'], flag: true }}>
306
+ Filter
307
+ </Link>;
308
+ ```
309
+
310
+ `Link` & `navigate` automatically serialize using the route schema, applying:
311
+
312
+ - Schema key order
313
+ - Array repetition (`?tags=a&tags=b`)
314
+ - Omission of default values when `omitDefaults` optimization applies internally
315
+
316
+ #### 5. Normalization
317
+
318
+ Built‑in simple normalization currently clamps `page < 1` to `1` and issues a `replaceState` to avoid polluting history.
319
+
320
+ #### 6. Serialization utility
321
+
322
+ ```ts
323
+ import { buildSearch, q } from '@plumile/router';
324
+
325
+ const schema = {
326
+ page: q.default(q.number(), 1),
327
+ tag: q.array(q.string()),
328
+ } as const;
329
+ const search = buildSearch({ page: 1, tag: ['x', 'y'] }, schema, {
330
+ omitDefaults: true,
331
+ });
332
+ // => '?tag=x&tag=y'
333
+ ```
334
+
335
+ #### 7. Parsing utility / alias
336
+
337
+ ```ts
338
+ import { parseSearch, q } from '@plumile/router';
339
+
340
+ const schema = { flag: q.boolean() } as const;
341
+ const typed = parseSearch(schema, '?flag=1'); // { flag: true }
342
+ ```
343
+
344
+ #### 8. Performance and stability
345
+
346
+ There are two coordinated caching layers:
347
+
348
+ 1. Raw query cache: A process-wide Map keyed by a canonicalized search string (sorted keys, ordered value emission). Produces a frozen empty object for the empty search and reuses prior parsed objects, yielding stable references for unchanged search state.
349
+ 2. Typed query cache: A WeakMap keyed by (schema reference → canonical search signature) that parses once and reuses the typed value. Structural deep-equality canonicalization means semantically equivalent searches (e.g. reordered keys) reuse object identity.
350
+
351
+ Effects:
352
+
353
+ - Stable object identity for both raw and typed queries eliminates needless renders and removes the need for an extra `useStableQuery` hook.
354
+ - Safe to put `useQuery()` / `useTypedQuery()` results directly in dependency arrays (`useEffect`, `useMemo`, selectors, etc.).
355
+ - Empty query allocations are avoided (shared frozen object).
356
+
357
+ Guideline: If you need to derive lightweight projections (e.g. `const { page } = typed`), you can still destructure; but avoid spreading into a new object if you rely on reference equality downstream.
358
+
359
+ #### Devtools / Inspection
360
+
361
+ In development (`NODE_ENV !== 'production'` by default, or when `createRouter(..., { devtools: true })` is passed), the router exposes a lightweight global inspector.
362
+
363
+ Optionally you can enable an in‑page overlay panel for quick visual inspection:
364
+
365
+ ```ts
366
+ createRouter(routes, {
367
+ devtools: { panel: true, global: true, shortcut: 'Alt+Shift+R' },
368
+ });
369
+ ```
370
+
371
+ Devtools option forms:
372
+
373
+ - `devtools: true | false` (boolean) – legacy form, controls global only.
374
+ - `devtools: { global?: boolean; panel?: boolean; shortcut?: string }` – granular.
375
+ - `global` (default: NODE_ENV !== 'production') exposes `window.__PLUMILE_ROUTER__`.
376
+ - `panel` (default: false) mounts an overlay; closed with the close button or page reload.
377
+ - `shortcut` (default: `Alt+Shift+R`) toggles panel visibility.
378
+
379
+ The panel displays current path+search, variables, raw query and typed query. It is intentionally framework‑agnostic (no React runtime cost) and uses a shadow root to minimize style collisions.
380
+
381
+ ```js
382
+ window.__PLUMILE_ROUTER__.get(); // current RouteEntry
383
+ const unsub = window.__PLUMILE_ROUTER__.subscribe((entry) =>
384
+ console.log(entry.typedQuery),
385
+ );
386
+ ```
387
+
388
+ This lets you introspect parsed queries & variables without adding debug code. Neither global nor panel is present in production unless explicitly forced with `devtools: { global: true, panel: true }` (discouraged).
389
+
390
+ ##### Detailed Usage
391
+
392
+ The global is intentionally tiny to avoid coupling and bundle weight. It only appears when the devtools flag resolves truthy:
393
+
394
+ 1. Explicit: `createRouter(routes, { devtools: true })`
395
+ 2. Implicit heuristic: `NODE_ENV !== 'production'`
396
+ 3. Disabled explicitly: `createRouter(routes, { devtools: false })`
397
+
398
+ Always guard in snippets that might be copied to production code:
399
+
400
+ ```js
401
+ if (window.__PLUMILE_ROUTER__) {
402
+ console.log(window.__PLUMILE_ROUTER__.get());
403
+ }
404
+ ```
405
+
406
+ `get()` returns the current `RouteEntry` (simplified shape):
407
+
408
+ ```ts
409
+ type RouteEntry = {
410
+ location: Location; // window.location snapshot
411
+ route: { match; params; path } | null; // current matched route (or null)
412
+ preparedMatch: { routes: { prepared; render?; resourcePage? }[]; match }; // internal prepared tree
413
+ forceRerender: boolean; // indicates forced re-render situations
414
+ rawSearch: string; // '?page=2&tag=a'
415
+ query: Record<string, string | string[]>; // raw aggregated query params
416
+ typedQuery: any; // typed query if schema present
417
+ };
418
+ ```
419
+
420
+ Common console patterns:
421
+
422
+ 1. Log every navigation with typed query & params:
423
+
424
+ ```js
425
+ const dev = window.__PLUMILE_ROUTER__;
426
+ if (dev) {
427
+ const off = dev.subscribe((e) => {
428
+ console.log('[router]', e.location.pathname + e.location.search, {
429
+ vars: e.route?.params,
430
+ typed: e.typedQuery,
431
+ });
432
+ });
433
+ // later: off();
434
+ }
435
+ ```
436
+
437
+ 2. Inspect prepared data for the deepest route (last element):
438
+
439
+ ```js
440
+ const entry = window.__PLUMILE_ROUTER__?.get();
441
+ const deepest = entry?.preparedMatch.routes.at(-1);
442
+ deepest?.prepared; // Prepared data returned by deepest prepare()
443
+ ```
444
+
445
+ 3. Quick diff watcher for query changes only:
446
+
447
+ ```js
448
+ let last = window.__PLUMILE_ROUTER__?.get().rawSearch;
449
+ const off = window.__PLUMILE_ROUTER__?.subscribe((e) => {
450
+ if (e.rawSearch !== last) {
451
+ console.log('query changed', last, '=>', e.rawSearch, e.typedQuery);
452
+ last = e.rawSearch;
453
+ }
454
+ });
455
+ ```
456
+
457
+ 4. Measuring parse performance (rough dev-only micro‑benchmark):
458
+
459
+ ```js
460
+ const { get } = window.__PLUMILE_ROUTER__;
461
+ const before = performance.now();
462
+ for (let i = 0; i < 200; i++) get().typedQuery; // use cache; ensures no GC
463
+ console.log('elapsed ms', performance.now() - before);
464
+ ```
465
+
466
+ 5. Safe optional chaining helper (copy/paste):
467
+
468
+ ```js
469
+ const R = window.__PLUMILE_ROUTER__;
470
+ R && R.subscribe((e) => console.debug('[typedQuery]', e.typedQuery));
471
+ ```
472
+
473
+ Unsubscribing: every `subscribe` returns a disposer function. Always call it if you set up long‑lived listeners in a debugging session to avoid memory leaks during hot reloads.
474
+
475
+ Extending: if you need more (e.g. trigger navigations) you can wrap `createRouter` in your app and attach additional methods to `window.__PLUMILE_ROUTER__` (e.g. `navigate: context.navigate`) — omitted by default to avoid accidental production reliance.
476
+
477
+ Production: the symbol is not defined; accessing it will yield `undefined`. Never ship logic depending on it; keep usage inside `if (process.env.NODE_ENV !== 'production')` blocks or guarded optional checks.
478
+
479
+ ### ESLint Rule: no-direct-window-location-search
480
+
481
+ To encourage consistent usage of the query hooks, a custom rule is provided inside the router package to flag raw `window.location.search` access.
482
+
483
+ Add to your flat ESLint config:
484
+
485
+ ```js
486
+ import noDirectWindowLocationSearch from '@plumile/router/lib/eslint-rules/no-direct-window-location-search.js';
487
+
488
+ export default [
489
+ {
490
+ plugins: {
491
+ '@plumile-router/dx': {
492
+ rules: {
493
+ 'no-direct-window-location-search': noDirectWindowLocationSearch,
494
+ },
495
+ },
496
+ },
497
+ rules: {
498
+ '@plumile-router/dx/no-direct-window-location-search': 'warn',
499
+ },
500
+ },
501
+ ];
502
+ ```
503
+
504
+ Optional configuration:
505
+
506
+ ```js
507
+ // allow some files (e.g. legacy bootstrap) to keep direct access
508
+ rules: {
509
+ '@plumile-router/dx/no-direct-window-location-search': [
510
+ 'warn',
511
+ { allowInFiles: ['legacy-entry.ts'] },
512
+ ],
513
+ },
514
+ ```
515
+
516
+ When triggered, replace patterns like:
517
+
518
+ ```ts
519
+ const qs = window.location.search;
520
+ ```
521
+
522
+ with:
523
+
524
+ ```ts
525
+ const query = useQuery(); // or useTypedQuery()
526
+ ```
527
+
528
+ ### Migration Guide (Phases 1 → 5)
529
+
530
+ 1. Phase 1/2: Upgrade — no schema needed. Use `useQuery()` for raw params.
531
+ 2. Phase 3: Add `query` schema to your deepest route; adopt `useTypedQuery()` where type safety is desirable.
532
+ 3. Phase 4: Replace manual URL building with `navigate({ query })` or `<Link query={...} />`. Remove ad‑hoc serialization logic.
533
+ 4. Phase 5: Move lightweight data loading logic that depends on query into `prepare({ query })` (now typed). Rely on built‑in normalization (e.g. page clamp) or add custom normalization inside `prepare` followed by a `navigate({ replace: true, query: normalized })` if needed.
534
+ 5. Optional: Use `buildSearch` / `parseSearch` for unit tests & utilities.
535
+
536
+ ### Query Descriptor Reference
537
+
538
+ | Descriptor | Description | Serialize Example |
539
+ | -------------------------------- | --------------------------------------------------- | ------------------------------------ |
540
+ | `q.string()` | Last occurrence string | `{ q: 'x' } -> ?q=x` |
541
+ | `q.number()` | Number (invalid => undefined) | `{ n: 2 } -> ?n=2` |
542
+ | `q.boolean()` | Presence / true/false/1/0 | `{ f: true } -> ?f=1` |
543
+ | `q.enum('a','b')` | Restricted string | `{ e: 'a' } -> ?e=a` |
544
+ | `q.array(inner)` | Repeated key multi-values | `{ tag: ['x','y'] } -> ?tag=x&tag=y` |
545
+ | `q.optional(d)` | Marks descriptor optional | omitted if undefined |
546
+ | `q.default(d, v)` | Supplies default + omit on serialize (omitDefaults) | default skipped |
547
+ | `q.emptyAsUndefined(q.string())` | Maps empty string '' to undefined | omitted |
548
+ | `q.custom({ parse, serialize })` | Custom parse/serialize logic | depends |
549
+
550
+ Custom: `q.custom({ parse(values), serialize(value) })` lets you wire bespoke formats. Ensure `serialize` outputs an array of raw string values. Use sparingly to keep schemas readable.
551
+
552
+ ### useQueryState Hook
553
+
554
+ `useQueryState(key, opts?)` creates a controlled binding between a single query parameter and component state.
555
+
556
+ ```tsx
557
+ const [page, setPage] = useQueryState<number>('page');
558
+ // Increment page without pushing a new history entry
559
+ setPage(page! + 1, { replace: true });
560
+ ```
561
+
562
+ Behavior:
563
+
564
+ - Reads from typedQuery if schema present, else raw query.
565
+ - Respects schema defaults (and `defaultValue` override in options) and omits key when value equals default (with `omitIfDefault: true`).
566
+ - Pass `{ raw: true }` to force raw (string) source for incremental migrations.
567
+ - Uses existing navigation serialization (ordering, omit defaults, arrays).
568
+
569
+ Options:
570
+ `{ defaultValue?, omitIfDefault?: boolean = true, replace?: boolean, raw?: boolean }`
571
+
572
+ ### Data Preloading
573
+
574
+ ```typescript
575
+ const route: Route<{ user: User }, { id: string }> = {
576
+ path: '/users/:id',
577
+ prepare: async ({ variables }) => {
578
+ const user = await fetchUser(variables.id);
579
+ return { user };
580
+ },
581
+ render: ({ prepared, children }) => {
582
+ if (!prepared) return null;
583
+ return <UserLayout user={prepared.user}>{children}</UserLayout>;
584
+ },
585
+ };
586
+ ```
587
+
588
+ ### Custom Route Rendering
589
+
590
+ ```typescript
591
+ const route: Route<any, any> = {
592
+ path: '/protected',
593
+ render: ({ children, prepared }) => {
594
+ if (!userIsAuthenticated()) {
595
+ return <Redirect to="/login" />;
596
+ }
597
+ return <ProtectedLayout>{children}</ProtectedLayout>;
598
+ },
599
+ };
600
+ ```
601
+
602
+ ### Programmatic Navigation
603
+
604
+ ```tsx
605
+ import { useContext } from 'react';
606
+ import { RoutingContext } from '@plumile/router';
607
+
608
+ function MyComponent() {
609
+ const router = useContext(RoutingContext);
610
+
611
+ const handleClick = () => {
612
+ router.history.push({ pathname: '/new-path' });
613
+ };
614
+
615
+ return <button onClick={handleClick}>Navigate</button>;
616
+ }
617
+ ```
618
+
619
+ ### Route Preloading
620
+
621
+ ```tsx
622
+ import { useContext } from 'react';
623
+ import { RoutingContext } from '@plumile/router';
624
+
625
+ function MyComponent() {
626
+ const router = useContext(RoutingContext);
627
+
628
+ const handleHover = () => {
629
+ // Preload code only
630
+ router.preloadCode('/users/123');
631
+
632
+ // Preload code and data
633
+ router.preload('/users/123');
634
+ };
635
+
636
+ return (
637
+ <Link to="/users/123" onMouseEnter={handleHover}>
638
+ User Profile
639
+ </Link>
640
+ );
641
+ }
642
+ ```
643
+
644
+ ## TypeScript Support
645
+
646
+ The router is built with TypeScript and provides full type safety:
647
+
648
+ ```typescript
649
+ import { Route, r } from '@plumile/router';
650
+
651
+ interface UserPageData {
652
+ user: User;
653
+ posts: Post[];
654
+ }
655
+
656
+ interface UserPageParams {
657
+ id: string;
658
+ }
659
+
660
+ const userRoute = r<UserPageData, UserPageParams>({
661
+ path: '/users/:id',
662
+ prepare: ({ variables }) => {
663
+ // variables.id is typed as string
664
+ return fetchUserData(variables.id);
665
+ },
666
+ render: ({ prepared }) => {
667
+ // prepared is typed as UserPageData | undefined
668
+ if (!prepared) return null;
669
+ return <UserPage user={prepared.user} posts={prepared.posts} />;
670
+ },
671
+ });
672
+ ```
673
+
674
+ ## Browser Support
675
+
676
+ - Modern browsers that support ES2021
677
+ - React 18+
678
+ - Node.js 21+
679
+
680
+ ## License
681
+
682
+ Licensed under the terms specified in the package's LICENSE file.
@@ -1 +1 @@
1
- {"version":3,"file":"ResourcePage.d.ts","sourceRoot":"","sources":["../../src/ResourcePage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAczE,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAe;IAE9B,OAAO,CAAC,QAAQ,CAAqB;IAErC,OAAO,CAAC,SAAS,CAAqC;IAEtD,OAAO,CAAC,QAAQ,CAA4B;IAG5C,OAAO,CAAC,UAAU,CAAS;gBAER,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM;IAWlD,IAAI,IAAI,OAAO,CAAC,kBAAkB,CAAC;IA2BzC,GAAG,IAAI,kBAAkB,GAAG,SAAS;IAgBrC,IAAI,IAAI,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,GAAG,KAAK;YAazD,MAAM;CAKrB;AAmBD,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,YAAY,GAAG,IAAI,CAOrB"}
1
+ {"version":3,"file":"ResourcePage.d.ts","sourceRoot":"","sources":["../../src/ResourcePage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA2BzE,qBAAa,YAAY;IAEvB,OAAO,CAAC,OAAO,CAAe;IAG9B,OAAO,CAAC,QAAQ,CAAqB;IAGrC,OAAO,CAAC,SAAS,CAAqC;IAGtD,OAAO,CAAC,QAAQ,CAA4B;IAI5C,OAAO,CAAC,UAAU,CAAS;gBAQR,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM;IAclD,IAAI,IAAI,OAAO,CAAC,kBAAkB,CAAC;IA8BzC,GAAG,IAAI,kBAAkB,GAAG,SAAS;IAmBrC,IAAI,IAAI,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,GAAG,KAAK;YAkBzD,MAAM;CAKrB;AAsBD,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,YAAY,GAAG,IAAI,CAOrB"}