@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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-06-17
4
+
5
+ - Added `csr` router mode for client-side first route rendering plus local SPA
6
+ navigation through route partial boundary swaps.
7
+
3
8
  ## 0.1.0 - 2026-06-17
4
9
 
5
10
  - Reset `@async/framework` to Layer 1 AsyncLoader.
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
- const router = createRouter({
382
- mode: "ssr-spa",
383
- root: document,
384
- boundary: "route",
385
- routes: createRouteRegistry({
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
- loader,
390
- signals,
391
- server,
392
- partials
393
- }).start();
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 | Behavior |
409
+ | Mode | Initial route | Later navigation |
401
410
  | --- | --- |
402
- | `spa` | Intercepts same-origin links and GET forms, then swaps route HTML |
403
- | `ssr-spa` | Starts from server HTML, then uses SPA navigation |
404
- | `mpa` | Does not intercept navigation |
405
- | `ssr` | Leaves navigation to the server document flow |
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) | SSR-SPA route boundary swaps |
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>
@@ -46,7 +46,7 @@ Async.use({
46
46
  });
47
47
 
48
48
  Async.start({
49
- mode: "ssr-spa",
49
+ mode: "csr",
50
50
  root: document,
51
51
  boundary: "route"
52
52
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@async/framework",
3
- "version": "0.1.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
- ensureRouterState(currentUrl());
112
- if (mode === "spa" || mode === "ssr-spa") {
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
- const matched = api.match(target);
143
- setRouterState({
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 resolveNavigation(target, matched) {
217
- if (matched?.route?.partial && partials?.resolve?.(matched.route.partial)) {
218
- return partials.render(matched.route.partial, matched.params, contextFor(matched));
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
- return fetchRoute(target.href);
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 ensureRouterState(url) {
260
- signalRegistry.ensure("router", {});
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?.pattern ?? null,
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: null
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
  }