@async/framework 0.6.0 → 0.8.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/src/loader.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { renderComponent } from "./component.js";
2
2
  import { createHandlerRegistry } from "./handlers.js";
3
+ import { createScheduler } from "./scheduler.js";
3
4
  import { createSignalRegistry, isSignalRef } from "./signals.js";
4
5
  import { matchAttribute, normalizeAttributeConfig, readAttribute } from "./attributes.js";
5
6
 
6
7
  const inlineBindingPrefix = "__async:inline:";
7
8
 
8
- export function Loader({ root, signals, handlers, server, router, cache, attributes } = {}) {
9
+ export function Loader({ root, signals, handlers, server, router, cache, attributes, scheduler } = {}) {
9
10
  const documentRef = root?.ownerDocument ?? root ?? globalThis.document;
10
11
  const rootNode = root ?? documentRef;
11
12
  const signalRegistry = signals ?? createSignalRegistry();
12
13
  const handlerRegistry = handlers ?? createHandlerRegistry();
14
+ const schedulerInstance = scheduler ?? createScheduler();
15
+ const ownsScheduler = !scheduler;
13
16
  const attributeConfig = normalizeAttributeConfig(attributes);
14
17
  const cleanups = new Set();
15
18
  const eventBindings = new WeakMap();
@@ -30,6 +33,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
30
33
  server,
31
34
  router,
32
35
  cache,
36
+ scheduler: schedulerInstance,
33
37
  attributes: attributeConfig,
34
38
 
35
39
  start() {
@@ -69,6 +73,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
69
73
  server: api.server,
70
74
  router: api.router,
71
75
  cache: api.cache,
76
+ scheduler: schedulerInstance,
72
77
  attributes: attributeConfig
73
78
  });
74
79
  cleanupChildren(target);
@@ -89,6 +94,9 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
89
94
  runCleanup(cleanup);
90
95
  }
91
96
  cleanups.clear();
97
+ if (ownsScheduler) {
98
+ schedulerInstance.destroy();
99
+ }
92
100
  },
93
101
 
94
102
  _observeVisible(target, fn) {
@@ -106,13 +114,14 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
106
114
  }
107
115
  };
108
116
 
109
- signalRegistry._setContext?.({ server: api.server, router: api.router, loader: api, cache: api.cache });
117
+ signalRegistry._setContext?.({ server: api.server, router: api.router, loader: api, cache: api.cache, scheduler: schedulerInstance });
110
118
  api.server?._setContext?.({
111
119
  signals: signalRegistry,
112
120
  handlers: handlerRegistry,
113
121
  loader: api,
114
122
  router: api.router,
115
- cache: api.cache
123
+ cache: api.cache,
124
+ scheduler: schedulerInstance
116
125
  });
117
126
 
118
127
  function bindEventAttributes(scope) {
@@ -144,18 +153,19 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
144
153
 
145
154
  const listener = async (event) => {
146
155
  try {
147
- await handlerRegistry.run(ref, {
156
+ await schedulerInstance.batch(() => handlerRegistry.run(ref, {
148
157
  signals: signalRegistry,
149
158
  handlers: handlerRegistry,
150
159
  loader: api,
151
160
  server: api.server,
152
161
  router: api.router,
153
162
  cache: api.cache,
163
+ scheduler: schedulerInstance,
154
164
  event,
155
165
  element,
156
166
  el: element,
157
167
  root: rootNode
158
- });
168
+ }));
159
169
  } catch (error) {
160
170
  dispatchAsyncError(element, error);
161
171
  }
@@ -271,7 +281,12 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
271
281
 
272
282
  const read = () => readBinding(path, options);
273
283
  apply(read());
274
- addCleanup(subscribeBinding(path, () => apply(read())), element);
284
+ addCleanup(subscribeBinding(path, () => {
285
+ schedulerInstance.enqueue("binding", () => apply(read()), {
286
+ scope: element,
287
+ key
288
+ });
289
+ }), element);
275
290
  }
276
291
 
277
292
  function bindValueWriter(element, path) {
@@ -332,7 +347,12 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
332
347
  const state = {
333
348
  id,
334
349
  templates,
335
- cleanup: signalRegistry.subscribe(`${id}.$status`, () => renderBoundary(boundary))
350
+ cleanup: signalRegistry.subscribe(`${id}.$status`, () => {
351
+ schedulerInstance.enqueue("binding", () => renderBoundary(boundary), {
352
+ scope: boundary,
353
+ key: `boundary:${id}`
354
+ });
355
+ })
336
356
  };
337
357
  boundaryState.set(boundary, state);
338
358
  addCleanup(state.cleanup, boundary);
@@ -372,7 +392,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
372
392
  }
373
393
  mountedElements.add(element);
374
394
  for (const ref of refs) {
375
- runPseudo(element, ref);
395
+ scheduleLifecycle(element, () => runPseudo(element, ref), `attach:${ref}`);
376
396
  }
377
397
  }
378
398
 
@@ -385,7 +405,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
385
405
  continue;
386
406
  }
387
407
  visibleElements.add(element);
388
- addCleanup(observeVisible(element, () => runPseudo(element, ref)), element);
408
+ addCleanup(observeVisible(element, () => scheduleLifecycle(element, () => runPseudo(element, ref), `visible:${ref}`)), element);
389
409
  }
390
410
  }
391
411
 
@@ -409,6 +429,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
409
429
  server: api.server,
410
430
  router: api.router,
411
431
  cache: api.cache,
432
+ scheduler: schedulerInstance,
412
433
  element,
413
434
  el: element,
414
435
  root: rootNode
@@ -427,10 +448,13 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
427
448
  const ownerWindow = target.ownerDocument?.defaultView ?? globalThis;
428
449
  const Observer = ownerWindow.IntersectionObserver ?? globalThis.IntersectionObserver;
429
450
  if (!Observer) {
430
- queueMicrotask(() => {
451
+ schedulerInstance.enqueue("lifecycle", () => {
431
452
  if (!destroyed) {
432
453
  fn(target);
433
454
  }
455
+ }, {
456
+ scope: target,
457
+ key: "visible:fallback"
434
458
  });
435
459
  return () => {};
436
460
  }
@@ -485,6 +509,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
485
509
  }
486
510
  for (const element of elementsIn(node)) {
487
511
  runScopedCleanups(element);
512
+ schedulerInstance.markScopeDestroyed(element);
488
513
  }
489
514
  }
490
515
 
@@ -508,6 +533,13 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
508
533
  }
509
534
  }
510
535
 
536
+ function scheduleLifecycle(element, fn, key) {
537
+ schedulerInstance.enqueue("lifecycle", fn, {
538
+ scope: element,
539
+ key
540
+ });
541
+ }
542
+
511
543
  return api;
512
544
  }
513
545
 
@@ -0,0 +1,40 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export function createRequestContextStore() {
4
+ const storage = new AsyncLocalStorage();
5
+
6
+ return {
7
+ storage,
8
+
9
+ run(context, fn, ...args) {
10
+ if (typeof fn !== "function") {
11
+ throw new TypeError("requestContext.run(context, fn) requires a function.");
12
+ }
13
+ return storage.run(context ?? {}, fn, ...args);
14
+ },
15
+
16
+ get() {
17
+ return storage.getStore();
18
+ },
19
+
20
+ snapshot() {
21
+ return { ...(storage.getStore() ?? {}) };
22
+ }
23
+ };
24
+ }
25
+
26
+ export function readRequestContext(store) {
27
+ if (!store) {
28
+ return {};
29
+ }
30
+ if (typeof store.get === "function") {
31
+ return store.get() ?? {};
32
+ }
33
+ if (typeof store.getStore === "function") {
34
+ return store.getStore() ?? {};
35
+ }
36
+ if (typeof store === "object") {
37
+ return store;
38
+ }
39
+ return {};
40
+ }
package/src/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Loader } from "./loader.js";
2
2
  import { createHandlerRegistry } from "./handlers.js";
3
+ import { createScheduler } from "./scheduler.js";
3
4
  import { createSignalRegistry } from "./signals.js";
4
5
  import { applyServerResult } from "./server.js";
5
6
  import { createRegistryStore } from "./registry-store.js";
@@ -31,6 +32,7 @@ export function createRouteRegistry(initialMap = {}, options = {}) {
31
32
  const nextRoute = normalizeRoute(pattern, definition);
32
33
  entries.set(pattern, nextRoute.definition);
33
34
  routes.push(nextRoute);
35
+ sortRoutes(routes);
34
36
  return nextRoute;
35
37
  },
36
38
 
@@ -103,6 +105,7 @@ export function createRouteRegistry(initialMap = {}, options = {}) {
103
105
  const nextRoute = normalizeRoute(pattern, definition);
104
106
  entries.set(pattern, nextRoute.definition);
105
107
  routes.push(nextRoute);
108
+ sortRoutes(routes);
106
109
  }
107
110
  }
108
111
 
@@ -119,12 +122,15 @@ export function createRouter({
119
122
  partials,
120
123
  fetch: fetchImpl = globalThis.fetch?.bind(globalThis),
121
124
  routeEndpoint = "/__async/route",
122
- attributes
125
+ attributes,
126
+ scheduler
123
127
  } = {}) {
124
128
  const documentRef = root?.ownerDocument ?? root ?? globalThis.document;
125
129
  const rootNode = root ?? documentRef;
126
130
  const signalRegistry = signals ?? loader?.signals ?? createSignalRegistry();
127
131
  const handlerRegistry = handlers ?? loader?.handlers ?? createHandlerRegistry();
132
+ const schedulerInstance = scheduler ?? loader?.scheduler ?? createScheduler();
133
+ const ownsScheduler = !scheduler && !loader?.scheduler;
128
134
  const attributeConfig = normalizeAttributeConfig(attributes ?? loader?.attributes);
129
135
  const loaderInstance =
130
136
  loader ??
@@ -134,11 +140,14 @@ export function createRouter({
134
140
  handlers: handlerRegistry,
135
141
  server,
136
142
  cache,
143
+ scheduler: schedulerInstance,
137
144
  attributes: attributeConfig
138
145
  });
139
146
  const ownsLoader = !loader;
140
147
  const cleanups = new Set();
141
148
  let destroyed = false;
149
+ let navigationVersion = 0;
150
+ let activeNavigation;
142
151
 
143
152
  const api = {
144
153
  mode,
@@ -151,12 +160,13 @@ export function createRouter({
151
160
  server,
152
161
  cache,
153
162
  partials,
163
+ scheduler: schedulerInstance,
154
164
  attributes: attributeConfig,
155
165
 
156
166
  start() {
157
167
  assertActive();
158
168
  loaderInstance.router = api;
159
- signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache });
169
+ signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache, scheduler: schedulerInstance });
160
170
  if (ownsLoader) {
161
171
  loaderInstance.start();
162
172
  }
@@ -178,7 +188,7 @@ export function createRouter({
178
188
  },
179
189
 
180
190
  match(url) {
181
- return routes.match(url);
191
+ return routes.match(resolveUrl(url));
182
192
  },
183
193
 
184
194
  prefetch(url) {
@@ -203,7 +213,7 @@ export function createRouter({
203
213
  return null;
204
214
  }
205
215
 
206
- const target = toUrl(url);
216
+ const target = resolveUrl(url);
207
217
  if (mode === "ssr-spa") {
208
218
  return fetchRoutePartial(target, options);
209
219
  }
@@ -215,10 +225,14 @@ export function createRouter({
215
225
  return;
216
226
  }
217
227
  destroyed = true;
228
+ activeNavigation?.controller.abort(new Error("Router has been destroyed."));
218
229
  for (const cleanup of cleanups) {
219
230
  cleanup();
220
231
  }
221
232
  cleanups.clear();
233
+ if (ownsScheduler) {
234
+ schedulerInstance.destroy();
235
+ }
222
236
  }
223
237
  };
224
238
 
@@ -254,24 +268,37 @@ export function createRouter({
254
268
  async function renderLocalRoutePartial(target, options = {}) {
255
269
  const matched = api.match(target);
256
270
  if (!matched) {
271
+ beginNavigation(target, null);
257
272
  setNoRouteError(target);
258
273
  return null;
259
274
  }
260
275
 
276
+ const navigation = beginNavigation(target, matched);
261
277
  setMatchedRouterState(target, matched, { pending: true, error: null });
262
278
 
263
279
  try {
264
280
  if (!matched.route?.partial || !partials?.resolve?.(matched.route.partial)) {
265
281
  const error = new Error(`Route "${target.pathname}" does not have a registered partial.`);
266
- setRouterState({ pending: false, error });
282
+ if (isActiveNavigation(navigation)) {
283
+ setRouterState({ pending: false, error });
284
+ }
267
285
  return null;
268
286
  }
269
287
 
270
- const result = await partials.render(matched.route.partial, matched.params, contextFor(matched));
271
- await applyNavigationResult(result, target, options);
288
+ const result = await partials.render(matched.route.partial, matched.params, contextFor(matched, navigation));
289
+ if (!isActiveNavigation(navigation)) {
290
+ return null;
291
+ }
292
+ await applyNavigationResult(result, target, options, navigation);
293
+ if (!isActiveNavigation(navigation)) {
294
+ return null;
295
+ }
272
296
  setRouterState({ pending: false, error: null });
273
297
  return result;
274
298
  } catch (error) {
299
+ if (!isActiveNavigation(navigation)) {
300
+ return null;
301
+ }
275
302
  setRouterState({ pending: false, error });
276
303
  throw error;
277
304
  }
@@ -279,28 +306,48 @@ export function createRouter({
279
306
 
280
307
  async function fetchRoutePartial(target, options = {}) {
281
308
  const matched = api.match(target);
309
+ const navigation = beginNavigation(target, matched);
282
310
  setMatchedRouterState(target, matched, { pending: true, error: null });
283
311
 
284
312
  try {
285
- const result = await fetchRoute(target.href);
286
- await applyNavigationResult(result, target, options);
313
+ const result = await fetchRoute(target.href, { signal: navigation.abort });
314
+ if (!isActiveNavigation(navigation)) {
315
+ return null;
316
+ }
317
+ await applyNavigationResult(result, target, options, navigation);
318
+ if (!isActiveNavigation(navigation)) {
319
+ return null;
320
+ }
287
321
  setRouterState({ pending: false, error: null });
288
322
  return result;
289
323
  } catch (error) {
324
+ if (!isActiveNavigation(navigation)) {
325
+ return null;
326
+ }
290
327
  setRouterState({ pending: false, error });
291
328
  throw error;
292
329
  }
293
330
  }
294
331
 
295
- async function applyNavigationResult(result, target, options) {
332
+ async function applyNavigationResult(result, target, options, navigation) {
333
+ if (!isActiveNavigation(navigation)) {
334
+ return;
335
+ }
296
336
  await applyServerResult(result, {
297
337
  signals: signalRegistry,
298
338
  loader: loaderInstance,
299
339
  router: api,
300
- cache
340
+ cache,
341
+ scheduler: schedulerInstance,
342
+ abort: navigation?.abort
301
343
  });
344
+ await schedulerInstance.flush();
345
+ if (!isActiveNavigation(navigation)) {
346
+ return;
347
+ }
302
348
  if (result?.html != null && !result.boundary && !result.redirect) {
303
349
  loaderInstance.swap(boundary, result.html);
350
+ await schedulerInstance.flush();
304
351
  }
305
352
  if (result?.redirect || options.history === false) {
306
353
  return;
@@ -312,14 +359,15 @@ export function createRouter({
312
359
  documentRef.defaultView?.history?.pushState?.({}, "", target.href);
313
360
  }
314
361
 
315
- async function fetchRoute(url, { prefetch = false } = {}) {
362
+ async function fetchRoute(url, { prefetch = false, signal } = {}) {
316
363
  if (typeof fetchImpl !== "function") {
317
364
  throw new Error("Router navigation requires a partial registry or fetch.");
318
365
  }
319
366
  const response = await fetchImpl(`${routeEndpoint}?to=${encodeURIComponent(String(url))}`, {
320
367
  headers: {
321
368
  accept: "application/json, text/html"
322
- }
369
+ },
370
+ signal
323
371
  });
324
372
  if (!response.ok) {
325
373
  throw new Error(`Route "${url}" failed with ${response.status}.`);
@@ -334,7 +382,7 @@ export function createRouter({
334
382
  return { boundary, html: await response.text() };
335
383
  }
336
384
 
337
- function contextFor(matched) {
385
+ function contextFor(matched, navigation) {
338
386
  return {
339
387
  params: matched.params,
340
388
  route: matched.route,
@@ -344,10 +392,29 @@ export function createRouter({
344
392
  loader: loaderInstance,
345
393
  server,
346
394
  cache,
347
- abort: undefined
395
+ scheduler: schedulerInstance,
396
+ abort: navigation?.abort
348
397
  };
349
398
  }
350
399
 
400
+ function beginNavigation(target, matched) {
401
+ activeNavigation?.controller.abort(new Error(`Router navigation superseded by ${target.pathname}${target.search}.`));
402
+ const controller = new AbortController();
403
+ const navigation = {
404
+ id: ++navigationVersion,
405
+ controller,
406
+ abort: controller.signal,
407
+ target,
408
+ matched
409
+ };
410
+ activeNavigation = navigation;
411
+ return navigation;
412
+ }
413
+
414
+ function isActiveNavigation(navigation) {
415
+ return !destroyed && navigation && activeNavigation?.id === navigation.id && !navigation.abort.aborted;
416
+ }
417
+
351
418
  function updateStateFromLocation() {
352
419
  const url = currentUrl();
353
420
  const matched = api.match(url);
@@ -382,7 +449,14 @@ export function createRouter({
382
449
  }
383
450
 
384
451
  function currentUrl() {
385
- return toUrl(documentRef.defaultView?.location?.href ?? "http://localhost/");
452
+ return resolveUrl(documentRef.defaultView?.location?.href ?? "http://localhost/");
453
+ }
454
+
455
+ function resolveUrl(url) {
456
+ if (url instanceof URL) {
457
+ return url;
458
+ }
459
+ return new URL(String(url), documentRef.defaultView?.location?.href ?? "http://localhost/");
386
460
  }
387
461
 
388
462
  function assertActive() {
@@ -399,6 +473,7 @@ function normalizeRoute(pattern, definition) {
399
473
  pattern,
400
474
  regex,
401
475
  keys,
476
+ score: routeScore(pattern),
402
477
  definition: normalized
403
478
  };
404
479
  }
@@ -467,6 +542,28 @@ function escapeRegExp(value) {
467
542
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
468
543
  }
469
544
 
545
+ function sortRoutes(routes) {
546
+ routes.sort((left, right) => right.score - left.score || right.pattern.length - left.pattern.length);
547
+ }
548
+
549
+ function routeScore(pattern) {
550
+ if (pattern === "*") {
551
+ return -1;
552
+ }
553
+ return pattern
554
+ .split("/")
555
+ .filter(Boolean)
556
+ .reduce((score, segment) => {
557
+ if (segment === "*") {
558
+ return score;
559
+ }
560
+ if (segment.startsWith(":")) {
561
+ return score + 2;
562
+ }
563
+ return score + 4;
564
+ }, pattern === "/" ? 3 : 0);
565
+ }
566
+
470
567
  function assertPattern(pattern) {
471
568
  if (typeof pattern !== "string" || (pattern !== "*" && !pattern.startsWith("/"))) {
472
569
  throw new TypeError("Route pattern must be a path string or \"*\".");