@plumile/router 0.1.11 → 0.1.13

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 (127) hide show
  1. package/README.md +755 -1
  2. package/lib/esm/builder.d.ts.map +1 -1
  3. package/lib/esm/builder.js +10 -3
  4. package/lib/esm/eslint-rules/index.d.ts +2 -0
  5. package/lib/esm/eslint-rules/index.d.ts.map +1 -0
  6. package/lib/esm/eslint-rules/index.js +2 -0
  7. package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts +4 -0
  8. package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
  9. package/lib/esm/eslint-rules/no-direct-window-location-search.js +48 -0
  10. package/lib/esm/history/BrowserHistory.d.ts.map +1 -1
  11. package/lib/esm/history/BrowserHistory.js +4 -2
  12. package/lib/esm/index.d.ts +5 -0
  13. package/lib/esm/index.d.ts.map +1 -1
  14. package/lib/esm/index.js +6 -1
  15. package/lib/esm/routing/Link.d.ts +1 -0
  16. package/lib/esm/routing/Link.d.ts.map +1 -1
  17. package/lib/esm/routing/Link.js +35 -4
  18. package/lib/esm/routing/RouteComponentWrapper.d.ts.map +1 -1
  19. package/lib/esm/routing/RouteComponentWrapper.js +7 -2
  20. package/lib/esm/routing/createRouter.d.ts +8 -1
  21. package/lib/esm/routing/createRouter.d.ts.map +1 -1
  22. package/lib/esm/routing/createRouter.js +540 -17
  23. package/lib/esm/routing/devtools.d.ts +20 -0
  24. package/lib/esm/routing/devtools.d.ts.map +1 -0
  25. package/lib/esm/routing/devtools.js +678 -0
  26. package/lib/esm/routing/filters.d.ts +97 -0
  27. package/lib/esm/routing/filters.d.ts.map +1 -0
  28. package/lib/esm/routing/filters.js +557 -0
  29. package/lib/esm/routing/index.d.ts +10 -0
  30. package/lib/esm/routing/index.d.ts.map +1 -1
  31. package/lib/esm/routing/index.js +11 -1
  32. package/lib/esm/routing/useActiveFilters.d.ts +9 -0
  33. package/lib/esm/routing/useActiveFilters.d.ts.map +1 -0
  34. package/lib/esm/routing/useActiveFilters.js +38 -0
  35. package/lib/esm/routing/useFilterState.d.ts +10 -0
  36. package/lib/esm/routing/useFilterState.d.ts.map +1 -0
  37. package/lib/esm/routing/useFilterState.js +14 -0
  38. package/lib/esm/routing/useNavigate.d.ts +13 -0
  39. package/lib/esm/routing/useNavigate.d.ts.map +1 -0
  40. package/lib/esm/routing/useNavigate.js +11 -0
  41. package/lib/esm/routing/useNavigateWithQuery.d.ts +15 -0
  42. package/lib/esm/routing/useNavigateWithQuery.d.ts.map +1 -0
  43. package/lib/esm/routing/useNavigateWithQuery.js +95 -0
  44. package/lib/esm/routing/useQuery.d.ts +2 -0
  45. package/lib/esm/routing/useQuery.d.ts.map +1 -0
  46. package/lib/esm/routing/useQuery.js +9 -0
  47. package/lib/esm/routing/useQueryObject.d.ts +18 -0
  48. package/lib/esm/routing/useQueryObject.d.ts.map +1 -0
  49. package/lib/esm/routing/useQueryObject.js +107 -0
  50. package/lib/esm/routing/useQueryState.d.ts +13 -0
  51. package/lib/esm/routing/useQueryState.d.ts.map +1 -0
  52. package/lib/esm/routing/useQueryState.js +80 -0
  53. package/lib/esm/routing/useStableRefEquality.d.ts +5 -0
  54. package/lib/esm/routing/useStableRefEquality.d.ts.map +1 -0
  55. package/lib/esm/routing/useStableRefEquality.js +47 -0
  56. package/lib/esm/routing/useTypedQuery.d.ts +2 -0
  57. package/lib/esm/routing/useTypedQuery.d.ts.map +1 -0
  58. package/lib/esm/routing/useTypedQuery.js +36 -0
  59. package/lib/esm/tools/buildSearch.d.ts +12 -0
  60. package/lib/esm/tools/buildSearch.d.ts.map +1 -0
  61. package/lib/esm/tools/buildSearch.js +264 -0
  62. package/lib/esm/tools/query-dsl.d.ts +28 -0
  63. package/lib/esm/tools/query-dsl.d.ts.map +1 -0
  64. package/lib/esm/tools/query-dsl.js +250 -0
  65. package/lib/esm/tools/query.d.ts +2 -0
  66. package/lib/esm/tools/query.d.ts.map +1 -0
  67. package/lib/esm/tools/query.js +43 -0
  68. package/lib/esm/tools.d.ts +2 -2
  69. package/lib/esm/tools.d.ts.map +1 -1
  70. package/lib/esm/tools.js +3 -2
  71. package/lib/esm/type-tests/query-infer.test-d.d.ts +2 -0
  72. package/lib/esm/type-tests/query-infer.test-d.d.ts.map +1 -0
  73. package/lib/esm/type-tests/query-infer.test-d.js +49 -0
  74. package/lib/esm/types.d.ts +47 -4
  75. package/lib/esm/types.d.ts.map +1 -1
  76. package/lib/esm/types.js +1 -1
  77. package/lib/tsconfig.esm.tsbuildinfo +1 -1
  78. package/lib/types/builder.d.ts.map +1 -1
  79. package/lib/types/eslint-rules/index.d.ts +2 -0
  80. package/lib/types/eslint-rules/index.d.ts.map +1 -0
  81. package/lib/types/eslint-rules/no-direct-window-location-search.d.ts +4 -0
  82. package/lib/types/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
  83. package/lib/types/history/BrowserHistory.d.ts.map +1 -1
  84. package/lib/types/index.d.ts +5 -0
  85. package/lib/types/index.d.ts.map +1 -1
  86. package/lib/types/routing/Link.d.ts +1 -0
  87. package/lib/types/routing/Link.d.ts.map +1 -1
  88. package/lib/types/routing/RouteComponentWrapper.d.ts.map +1 -1
  89. package/lib/types/routing/createRouter.d.ts +8 -1
  90. package/lib/types/routing/createRouter.d.ts.map +1 -1
  91. package/lib/types/routing/devtools.d.ts +20 -0
  92. package/lib/types/routing/devtools.d.ts.map +1 -0
  93. package/lib/types/routing/filters.d.ts +97 -0
  94. package/lib/types/routing/filters.d.ts.map +1 -0
  95. package/lib/types/routing/index.d.ts +10 -0
  96. package/lib/types/routing/index.d.ts.map +1 -1
  97. package/lib/types/routing/useActiveFilters.d.ts +9 -0
  98. package/lib/types/routing/useActiveFilters.d.ts.map +1 -0
  99. package/lib/types/routing/useFilterState.d.ts +10 -0
  100. package/lib/types/routing/useFilterState.d.ts.map +1 -0
  101. package/lib/types/routing/useNavigate.d.ts +13 -0
  102. package/lib/types/routing/useNavigate.d.ts.map +1 -0
  103. package/lib/types/routing/useNavigateWithQuery.d.ts +15 -0
  104. package/lib/types/routing/useNavigateWithQuery.d.ts.map +1 -0
  105. package/lib/types/routing/useQuery.d.ts +2 -0
  106. package/lib/types/routing/useQuery.d.ts.map +1 -0
  107. package/lib/types/routing/useQueryObject.d.ts +18 -0
  108. package/lib/types/routing/useQueryObject.d.ts.map +1 -0
  109. package/lib/types/routing/useQueryState.d.ts +13 -0
  110. package/lib/types/routing/useQueryState.d.ts.map +1 -0
  111. package/lib/types/routing/useStableRefEquality.d.ts +5 -0
  112. package/lib/types/routing/useStableRefEquality.d.ts.map +1 -0
  113. package/lib/types/routing/useTypedQuery.d.ts +2 -0
  114. package/lib/types/routing/useTypedQuery.d.ts.map +1 -0
  115. package/lib/types/tools/buildSearch.d.ts +12 -0
  116. package/lib/types/tools/buildSearch.d.ts.map +1 -0
  117. package/lib/types/tools/query-dsl.d.ts +28 -0
  118. package/lib/types/tools/query-dsl.d.ts.map +1 -0
  119. package/lib/types/tools/query.d.ts +2 -0
  120. package/lib/types/tools/query.d.ts.map +1 -0
  121. package/lib/types/tools.d.ts +2 -2
  122. package/lib/types/tools.d.ts.map +1 -1
  123. package/lib/types/type-tests/query-infer.test-d.d.ts +2 -0
  124. package/lib/types/type-tests/query-infer.test-d.d.ts.map +1 -0
  125. package/lib/types/types.d.ts +47 -4
  126. package/lib/types/types.d.ts.map +1 -1
  127. package/package.json +7 -7
package/README.md CHANGED
@@ -37,6 +37,9 @@ import { Route, getResourcePage } from '@plumile/router';
37
37
  const routes: Route<any, any>[] = [
38
38
  {
39
39
  path: '/',
40
+
41
+ ## Advanced Hooks Deep Dive & Combined Examples
42
+
40
43
  resourcePage: getResourcePage('Home', () => import('./pages/Home')),
41
44
  },
42
45
  {
@@ -111,13 +114,15 @@ function Navigation() {
111
114
 
112
115
  ### Core Components
113
116
 
114
- #### `createRouter(routes: Route[])`
117
+ #### `createRouter(routes: Route[], options?)`
115
118
 
116
119
  Creates a router instance with the given route configuration.
117
120
 
118
121
  **Parameters:**
119
122
 
120
123
  - `routes`: Array of route definitions
124
+ - `options?`: Optional object
125
+ - `devtools?: boolean` Force enable/disable the global inspector. Defaults to enabled when `NODE_ENV !== 'production'`, disabled otherwise.
121
126
 
122
127
  **Returns:**
123
128
 
@@ -142,6 +147,9 @@ Navigation component that handles client-side routing.
142
147
 
143
148
  - `to`: string - Destination path
144
149
  - `exact?`: boolean - Exact path matching for active state
150
+
151
+ ### Combined Example: Products Listing Page
152
+
145
153
  - `activeClassName?`: string - CSS class when link is active
146
154
  - `className?`: string - Base CSS class
147
155
  - `preload?`: boolean - Preload route on hover
@@ -228,6 +236,669 @@ Type helper for strongly-typed route definitions.
228
236
 
229
237
  ## Advanced Usage
230
238
 
239
+ ### Typed Query Parameters (DSL)
240
+
241
+ The router provides a lightweight schema DSL for parsing, typing, normalizing and serializing query strings.
242
+
243
+ #### 1. Define a schema on the deepest route
244
+
245
+ ```ts
246
+ import { q, r } from '@plumile/router';
247
+
248
+ const routes = [
249
+ r({
250
+ path: '/items',
251
+ // Schema: page = number (default 1), tag(s) = array of strings, flag = optional boolean
252
+ query: {
253
+ page: q.default(q.number(), 1),
254
+ tags: q.array(q.string()),
255
+ flag: q.optional(q.boolean()),
256
+ },
257
+ prepare: ({ query }) => {
258
+ // query is typed: { page: number; tags: string[]; flag?: boolean }
259
+ return { page: query.page };
260
+ },
261
+ render: () => null,
262
+ }),
263
+ ];
264
+ ```
265
+
266
+ #### 2. Access parsed & typed queries
267
+
268
+ ```tsx
269
+ import { useQuery, useTypedQuery } from '@plumile/router';
270
+
271
+ function List() {
272
+ const raw = useQuery(); // Record<string, string | string[]>
273
+ const typed = useTypedQuery(); // Auto‑inferred from deepest route schema (no generic needed)
274
+ const [page, setPage] = useQueryState<number>('page'); // Bidirectional state ↔ URL for one param
275
+ return <pre>{JSON.stringify({ raw, typed }, null, 2)}</pre>;
276
+ }
277
+ ```
278
+
279
+ 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).
280
+
281
+ Type inference:
282
+
283
+ - If the matched deepest route has a `query` schema, `useTypedQuery()` returns the inferred `InferQuery<typeof schema>` shape automatically.
284
+ - If no schema exists, it returns the raw parsed object (record of string | string[]) so you can still read values safely.
285
+ - You can still supply a generic manually (`useTypedQuery<MyShape>()`) in edge cases (e.g. incremental migration) but it is usually unnecessary now.
286
+
287
+ #### 3. Programmatic navigation with typed query
288
+
289
+ ```tsx
290
+ import { useNavigate } from '@plumile/router';
291
+
292
+ function Pager({ page }: { page: number }) {
293
+ const navigate = useNavigate();
294
+ return (
295
+ <button
296
+ onClick={() => {
297
+ navigate({ query: { page: page + 1 } });
298
+ }}
299
+ >
300
+ Next page
301
+ </button>
302
+ );
303
+ }
304
+ ```
305
+
306
+ #### 4. Link component with query
307
+
308
+ ```tsx
309
+ import { Link } from '@plumile/router';
310
+
311
+ <Link to="/items" query={{ page: 2, tags: ['a', 'b'], flag: true }}>
312
+ Filter
313
+ </Link>;
314
+ ```
315
+
316
+ `Link` & `navigate` automatically serialize using the route schema, applying:
317
+
318
+ - Schema key order
319
+ - Array repetition (`?tags=a&tags=b`)
320
+ - Omission of default values when `omitDefaults` optimization applies internally
321
+
322
+ #### 5. Normalization
323
+
324
+ Built‑in simple normalization currently clamps `page < 1` to `1` and issues a `replaceState` to avoid polluting history.
325
+
326
+ #### 6. Serialization utility
327
+
328
+ ```ts
329
+ import { buildSearch, q } from '@plumile/router';
330
+
331
+ const schema = {
332
+ page: q.default(q.number(), 1),
333
+ tag: q.array(q.string()),
334
+ } as const;
335
+ const search = buildSearch({ page: 1, tag: ['x', 'y'] }, schema, {
336
+ omitDefaults: true,
337
+ });
338
+ // => '?tag=x&tag=y'
339
+ ```
340
+
341
+ #### 7. Parsing utility / alias
342
+
343
+ ```ts
344
+ import { parseSearch, q } from '@plumile/router';
345
+
346
+ const schema = { flag: q.boolean() } as const;
347
+ const typed = parseSearch(schema, '?flag=1'); // { flag: true }
348
+ ```
349
+
350
+ #### 8. Performance and stability
351
+
352
+ There are two coordinated caching layers:
353
+
354
+ 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.
355
+ 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.
356
+
357
+ Effects:
358
+
359
+ - Stable object identity for both raw and typed queries eliminates needless renders and removes the need for an extra `useStableQuery` hook.
360
+ - Safe to put `useQuery()` / `useTypedQuery()` results directly in dependency arrays (`useEffect`, `useMemo`, selectors, etc.).
361
+ - Empty query allocations are avoided (shared frozen object).
362
+
363
+ 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.
364
+
365
+ #### Devtools / Inspection
366
+
367
+ In development (`NODE_ENV !== 'production'` by default, or when `createRouter(..., { devtools: true })` is passed), the router exposes a lightweight global inspector.
368
+
369
+ Optionally you can enable an in‑page overlay panel for quick visual inspection:
370
+
371
+ ```ts
372
+ createRouter(routes, {
373
+ devtools: { panel: true, global: true, shortcut: 'Alt+Shift+R' },
374
+ });
375
+ ```
376
+
377
+ Devtools option forms:
378
+
379
+ - `devtools: true | false` (boolean) – legacy form, controls global only.
380
+ - `devtools: { global?: boolean; panel?: boolean; shortcut?: string }` – granular.
381
+ - `global` (default: NODE_ENV !== 'production') exposes `window.__PLUMILE_ROUTER__`.
382
+ - `panel` (default: false) mounts an overlay; closed with the close button or page reload.
383
+ - `shortcut` (default: `Alt+Shift+R`) toggles panel visibility.
384
+
385
+ 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.
386
+
387
+ ```js
388
+ window.__PLUMILE_ROUTER__.get(); // current RouteEntry
389
+ const unsub = window.__PLUMILE_ROUTER__.subscribe((entry) =>
390
+ console.log(entry.typedQuery),
391
+ );
392
+ ```
393
+
394
+ 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).
395
+
396
+ ##### Detailed Usage
397
+
398
+ The global is intentionally tiny to avoid coupling and bundle weight. It only appears when the devtools flag resolves truthy:
399
+
400
+ 1. Explicit: `createRouter(routes, { devtools: true })`
401
+ 2. Implicit heuristic: `NODE_ENV !== 'production'`
402
+ 3. Disabled explicitly: `createRouter(routes, { devtools: false })`
403
+
404
+ Always guard in snippets that might be copied to production code:
405
+
406
+ ```js
407
+ if (window.__PLUMILE_ROUTER__) {
408
+ console.log(window.__PLUMILE_ROUTER__.get());
409
+ }
410
+ ```
411
+
412
+ `get()` returns the current `RouteEntry` (simplified shape):
413
+
414
+ ```ts
415
+ type RouteEntry = {
416
+ location: Location; // window.location snapshot
417
+ route: { match; params; path } | null; // current matched route (or null)
418
+ preparedMatch: { routes: { prepared; render?; resourcePage? }[]; match }; // internal prepared tree
419
+ forceRerender: boolean; // indicates forced re-render situations
420
+ rawSearch: string; // '?page=2&tag=a'
421
+ query: Record<string, string | string[]>; // raw aggregated query params
422
+ typedQuery: any; // typed query if schema present
423
+ };
424
+ ```
425
+
426
+ Common console patterns:
427
+
428
+ 1. Log every navigation with typed query & params:
429
+
430
+ ```js
431
+ const dev = window.__PLUMILE_ROUTER__;
432
+ if (dev) {
433
+ const off = dev.subscribe((e) => {
434
+ console.log('[router]', e.location.pathname + e.location.search, {
435
+ vars: e.route?.params,
436
+ typed: e.typedQuery,
437
+ });
438
+ });
439
+ // later: off();
440
+ }
441
+ ```
442
+
443
+ 2. Inspect prepared data for the deepest route (last element):
444
+
445
+ ```js
446
+ const entry = window.__PLUMILE_ROUTER__?.get();
447
+ const deepest = entry?.preparedMatch.routes.at(-1);
448
+ deepest?.prepared; // Prepared data returned by deepest prepare()
449
+ ```
450
+
451
+ 3. Quick diff watcher for query changes only:
452
+
453
+ ```js
454
+ let last = window.__PLUMILE_ROUTER__?.get().rawSearch;
455
+ const off = window.__PLUMILE_ROUTER__?.subscribe((e) => {
456
+ if (e.rawSearch !== last) {
457
+ console.log('query changed', last, '=>', e.rawSearch, e.typedQuery);
458
+ last = e.rawSearch;
459
+ }
460
+ });
461
+ ```
462
+
463
+ 4. Measuring parse performance (rough dev-only micro‑benchmark):
464
+
465
+ ```js
466
+ const { get } = window.__PLUMILE_ROUTER__;
467
+ const before = performance.now();
468
+ for (let i = 0; i < 200; i++) get().typedQuery; // use cache; ensures no GC
469
+ console.log('elapsed ms', performance.now() - before);
470
+ ```
471
+
472
+ 5. Safe optional chaining helper (copy/paste):
473
+
474
+ ```js
475
+ const R = window.__PLUMILE_ROUTER__;
476
+ R && R.subscribe((e) => console.debug('[typedQuery]', e.typedQuery));
477
+ ```
478
+
479
+ 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.
480
+
481
+ 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.
482
+
483
+ 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.
484
+
485
+ ### ESLint Rule: no-direct-window-location-search
486
+
487
+ To encourage consistent usage of the query hooks, a custom rule is provided inside the router package to flag raw `window.location.search` access.
488
+
489
+ Add to your flat ESLint config:
490
+
491
+ ````js
492
+ import noDirectWindowLocationSearch from '@plumile/router/lib/eslint-rules/no-direct-window-location-search.js';
493
+
494
+ export default [
495
+ {
496
+
497
+ ## Batched Navigation & Provisional typedQuery Contract
498
+
499
+ The router supports two batching modes:
500
+
501
+ - Manual batching: pass `batch: true` in successive `navigate` calls within the same microtask.
502
+ - Auto batching: enable `createRouter(routes, { autoBatch: true })` and omit `immediate: true` to coalesce navigations scheduled in the same microtask.
503
+
504
+ During a batched sequence, the final history update (a single `push`/`set`) is deferred to a queued microtask. Some code (tests, imperative flows) may need to synchronously observe the merged query state before the flush occurs. To support this, the router performs a provisional in‑memory update of the current route entry on each batched `navigate`:
505
+
506
+ Guarantees (provisional phase):
507
+
508
+ 1. `context.get()` returns an entry whose `location.search`, `rawSearch`, `query` (raw parsed) and `typedQuery` reflect the merged state of all batched calls so far.
509
+ 2. Each additional batched call merges query keys (last write wins) and overwrites the entire filters object (they are considered atomic state) before recomputing `typedQuery`.
510
+ 3. The provisional `typedQuery` uses the deepest route schema of the eventual target pathname (if a batched navigation changes pathname, subsequent calls resolve the schema for that new path).
511
+ 4. Normalization (e.g. clamping `page < 1`) is only applied at the final flush listener step; the provisional `typedQuery` may briefly contain unnormalized values until flush.
512
+ 5. Subscribers (`context.subscribe`) are **not** notified until the actual history update fires (asynchronously). Reading inside the same tick requires using `context.get()` directly, not relying on subscription callbacks.
513
+
514
+ Non‑guarantees / caveats:
515
+
516
+ - If you read `window.location.search` directly (discouraged) during a batch it will still show the pre‑batch URL; use the entry returned by `context.get()`.
517
+ - If later batched calls change the pathname, earlier provisional `typedQuery` objects become stale; always re‑read after your final batched mutation if you need the merged result.
518
+ - Filters batching (from `useFilters` helpers) coalesces multiple `set/patch/clear` calls in the same microtask via an internal microtask queue. These feed into the outer navigation batching so a burst of filter helper calls plus other `navigate` calls still produce a single history entry.
519
+
520
+ Edge cases:
521
+
522
+ - Calling `navigate({ immediate: true })` inside an active batch forces an immediate flush, bypassing batching for that specific call.
523
+ - Interleaving manual `batch: true` and autoBatch calls: if autoBatch is enabled and you explicitly pass `batch: false`, that call flushes immediately; otherwise the presence of any manual `batch: true` call within the tick keeps coalescing active until flush.
524
+
525
+ Recommended usage pattern inside effects or event handlers:
526
+
527
+ ```ts
528
+ // Multiple logical updates unified into 1 URL change
529
+ context.navigate({ query: { page: 1 }, batch: true });
530
+ context.navigate({ filters: { status: { in: ['active'] } }, filterSchema, batch: true });
531
+ context.navigate({ query: { tag: 'green' }, batch: true });
532
+ // Synchronous inspection (provisional)
533
+ const merged = context.get().typedQuery; // already includes page=1, tag=green, status filter
534
+ ````
535
+
536
+ Testing note: tests asserting final URL should poll or await a microtask / tick rather than expecting synchronous `window.location.search` updates when batching is involved.
537
+
538
+ Future evolution: if normalization or additional query transformations grow more complex, the provisional recompute will remain a best‑effort mirror; code relying on _final_ normalized values should subscribe or await the flush boundary.
539
+
540
+ Summary: Provisional batching gives synchronous, allocation‑minimal read access to the in‑flight merged query & typedQuery without sacrificing a single history entry – use `context.get()` for immediate reads, and rely on subscriptions or hooks for React render updates.
541
+
542
+ ## Route-Level Filter Schemas
543
+
544
+ Attach a `filterSchema` to routes to progressively declare filterable fields along the hierarchy. Parent → child schemas are shallow merged; fields defined later override earlier ones by name.
545
+
546
+ Example:
547
+
548
+ ```ts
549
+ import { r, defineFilters, numberFilter, stringFilter } from '@plumile/router';
550
+
551
+ export const routes = [
552
+ r({
553
+ path: '/products',
554
+ filterSchema: defineFilters({
555
+ price: numberFilter({ operators: ['gte', 'lte'] }),
556
+ }),
557
+ children: [
558
+ r({
559
+ path: '/:id',
560
+ filterSchema: defineFilters({
561
+ name: stringFilter({ operators: ['contains'] }),
562
+ }),
563
+ }),
564
+ ],
565
+ }),
566
+ ];
567
+ ```
568
+
569
+ Merged result for `/products/123` is `{ price, name }` definitions. Currently, implicit serialization of new filter values still requires passing `{ filters, filterSchema }` in a navigate/helper call; the route-level merged schema is used for parsing existing keys. This keeps mutations explicit. A future enhancement may allow omitting `filterSchema` when adding new filter values on a route that already declares one.
570
+
571
+ Rules:
572
+
573
+ - Order: shallow override by field name.
574
+ - No deep merge inside a field definition.
575
+ - Omitted routes: ignored.
576
+
577
+ Limitations / current behavior:
578
+
579
+ - Initial merged schema is internal; helpers still need explicit schema to serialize new filter state.
580
+ - Empty filters object with route-level schema is treated as “no change” (no extra keys added).
581
+
582
+ Planned: implicit mode & devtools visualization of merged filter schema chain.
583
+ plugins: {
584
+ '@plumile-router/dx': {
585
+ rules: {
586
+ 'no-direct-window-location-search': noDirectWindowLocationSearch,
587
+ },
588
+ },
589
+ },
590
+ rules: {
591
+ '@plumile-router/dx/no-direct-window-location-search': 'warn',
592
+ },
593
+ },
594
+ ];
595
+
596
+ ````
597
+
598
+ Optional configuration:
599
+
600
+ ```js
601
+ // allow some files (e.g. legacy bootstrap) to keep direct access
602
+ rules: {
603
+ '@plumile-router/dx/no-direct-window-location-search': [
604
+ 'warn',
605
+ { allowInFiles: ['legacy-entry.ts'] },
606
+ ],
607
+ },
608
+ ````
609
+
610
+ When triggered, replace patterns like:
611
+
612
+ ```ts
613
+ const qs = window.location.search;
614
+ ```
615
+
616
+ with:
617
+
618
+ ```ts
619
+ const query = useQuery(); // or useTypedQuery()
620
+ ```
621
+
622
+ ### Migration Guide (Phases 1 → 5)
623
+
624
+ 1. Phase 1/2: Upgrade — no schema needed. Use `useQuery()` for raw params.
625
+ 2. Phase 3: Add `query` schema to your deepest route; adopt `useTypedQuery()` where type safety is desirable.
626
+ 3. Phase 4: Replace manual URL building with `navigate({ query })` or `<Link query={...} />`. Remove ad‑hoc serialization logic.
627
+ 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.
628
+ 5. Optional: Use `buildSearch` / `parseSearch` for unit tests & utilities.
629
+
630
+ ### Query Descriptor Reference
631
+
632
+ | Descriptor | Description | Serialize Example |
633
+ | -------------------------------- | --------------------------------------------------- | ------------------------------------ |
634
+ | `q.string()` | Last occurrence string | `{ q: 'x' } -> ?q=x` |
635
+ | `q.number()` | Number (invalid => undefined) | `{ n: 2 } -> ?n=2` |
636
+ | `q.boolean()` | Presence / true/false/1/0 | `{ f: true } -> ?f=1` |
637
+ | `q.enum('a','b')` | Restricted string | `{ e: 'a' } -> ?e=a` |
638
+ | `q.array(inner)` | Repeated key multi-values | `{ tag: ['x','y'] } -> ?tag=x&tag=y` |
639
+ | `q.optional(d)` | Marks descriptor optional | omitted if undefined |
640
+ | `q.default(d, v)` | Supplies default + omit on serialize (omitDefaults) | default skipped |
641
+ | `q.emptyAsUndefined(q.string())` | Maps empty string '' to undefined | omitted |
642
+ | `q.custom({ parse, serialize })` | Custom parse/serialize logic | depends |
643
+
644
+ 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.
645
+
646
+ ### useQueryState Hook
647
+
648
+ `useQueryState(key, opts?)` creates a controlled binding between a single query parameter and component state.
649
+
650
+ ```tsx
651
+ const [page, setPage] = useQueryState<number>('page');
652
+ // Increment page without pushing a new history entry
653
+ setPage(page! + 1, { replace: true });
654
+ ```
655
+
656
+ Behavior:
657
+
658
+ - Reads from typedQuery if schema present, else raw query.
659
+ - Respects schema defaults (and `defaultValue` override in options) and omits key when value equals default (with `omitIfDefault: true`).
660
+ - Pass `{ raw: true }` to force raw (string) source for incremental migrations.
661
+ - Uses existing navigation serialization (ordering, omit defaults, arrays).
662
+
663
+ Options:
664
+ `{ defaultValue?, omitIfDefault?: boolean = true, replace?: boolean, raw?: boolean }`
665
+
666
+ ### Filters DSL (Experimental Advanced Filtering)
667
+
668
+ Structured multi‑operator filtering can be expressed via query keys using the pattern `field.operator`.
669
+
670
+ 1. Define a schema
671
+
672
+ ```ts
673
+ import { defineFilters, numberFilter, stringFilter } from '@plumile/router';
674
+
675
+ export const filters = defineFilters({
676
+ price: numberFilter(), // numeric: eq, neq, gt, gte, lt, lte, in, nin, between, exists
677
+ status: stringFilter({ operators: ['eq', 'in', 'nin'] }),
678
+ name: stringFilter({ operators: ['contains', 'starts', 'ends'] }),
679
+ // Provide default operator values to omit them from serialization when matched
680
+ availability: numberFilter({
681
+ operators: ['exists'],
682
+ defaults: { exists: true },
683
+ }),
684
+ priceRange: numberFilter({
685
+ operators: ['between'],
686
+ defaults: { between: [[0, 100]] },
687
+ }),
688
+ });
689
+ ```
690
+
691
+ 2. Read & update filters
692
+
693
+ ```tsx
694
+ import { useFilters } from '@plumile/router';
695
+
696
+ function Products() {
697
+ const [f, { patch, clear }] = useFilters(filters);
698
+ // f.price.gt => number[] | undefined
699
+ // f.status.in => string[] | undefined
700
+ return (
701
+ <button
702
+ onClick={() => {
703
+ patch({ price: { gt: [10] } });
704
+ }}
705
+ >
706
+ Price &gt; 10
707
+ </button>
708
+ );
709
+ }
710
+ ```
711
+
712
+ 3. Narrow to a single operator
713
+
714
+ ```tsx
715
+ import { useFilterState } from '@plumile/router';
716
+
717
+ function PriceGt() {
718
+ const [gt, setGt] = useFilterState(filters, 'price', 'gt');
719
+ return (
720
+ <input
721
+ value={gt?.[0] ?? ''}
722
+ onChange={(e) => setGt([Number(e.target.value)])}
723
+ />
724
+ );
725
+ }
726
+ ```
727
+
728
+ 4. Supported operators
729
+
730
+ | Kind | Operators |
731
+ | ------ | ------------------------------------------------------------------------------------ |
732
+ | number | eq, neq, gt, gte, lt, lte, in, nin, between (tuples), exists |
733
+ | string | eq, neq, in, nin, between (tuples of strings), exists, contains, starts, ends, regex |
734
+
735
+ `exists` expects a boolean; presence of key defaults to `true` if value unrecognized.
736
+
737
+ 5. Query string format
738
+
739
+ ```
740
+ ?price.gt=10&price.between=10&price.between=20&price.between=30&price.between=40&status.in=active&status.in=pending
741
+ ```
742
+
743
+ 6. Between operator
744
+
745
+ Pairs of values create ranges: four values → two ranges. Odd final value is ignored.
746
+
747
+ 7. Clearing
748
+
749
+ `clear()` → remove all filters. `clear(['price'])` → remove only price.\* keys.
750
+
751
+ 8. Patching
752
+
753
+ `patch({ price: { gt: [20], between: [[10,20]] } })` merges at field level, replacing individual operator arrays.
754
+
755
+ 9. Serialization rules
756
+
757
+ - Previous filter keys (`field.op`) are dropped before adding new ones.
758
+ - Multiple values produce repeated keys (same as existing query arrays).
759
+ - Boolean `exists` serialized as `1` / `0`.
760
+ - Optional omission of default operator values: if a filter definition supplies a `defaults` object
761
+ (e.g. `{ defaults: { gt: [10] } }` or `{ defaults: { exists: true } }`), and the current operator
762
+ value is shallow‑equal to that default, the `field.operator` key is skipped during serialization.
763
+ Applies to array operators (exact ordered match) and `exists` boolean. `between` defaults compare
764
+ tuple pairs (`[[a,b]]`) element-wise.
765
+
766
+ 10. Type inference
767
+
768
+ `InferFilters<typeof filters.schema>` gives `{ price: { gt?: number[]; between?: [number, number][]; ... } }`.
769
+
770
+ 11. Opt‑in / Backward Compatible
771
+
772
+ No change to existing query parameter behavior. Filter keys are just additional query entries; legacy code reading raw `useQuery()` continues to function.
773
+
774
+ 12. Limitations / Future
775
+
776
+ - Sorting deliberately excluded (would be separate DSL).
777
+ - No date operator specialization yet.
778
+ - Regex values are passed through verbatim (URL decoding handled upstream).
779
+ - Default omission currently uses shallow equality (ordered array match). For number arrays
780
+ canonicalization (sorting + optional dedupe) happens prior to comparison, ensuring stable omission.
781
+
782
+ Tests cover parsing (including multiple ranges) and building; extend as needed for your domain.
783
+
784
+ ### New Hooks (Phase 4)
785
+
786
+ #### `useQueryObject(opts?)`
787
+
788
+ High-level accessor returning the entire current query object (typed when the matched deepest route declares a `query` schema). It also returns a stable setter that merges partial updates.
789
+
790
+ ```tsx
791
+ import { useQueryObject } from '@plumile/router';
792
+
793
+ function Panel() {
794
+ const [query, setQuery] = useQueryObject({ omitDefaults: true });
795
+ // query: typed (if schema) else raw record
796
+ return (
797
+ <button
798
+ onClick={() => {
799
+ setQuery({ page: (query.page ?? 1) + 1 });
800
+ }}
801
+ >
802
+ Next page
803
+ </button>
804
+ );
805
+ }
806
+ ```
807
+
808
+ Options:
809
+
810
+ - `raw?: boolean` Use aggregated raw query even if a schema exists.
811
+ - `replace?: boolean` Default navigation mode for all subsequent `setQuery` calls.
812
+ - `omitDefaults?: boolean` When true, schema keys whose value equals their declared default are omitted from the URL.
813
+
814
+ Setter semantics:
815
+
816
+ - Accepts an object or function `(prev) => next`.
817
+ - Keys set to `undefined` are removed from the URL.
818
+ - Partial objects are shallow‑merged with previous query state (post-merge omission rules are applied if `omitDefaults` is enabled).
819
+
820
+ #### `useActiveFilters(filterSchema)`
821
+
822
+ Returns a flat array of currently active filter operator entries derived from the structured filters state returned by `useFilters`.
823
+
824
+ ```tsx
825
+ import { useActiveFilters } from '@plumile/router';
826
+ import { filters } from './filters-schema';
827
+
828
+ function ActiveChips() {
829
+ const active = useActiveFilters(filters);
830
+ return (
831
+ <ul>
832
+ {active.map((f) => (
833
+ <li key={f.field + f.operator}>
834
+ {f.field}.{f.operator}: {f.values.join(', ')}
835
+ </li>
836
+ ))}
837
+ </ul>
838
+ );
839
+ }
840
+ ```
841
+
842
+ Each entry: `{ field: string; operator: string; values: any[] }` where `values` is the already normalized array (for non‑array primitives a singleton array is produced). Empty arrays / undefined operators are skipped.
843
+
844
+ #### `useNavigateWithQuery(opts?)`
845
+
846
+ Convenience wrapper combining current query (typed if possible) with partial updates before invoking `navigate`.
847
+
848
+ ```tsx
849
+ import { useNavigateWithQuery } from '@plumile/router';
850
+
851
+ function Incrementer() {
852
+ const update = useNavigateWithQuery({ omitDefaults: true });
853
+ return (
854
+ <button
855
+ onClick={() => {
856
+ update((prev) => ({ page: (prev.page ?? 1) + 1 }));
857
+ }}
858
+ >
859
+ Next
860
+ </button>
861
+ );
862
+ }
863
+ ```
864
+
865
+ Behavior:
866
+
867
+ - Reads existing (typed or raw) query as base.
868
+ - Accepts object or functional update.
869
+ - Keys set to `undefined` are removed.
870
+ - `omitDefaults` removes schema-default valued keys.
871
+ - `raw` forces ignoring the schema (raw aggregation base).
872
+ - `replace` option (global or per call) controls history mode.
873
+
874
+ #### `useStableRefEquality(value, areEqual?)`
875
+
876
+ Utility hook that preserves the reference of the latest value (object or array) as long as shallow equality holds. When a new value is passed whose own enumerable keys (and their primitive or reference-equal values) match the previous one, the previous stable reference is returned. If they differ, the new value becomes the stored reference.
877
+
878
+ Designed for niche interop cases (e.g. passing derived objects to dependency arrays of third-party hooks) where you can't or don't want the upstream producer to handle memoization. Most consumers of the router do NOT need this because `useQuery`, `useTypedQuery`, `useFilters` already return stable references.
879
+
880
+ ```ts
881
+ import { useStableRefEquality } from '@plumile/router';
882
+
883
+ function Chart({ data }) {
884
+ // Avoid re-renders in a heavy child when parent recreates equivalent arrays
885
+ const stableData = useStableRefEquality(data);
886
+ return <ExpensiveChart data={stableData} />;
887
+ }
888
+ ```
889
+
890
+ Custom comparator:
891
+
892
+ ```ts
893
+ const value = useStableRefEquality(complex, (a, b) => deepCustomCompare(a, b));
894
+ ```
895
+
896
+ Notes:
897
+
898
+ - Only shallow by default (no deep traversal) for perf.
899
+ - Non-object/array inputs are returned as-is (no caching layer needed).
900
+ - Avoid overuse; prefer structuring code so sources themselves are stable.
901
+
231
902
  ### Data Preloading
232
903
 
233
904
  ```typescript
@@ -275,6 +946,89 @@ function MyComponent() {
275
946
  }
276
947
  ```
277
948
 
949
+ ### Navigation With Filters & Batching (Phase 5)
950
+
951
+ You can atomically update query parameters and structured filters via the extended `navigate` API and related hooks.
952
+
953
+ ```tsx
954
+ import {
955
+ useNavigate,
956
+ useFilters,
957
+ defineFilters,
958
+ numberFilter,
959
+ } from '@plumile/router';
960
+
961
+ const filterSchema = defineFilters({ price: numberFilter() });
962
+
963
+ function PriceBump() {
964
+ const navigate = useNavigate();
965
+ const [filters, { patch }] = useFilters(filterSchema);
966
+ return (
967
+ <button
968
+ onClick={() => {
969
+ // Update both query & filters in a single URL change
970
+ navigate({
971
+ query: { page: 1 },
972
+ filters: { price: { gt: [10] } },
973
+ filterSchema,
974
+ });
975
+ }}
976
+ >
977
+ Apply
978
+ </button>
979
+ );
980
+ }
981
+ ```
982
+
983
+ Batch multiple navigations issued during the same micro‑task into _one_ history update by passing `batch: true`:
984
+
985
+ ```tsx
986
+ function ComplexMutation() {
987
+ const navigate = useNavigate();
988
+ return (
989
+ <button
990
+ onClick={() => {
991
+ navigate({ query: { page: 1 }, batch: true });
992
+ navigate({
993
+ filters: { price: { gt: [20] } },
994
+ filterSchema,
995
+ batch: true,
996
+ });
997
+ navigate({
998
+ filters: { price: {} /* clear price */ },
999
+ filterSchema,
1000
+ batch: true,
1001
+ });
1002
+ // All three coalesce into one push
1003
+ }}
1004
+ >
1005
+ Run sequence
1006
+ </button>
1007
+ );
1008
+ }
1009
+ ```
1010
+
1011
+ Hooks `useQueryObject` and `useFilters` also accept a `batch` flag in their nav options:
1012
+
1013
+ ```tsx
1014
+ const [query, setQuery] = useQueryObject();
1015
+ const [f, { patch, clear }] = useFilters(filterSchema);
1016
+
1017
+ // Coalesce three operations
1018
+ setQuery({ page: 2 }, { batch: true });
1019
+ patch({ price: { gt: [30] } }, { batch: true });
1020
+ clear(['price'], { batch: true });
1021
+ ```
1022
+
1023
+ Notes:
1024
+
1025
+ - Batching is opt‑in: default behavior is immediate navigation (backward compatible).
1026
+ - The last non‑undefined `replace` flag wins inside a batch group.
1027
+ - The last provided `query` object and `filters` snapshot in the batch are used for serialization.
1028
+ - Provide `filterSchema` whenever you include `filters`.
1029
+
1030
+ `buildSearch(query, schema, { filters, filterSchema })` merges filters deterministically with query keys (schema‑ordered keys first, then extras).
1031
+
278
1032
  ### Route Preloading
279
1033
 
280
1034
  ```tsx