@async/framework 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +65 -18
- package/examples/router/index.html +2 -5
- package/examples/router/main.js +1 -1
- package/package.json +1 -1
- package/src/router.js +104 -45
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -375,34 +375,81 @@ const partials = createPartialRegistry({
|
|
|
375
375
|
});
|
|
376
376
|
```
|
|
377
377
|
|
|
378
|
-
The router swaps route partials into a boundary
|
|
378
|
+
The router swaps route partials into a boundary. `csr` starts from an empty
|
|
379
|
+
route boundary, renders the current route partial locally, then keeps future
|
|
380
|
+
navigation local too:
|
|
379
381
|
|
|
380
382
|
```js
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
Async.use({
|
|
384
|
+
partial: {
|
|
385
|
+
home() {
|
|
386
|
+
return html`<h1>Home</h1>`;
|
|
387
|
+
},
|
|
388
|
+
"product.page"({ id }) {
|
|
389
|
+
return html`<h1>Product ${id}</h1>`;
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
route: {
|
|
386
393
|
"/": defineRoute("home"),
|
|
387
394
|
"/products/:id": defineRoute("product.page")
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
Async.start({
|
|
399
|
+
mode: "csr",
|
|
400
|
+
boundary: "route",
|
|
401
|
+
root: document
|
|
402
|
+
});
|
|
394
403
|
```
|
|
395
404
|
|
|
396
405
|
`route(...)` remains a compatibility alias for `defineRoute(...)`.
|
|
397
406
|
|
|
398
407
|
Router modes:
|
|
399
408
|
|
|
400
|
-
| Mode |
|
|
409
|
+
| Mode | Initial route | Later navigation |
|
|
401
410
|
| --- | --- |
|
|
402
|
-
| `
|
|
403
|
-
| `
|
|
404
|
-
| `
|
|
405
|
-
| `ssr` |
|
|
411
|
+
| `csr` | Client renders local partial into boundary | Client renders local partial and swaps |
|
|
412
|
+
| `spa` | Existing HTML may already contain route | Client renders local partial and swaps |
|
|
413
|
+
| `ssr` | Server rendered document | Browser navigates normally |
|
|
414
|
+
| `ssr-spa` | Server rendered document/route boundary | Fetch route partial, apply effects, swap |
|
|
415
|
+
| `mpa` | Any document source | Browser navigates normally |
|
|
416
|
+
|
|
417
|
+
CSR startup can use an empty route boundary:
|
|
418
|
+
|
|
419
|
+
```html
|
|
420
|
+
<main data-async-container>
|
|
421
|
+
<nav>
|
|
422
|
+
<a href="/">Home</a>
|
|
423
|
+
<a href="/products/sku-1">Product</a>
|
|
424
|
+
</nav>
|
|
425
|
+
|
|
426
|
+
<section data-async-boundary="route"></section>
|
|
427
|
+
</main>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Router state lives under `router.*` signals:
|
|
431
|
+
|
|
432
|
+
```txt
|
|
433
|
+
router.url
|
|
434
|
+
router.path
|
|
435
|
+
router.params
|
|
436
|
+
router.query
|
|
437
|
+
router.route
|
|
438
|
+
router.pending
|
|
439
|
+
router.error
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Register a wildcard route for an explicit fallback page:
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
Async.use({
|
|
446
|
+
route: {
|
|
447
|
+
"/": defineRoute("home.page"),
|
|
448
|
+
"/products/:id": defineRoute("product.page"),
|
|
449
|
+
"*": defineRoute("notFound.page")
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
```
|
|
406
453
|
|
|
407
454
|
### Cache
|
|
408
455
|
|
|
@@ -569,7 +616,7 @@ rescans the inserted fragment.
|
|
|
569
616
|
| [`examples/components`](./examples/components) | Scoped fragment components and lifecycle hooks |
|
|
570
617
|
| [`examples/streaming`](./examples/streaming) | Boundary swaps with rescanned handlers |
|
|
571
618
|
| [`examples/server-call`](./examples/server-call) | Command events calling server functions |
|
|
572
|
-
| [`examples/router`](./examples/router) |
|
|
619
|
+
| [`examples/router`](./examples/router) | CSR first render and local route boundary swaps |
|
|
573
620
|
| [`examples/partials`](./examples/partials) | Server-rendered partial fragments |
|
|
574
621
|
| [`examples/cache`](./examples/cache) | Browser/server cache declarations |
|
|
575
622
|
| [`examples/ssr`](./examples/ssr) | Server render output and browser activation snapshot |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>AsyncLoader Router</title>
|
|
6
|
+
<title>AsyncLoader CSR Router</title>
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
9
|
<main data-async-container>
|
|
@@ -11,10 +11,7 @@
|
|
|
11
11
|
<a href="/">Home</a>
|
|
12
12
|
<a href="/products/sku-1">Product</a>
|
|
13
13
|
</nav>
|
|
14
|
-
<section data-async-boundary="route">
|
|
15
|
-
<h1>Home</h1>
|
|
16
|
-
<p>Cart: <strong data-async-text="routerDemo.cartCount"></strong></p>
|
|
17
|
-
</section>
|
|
14
|
+
<section data-async-boundary="route"></section>
|
|
18
15
|
</main>
|
|
19
16
|
<script type="module" src="./main.js"></script>
|
|
20
17
|
</body>
|
package/examples/router/main.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@async/framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "No-build AsyncLoader app runtime with signals, command events, server calls, route partials, cache split, SSR activation, and streaming boundaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.1.0",
|
package/src/router.js
CHANGED
|
@@ -63,7 +63,7 @@ export function createRouteRegistry(initialMap = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function createRouter({
|
|
66
|
-
mode = "spa",
|
|
66
|
+
mode = "ssr-spa",
|
|
67
67
|
root,
|
|
68
68
|
boundary = "route",
|
|
69
69
|
routes = createRouteRegistry(),
|
|
@@ -89,6 +89,7 @@ export function createRouter({
|
|
|
89
89
|
server,
|
|
90
90
|
cache
|
|
91
91
|
});
|
|
92
|
+
const ownsLoader = !loader;
|
|
92
93
|
const cleanups = new Set();
|
|
93
94
|
let destroyed = false;
|
|
94
95
|
|
|
@@ -108,10 +109,23 @@ export function createRouter({
|
|
|
108
109
|
assertActive();
|
|
109
110
|
loaderInstance.router = api;
|
|
110
111
|
signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
bindNavigation();
|
|
112
|
+
if (ownsLoader) {
|
|
113
|
+
loaderInstance.start();
|
|
114
114
|
}
|
|
115
|
+
if (mode === "mpa" || mode === "ssr") {
|
|
116
|
+
updateStateFromLocation();
|
|
117
|
+
return api;
|
|
118
|
+
}
|
|
119
|
+
bindNavigation();
|
|
120
|
+
if (mode === "csr") {
|
|
121
|
+
void api.navigate(currentUrl(), {
|
|
122
|
+
replace: true,
|
|
123
|
+
initial: true,
|
|
124
|
+
source: "client"
|
|
125
|
+
}).catch(() => {});
|
|
126
|
+
return api;
|
|
127
|
+
}
|
|
128
|
+
updateStateFromLocation();
|
|
115
129
|
return api;
|
|
116
130
|
},
|
|
117
131
|
|
|
@@ -121,6 +135,9 @@ export function createRouter({
|
|
|
121
135
|
|
|
122
136
|
prefetch(url) {
|
|
123
137
|
assertActive();
|
|
138
|
+
if (mode === "ssr-spa" && typeof fetchImpl === "function") {
|
|
139
|
+
return fetchRoute(url, { prefetch: true });
|
|
140
|
+
}
|
|
124
141
|
const matched = api.match(url);
|
|
125
142
|
if (matched?.route?.partial && partials?.resolve?.(matched.route.partial)) {
|
|
126
143
|
return partials.render(matched.route.partial, matched.params, contextFor(matched));
|
|
@@ -133,43 +150,16 @@ export function createRouter({
|
|
|
133
150
|
|
|
134
151
|
async navigate(url, options = {}) {
|
|
135
152
|
assertActive();
|
|
136
|
-
if (mode === "mpa") {
|
|
153
|
+
if (mode === "mpa" || mode === "ssr") {
|
|
137
154
|
documentRef.defaultView?.location?.assign?.(url);
|
|
138
155
|
return null;
|
|
139
156
|
}
|
|
140
157
|
|
|
141
158
|
const target = toUrl(url);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
url: target.href,
|
|
145
|
-
path: target.pathname,
|
|
146
|
-
query: queryObject(target),
|
|
147
|
-
params: matched?.params ?? {},
|
|
148
|
-
route: matched?.pattern ?? null,
|
|
149
|
-
pending: true,
|
|
150
|
-
error: null
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const result = await resolveNavigation(target, matched);
|
|
155
|
-
await applyServerResult(result, {
|
|
156
|
-
signals: signalRegistry,
|
|
157
|
-
loader: loaderInstance,
|
|
158
|
-
router: api,
|
|
159
|
-
cache
|
|
160
|
-
});
|
|
161
|
-
if (result?.html != null && !result.boundary && !result.redirect) {
|
|
162
|
-
loaderInstance.swap(boundary, result.html);
|
|
163
|
-
}
|
|
164
|
-
if (options.history !== false) {
|
|
165
|
-
documentRef.defaultView?.history?.pushState?.({}, "", target.href);
|
|
166
|
-
}
|
|
167
|
-
setRouterState({ pending: false, error: null });
|
|
168
|
-
return result;
|
|
169
|
-
} catch (error) {
|
|
170
|
-
setRouterState({ pending: false, error });
|
|
171
|
-
throw error;
|
|
159
|
+
if (mode === "ssr-spa") {
|
|
160
|
+
return fetchRoutePartial(target, options);
|
|
172
161
|
}
|
|
162
|
+
return renderLocalRoutePartial(target, options);
|
|
173
163
|
},
|
|
174
164
|
|
|
175
165
|
destroy() {
|
|
@@ -213,11 +203,65 @@ export function createRouter({
|
|
|
213
203
|
cleanups.add(() => documentRef.defaultView?.removeEventListener?.("popstate", popstate));
|
|
214
204
|
}
|
|
215
205
|
|
|
216
|
-
async function
|
|
217
|
-
|
|
218
|
-
|
|
206
|
+
async function renderLocalRoutePartial(target, options = {}) {
|
|
207
|
+
const matched = api.match(target);
|
|
208
|
+
if (!matched) {
|
|
209
|
+
setNoRouteError(target);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setMatchedRouterState(target, matched, { pending: true, error: null });
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
if (!matched.route?.partial || !partials?.resolve?.(matched.route.partial)) {
|
|
217
|
+
const error = new Error(`Route "${target.pathname}" does not have a registered partial.`);
|
|
218
|
+
setRouterState({ pending: false, error });
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = await partials.render(matched.route.partial, matched.params, contextFor(matched));
|
|
223
|
+
await applyNavigationResult(result, target, options);
|
|
224
|
+
setRouterState({ pending: false, error: null });
|
|
225
|
+
return result;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
setRouterState({ pending: false, error });
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchRoutePartial(target, options = {}) {
|
|
233
|
+
const matched = api.match(target);
|
|
234
|
+
setMatchedRouterState(target, matched, { pending: true, error: null });
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const result = await fetchRoute(target.href);
|
|
238
|
+
await applyNavigationResult(result, target, options);
|
|
239
|
+
setRouterState({ pending: false, error: null });
|
|
240
|
+
return result;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
setRouterState({ pending: false, error });
|
|
243
|
+
throw error;
|
|
219
244
|
}
|
|
220
|
-
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function applyNavigationResult(result, target, options) {
|
|
248
|
+
await applyServerResult(result, {
|
|
249
|
+
signals: signalRegistry,
|
|
250
|
+
loader: loaderInstance,
|
|
251
|
+
router: api,
|
|
252
|
+
cache
|
|
253
|
+
});
|
|
254
|
+
if (result?.html != null && !result.boundary && !result.redirect) {
|
|
255
|
+
loaderInstance.swap(boundary, result.html);
|
|
256
|
+
}
|
|
257
|
+
if (result?.redirect || options.history === false) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (options.replace) {
|
|
261
|
+
documentRef.defaultView?.history?.replaceState?.({}, "", target.href);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
documentRef.defaultView?.history?.pushState?.({}, "", target.href);
|
|
221
265
|
}
|
|
222
266
|
|
|
223
267
|
async function fetchRoute(url, { prefetch = false } = {}) {
|
|
@@ -256,17 +300,29 @@ export function createRouter({
|
|
|
256
300
|
};
|
|
257
301
|
}
|
|
258
302
|
|
|
259
|
-
function
|
|
260
|
-
|
|
303
|
+
function updateStateFromLocation() {
|
|
304
|
+
const url = currentUrl();
|
|
261
305
|
const matched = api.match(url);
|
|
306
|
+
setMatchedRouterState(url, matched, { pending: false, error: null });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function setMatchedRouterState(url, matched, patch = {}) {
|
|
310
|
+
signalRegistry.ensure("router", {});
|
|
262
311
|
setRouterState({
|
|
263
312
|
url: url.href,
|
|
264
313
|
path: url.pathname,
|
|
265
314
|
query: queryObject(url),
|
|
266
315
|
params: matched?.params ?? {},
|
|
267
|
-
route: matched?.
|
|
316
|
+
route: matched?.route ?? null,
|
|
317
|
+
...patch
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function setNoRouteError(url) {
|
|
322
|
+
const error = new Error(`No route matched ${url.pathname}${url.search}`);
|
|
323
|
+
setMatchedRouterState(url, null, {
|
|
268
324
|
pending: false,
|
|
269
|
-
error
|
|
325
|
+
error
|
|
270
326
|
});
|
|
271
327
|
}
|
|
272
328
|
|
|
@@ -301,6 +357,9 @@ function normalizeRoute(pattern, definition) {
|
|
|
301
357
|
|
|
302
358
|
function compilePattern(pattern) {
|
|
303
359
|
const keys = [];
|
|
360
|
+
if (pattern === "*") {
|
|
361
|
+
return { regex: /^.*$/, keys };
|
|
362
|
+
}
|
|
304
363
|
if (pattern === "/") {
|
|
305
364
|
return { regex: /^\/$/, keys };
|
|
306
365
|
}
|
|
@@ -361,7 +420,7 @@ function escapeRegExp(value) {
|
|
|
361
420
|
}
|
|
362
421
|
|
|
363
422
|
function assertPattern(pattern) {
|
|
364
|
-
if (typeof pattern !== "string" || !pattern.startsWith("/")) {
|
|
365
|
-
throw new TypeError("Route pattern must be a path string.");
|
|
423
|
+
if (typeof pattern !== "string" || (pattern !== "*" && !pattern.startsWith("/"))) {
|
|
424
|
+
throw new TypeError("Route pattern must be a path string or \"*\".");
|
|
366
425
|
}
|
|
367
426
|
}
|