@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.
- package/LICENSE +1 -1
- package/README.md +682 -1
- package/lib/esm/ResourcePage.d.ts.map +1 -1
- package/lib/esm/ResourcePage.js +1 -1
- package/lib/esm/builder.d.ts.map +1 -1
- package/lib/esm/builder.js +10 -3
- package/lib/esm/errors/HttpRedirect.d.ts.map +1 -1
- package/lib/esm/errors/HttpRedirect.js +1 -1
- package/lib/esm/errors/index.d.ts.map +1 -1
- package/lib/esm/errors/index.js +1 -1
- package/lib/esm/eslint-rules/index.d.ts +2 -0
- package/lib/esm/eslint-rules/index.d.ts.map +1 -0
- package/lib/esm/eslint-rules/index.js +2 -0
- package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts +4 -0
- package/lib/esm/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
- package/lib/esm/eslint-rules/no-direct-window-location-search.js +48 -0
- package/lib/esm/history/BrowserHistory.d.ts.map +1 -1
- package/lib/esm/history/BrowserHistory.js +4 -2
- package/lib/esm/history/index.d.ts.map +1 -1
- package/lib/esm/history/index.js +1 -1
- package/lib/esm/history/types.d.ts.map +1 -1
- package/lib/esm/history/types.js +1 -1
- package/lib/esm/index.d.ts +1 -0
- package/lib/esm/index.d.ts.map +1 -1
- package/lib/esm/index.js +2 -1
- package/lib/esm/routing/Link.d.ts +1 -0
- package/lib/esm/routing/Link.d.ts.map +1 -1
- package/lib/esm/routing/Link.js +35 -4
- package/lib/esm/routing/RouteComponentWrapper.d.ts.map +1 -1
- package/lib/esm/routing/RouteComponentWrapper.js +7 -2
- package/lib/esm/routing/RouterRenderer.d.ts.map +1 -1
- package/lib/esm/routing/RouterRenderer.js +1 -1
- package/lib/esm/routing/createRouter.d.ts +7 -1
- package/lib/esm/routing/createRouter.d.ts.map +1 -1
- package/lib/esm/routing/createRouter.js +494 -11
- package/lib/esm/routing/index.d.ts +4 -0
- package/lib/esm/routing/index.d.ts.map +1 -1
- package/lib/esm/routing/index.js +5 -1
- package/lib/esm/routing/useNavigate.d.ts +6 -0
- package/lib/esm/routing/useNavigate.d.ts.map +1 -0
- package/lib/esm/routing/useNavigate.js +11 -0
- package/lib/esm/routing/useQuery.d.ts +2 -0
- package/lib/esm/routing/useQuery.d.ts.map +1 -0
- package/lib/esm/routing/useQuery.js +9 -0
- package/lib/esm/routing/useQueryState.d.ts +13 -0
- package/lib/esm/routing/useQueryState.d.ts.map +1 -0
- package/lib/esm/routing/useQueryState.js +80 -0
- package/lib/esm/routing/useTypedQuery.d.ts +2 -0
- package/lib/esm/routing/useTypedQuery.d.ts.map +1 -0
- package/lib/esm/routing/useTypedQuery.js +36 -0
- package/lib/esm/tools/buildSearch.d.ts +6 -0
- package/lib/esm/tools/buildSearch.d.ts.map +1 -0
- package/lib/esm/tools/buildSearch.js +60 -0
- package/lib/esm/tools/index.d.ts.map +1 -1
- package/lib/esm/tools/index.js +1 -1
- package/lib/esm/tools/query-dsl.d.ts +28 -0
- package/lib/esm/tools/query-dsl.d.ts.map +1 -0
- package/lib/esm/tools/query-dsl.js +250 -0
- package/lib/esm/tools/query.d.ts +2 -0
- package/lib/esm/tools/query.d.ts.map +1 -0
- package/lib/esm/tools/query.js +43 -0
- package/lib/esm/tools.d.ts +2 -2
- package/lib/esm/tools.d.ts.map +1 -1
- package/lib/esm/tools.js +3 -2
- package/lib/esm/type-tests/query-infer.test-d.d.ts +2 -0
- package/lib/esm/type-tests/query-infer.test-d.d.ts.map +1 -0
- package/lib/esm/type-tests/query-infer.test-d.js +49 -0
- package/lib/esm/types.d.ts +28 -4
- package/lib/esm/types.d.ts.map +1 -1
- package/lib/esm/types.js +1 -1
- package/lib/tsconfig.esm.tsbuildinfo +1 -1
- package/lib/types/ResourcePage.d.ts.map +1 -1
- package/lib/types/builder.d.ts.map +1 -1
- package/lib/types/errors/HttpRedirect.d.ts.map +1 -1
- package/lib/types/errors/index.d.ts.map +1 -1
- package/lib/types/eslint-rules/index.d.ts +2 -0
- package/lib/types/eslint-rules/index.d.ts.map +1 -0
- package/lib/types/eslint-rules/no-direct-window-location-search.d.ts +4 -0
- package/lib/types/eslint-rules/no-direct-window-location-search.d.ts.map +1 -0
- package/lib/types/history/BrowserHistory.d.ts.map +1 -1
- package/lib/types/history/index.d.ts.map +1 -1
- package/lib/types/history/types.d.ts.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/routing/Link.d.ts +1 -0
- package/lib/types/routing/Link.d.ts.map +1 -1
- package/lib/types/routing/RouteComponentWrapper.d.ts.map +1 -1
- package/lib/types/routing/RouterRenderer.d.ts.map +1 -1
- package/lib/types/routing/createRouter.d.ts +7 -1
- package/lib/types/routing/createRouter.d.ts.map +1 -1
- package/lib/types/routing/index.d.ts +4 -0
- package/lib/types/routing/index.d.ts.map +1 -1
- package/lib/types/routing/useNavigate.d.ts +6 -0
- package/lib/types/routing/useNavigate.d.ts.map +1 -0
- package/lib/types/routing/useQuery.d.ts +2 -0
- package/lib/types/routing/useQuery.d.ts.map +1 -0
- package/lib/types/routing/useQueryState.d.ts +13 -0
- package/lib/types/routing/useQueryState.d.ts.map +1 -0
- package/lib/types/routing/useTypedQuery.d.ts +2 -0
- package/lib/types/routing/useTypedQuery.d.ts.map +1 -0
- package/lib/types/tools/buildSearch.d.ts +6 -0
- package/lib/types/tools/buildSearch.d.ts.map +1 -0
- package/lib/types/tools/index.d.ts.map +1 -1
- package/lib/types/tools/query-dsl.d.ts +28 -0
- package/lib/types/tools/query-dsl.d.ts.map +1 -0
- package/lib/types/tools/query.d.ts +2 -0
- package/lib/types/tools/query.d.ts.map +1 -0
- package/lib/types/tools.d.ts +2 -2
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/type-tests/query-infer.test-d.d.ts +2 -0
- package/lib/types/type-tests/query-infer.test-d.d.ts.map +1 -0
- package/lib/types/types.d.ts +28 -4
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +12 -12
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) Plumile
|
|
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
|
-
#
|
|
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;
|
|
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"}
|