@async/framework 0.6.0 → 0.7.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/router.js CHANGED
@@ -31,6 +31,7 @@ export function createRouteRegistry(initialMap = {}, options = {}) {
31
31
  const nextRoute = normalizeRoute(pattern, definition);
32
32
  entries.set(pattern, nextRoute.definition);
33
33
  routes.push(nextRoute);
34
+ sortRoutes(routes);
34
35
  return nextRoute;
35
36
  },
36
37
 
@@ -103,6 +104,7 @@ export function createRouteRegistry(initialMap = {}, options = {}) {
103
104
  const nextRoute = normalizeRoute(pattern, definition);
104
105
  entries.set(pattern, nextRoute.definition);
105
106
  routes.push(nextRoute);
107
+ sortRoutes(routes);
106
108
  }
107
109
  }
108
110
 
@@ -139,6 +141,8 @@ export function createRouter({
139
141
  const ownsLoader = !loader;
140
142
  const cleanups = new Set();
141
143
  let destroyed = false;
144
+ let navigationVersion = 0;
145
+ let activeNavigation;
142
146
 
143
147
  const api = {
144
148
  mode,
@@ -178,7 +182,7 @@ export function createRouter({
178
182
  },
179
183
 
180
184
  match(url) {
181
- return routes.match(url);
185
+ return routes.match(resolveUrl(url));
182
186
  },
183
187
 
184
188
  prefetch(url) {
@@ -203,7 +207,7 @@ export function createRouter({
203
207
  return null;
204
208
  }
205
209
 
206
- const target = toUrl(url);
210
+ const target = resolveUrl(url);
207
211
  if (mode === "ssr-spa") {
208
212
  return fetchRoutePartial(target, options);
209
213
  }
@@ -215,6 +219,7 @@ export function createRouter({
215
219
  return;
216
220
  }
217
221
  destroyed = true;
222
+ activeNavigation?.controller.abort(new Error("Router has been destroyed."));
218
223
  for (const cleanup of cleanups) {
219
224
  cleanup();
220
225
  }
@@ -254,24 +259,37 @@ export function createRouter({
254
259
  async function renderLocalRoutePartial(target, options = {}) {
255
260
  const matched = api.match(target);
256
261
  if (!matched) {
262
+ beginNavigation(target, null);
257
263
  setNoRouteError(target);
258
264
  return null;
259
265
  }
260
266
 
267
+ const navigation = beginNavigation(target, matched);
261
268
  setMatchedRouterState(target, matched, { pending: true, error: null });
262
269
 
263
270
  try {
264
271
  if (!matched.route?.partial || !partials?.resolve?.(matched.route.partial)) {
265
272
  const error = new Error(`Route "${target.pathname}" does not have a registered partial.`);
266
- setRouterState({ pending: false, error });
273
+ if (isActiveNavigation(navigation)) {
274
+ setRouterState({ pending: false, error });
275
+ }
267
276
  return null;
268
277
  }
269
278
 
270
- const result = await partials.render(matched.route.partial, matched.params, contextFor(matched));
271
- await applyNavigationResult(result, target, options);
279
+ const result = await partials.render(matched.route.partial, matched.params, contextFor(matched, navigation));
280
+ if (!isActiveNavigation(navigation)) {
281
+ return null;
282
+ }
283
+ await applyNavigationResult(result, target, options, navigation);
284
+ if (!isActiveNavigation(navigation)) {
285
+ return null;
286
+ }
272
287
  setRouterState({ pending: false, error: null });
273
288
  return result;
274
289
  } catch (error) {
290
+ if (!isActiveNavigation(navigation)) {
291
+ return null;
292
+ }
275
293
  setRouterState({ pending: false, error });
276
294
  throw error;
277
295
  }
@@ -279,26 +297,43 @@ export function createRouter({
279
297
 
280
298
  async function fetchRoutePartial(target, options = {}) {
281
299
  const matched = api.match(target);
300
+ const navigation = beginNavigation(target, matched);
282
301
  setMatchedRouterState(target, matched, { pending: true, error: null });
283
302
 
284
303
  try {
285
- const result = await fetchRoute(target.href);
286
- await applyNavigationResult(result, target, options);
304
+ const result = await fetchRoute(target.href, { signal: navigation.abort });
305
+ if (!isActiveNavigation(navigation)) {
306
+ return null;
307
+ }
308
+ await applyNavigationResult(result, target, options, navigation);
309
+ if (!isActiveNavigation(navigation)) {
310
+ return null;
311
+ }
287
312
  setRouterState({ pending: false, error: null });
288
313
  return result;
289
314
  } catch (error) {
315
+ if (!isActiveNavigation(navigation)) {
316
+ return null;
317
+ }
290
318
  setRouterState({ pending: false, error });
291
319
  throw error;
292
320
  }
293
321
  }
294
322
 
295
- async function applyNavigationResult(result, target, options) {
323
+ async function applyNavigationResult(result, target, options, navigation) {
324
+ if (!isActiveNavigation(navigation)) {
325
+ return;
326
+ }
296
327
  await applyServerResult(result, {
297
328
  signals: signalRegistry,
298
329
  loader: loaderInstance,
299
330
  router: api,
300
- cache
331
+ cache,
332
+ abort: navigation?.abort
301
333
  });
334
+ if (!isActiveNavigation(navigation)) {
335
+ return;
336
+ }
302
337
  if (result?.html != null && !result.boundary && !result.redirect) {
303
338
  loaderInstance.swap(boundary, result.html);
304
339
  }
@@ -312,14 +347,15 @@ export function createRouter({
312
347
  documentRef.defaultView?.history?.pushState?.({}, "", target.href);
313
348
  }
314
349
 
315
- async function fetchRoute(url, { prefetch = false } = {}) {
350
+ async function fetchRoute(url, { prefetch = false, signal } = {}) {
316
351
  if (typeof fetchImpl !== "function") {
317
352
  throw new Error("Router navigation requires a partial registry or fetch.");
318
353
  }
319
354
  const response = await fetchImpl(`${routeEndpoint}?to=${encodeURIComponent(String(url))}`, {
320
355
  headers: {
321
356
  accept: "application/json, text/html"
322
- }
357
+ },
358
+ signal
323
359
  });
324
360
  if (!response.ok) {
325
361
  throw new Error(`Route "${url}" failed with ${response.status}.`);
@@ -334,7 +370,7 @@ export function createRouter({
334
370
  return { boundary, html: await response.text() };
335
371
  }
336
372
 
337
- function contextFor(matched) {
373
+ function contextFor(matched, navigation) {
338
374
  return {
339
375
  params: matched.params,
340
376
  route: matched.route,
@@ -344,10 +380,28 @@ export function createRouter({
344
380
  loader: loaderInstance,
345
381
  server,
346
382
  cache,
347
- abort: undefined
383
+ abort: navigation?.abort
348
384
  };
349
385
  }
350
386
 
387
+ function beginNavigation(target, matched) {
388
+ activeNavigation?.controller.abort(new Error(`Router navigation superseded by ${target.pathname}${target.search}.`));
389
+ const controller = new AbortController();
390
+ const navigation = {
391
+ id: ++navigationVersion,
392
+ controller,
393
+ abort: controller.signal,
394
+ target,
395
+ matched
396
+ };
397
+ activeNavigation = navigation;
398
+ return navigation;
399
+ }
400
+
401
+ function isActiveNavigation(navigation) {
402
+ return !destroyed && navigation && activeNavigation?.id === navigation.id && !navigation.abort.aborted;
403
+ }
404
+
351
405
  function updateStateFromLocation() {
352
406
  const url = currentUrl();
353
407
  const matched = api.match(url);
@@ -382,7 +436,14 @@ export function createRouter({
382
436
  }
383
437
 
384
438
  function currentUrl() {
385
- return toUrl(documentRef.defaultView?.location?.href ?? "http://localhost/");
439
+ return resolveUrl(documentRef.defaultView?.location?.href ?? "http://localhost/");
440
+ }
441
+
442
+ function resolveUrl(url) {
443
+ if (url instanceof URL) {
444
+ return url;
445
+ }
446
+ return new URL(String(url), documentRef.defaultView?.location?.href ?? "http://localhost/");
386
447
  }
387
448
 
388
449
  function assertActive() {
@@ -399,6 +460,7 @@ function normalizeRoute(pattern, definition) {
399
460
  pattern,
400
461
  regex,
401
462
  keys,
463
+ score: routeScore(pattern),
402
464
  definition: normalized
403
465
  };
404
466
  }
@@ -467,6 +529,28 @@ function escapeRegExp(value) {
467
529
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
468
530
  }
469
531
 
532
+ function sortRoutes(routes) {
533
+ routes.sort((left, right) => right.score - left.score || right.pattern.length - left.pattern.length);
534
+ }
535
+
536
+ function routeScore(pattern) {
537
+ if (pattern === "*") {
538
+ return -1;
539
+ }
540
+ return pattern
541
+ .split("/")
542
+ .filter(Boolean)
543
+ .reduce((score, segment) => {
544
+ if (segment === "*") {
545
+ return score;
546
+ }
547
+ if (segment.startsWith(":")) {
548
+ return score + 2;
549
+ }
550
+ return score + 4;
551
+ }, pattern === "/" ? 3 : 0);
552
+ }
553
+
470
554
  function assertPattern(pattern) {
471
555
  if (typeof pattern !== "string" || (pattern !== "*" && !pattern.startsWith("/"))) {
472
556
  throw new TypeError("Route pattern must be a path string or \"*\".");
package/src/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
2
2
 
3
3
  const serverEnvelopeKeys = new Set(["value", "signals", "boundary", "html", "redirect", "error"]);
4
+ const appliedServerResult = Symbol.for("@async/framework.appliedServerResult");
5
+ const appliedServerValues = new WeakSet();
4
6
 
5
7
  export function createServerRegistry(initialMap = {}, options = {}) {
6
8
  const registryStore = options.registry ?? createRegistryStore();
@@ -107,6 +109,7 @@ export function createServerProxy({
107
109
  input: context.input ?? defaultInput(runContext),
108
110
  signals: context.signalValues ?? snapshotSignalPaths(context.signalPaths, runContext.signals)
109
111
  };
112
+ assertJsonTransportable(body);
110
113
 
111
114
  const response = await fetchImpl(joinEndpoint(endpoint, id), {
112
115
  method: "POST",
@@ -124,7 +127,7 @@ export function createServerProxy({
124
127
 
125
128
  const result = await readServerResponse(response);
126
129
  await applyServerResult(result, runContext);
127
- return unwrapServerResult(result);
130
+ return markAppliedServerValue(unwrapServerResult(result));
128
131
  }
129
132
 
130
133
  return createServerNamespace(run, {
@@ -159,6 +162,9 @@ export async function applyServerResult(result, context = {}) {
159
162
  if (!isServerEnvelope(result)) {
160
163
  return result;
161
164
  }
165
+ if (result[appliedServerResult] || appliedServerValues.has(result)) {
166
+ return result;
167
+ }
162
168
 
163
169
  if (result.signals && context.signals) {
164
170
  for (const [path, value] of Object.entries(result.signals)) {
@@ -182,6 +188,12 @@ export async function applyServerResult(result, context = {}) {
182
188
  throw toError(result.error);
183
189
  }
184
190
 
191
+ Object.defineProperty(result, appliedServerResult, {
192
+ configurable: true,
193
+ enumerable: false,
194
+ value: true
195
+ });
196
+
185
197
  return result;
186
198
  }
187
199
 
@@ -192,6 +204,13 @@ export function unwrapServerResult(result) {
192
204
  return result;
193
205
  }
194
206
 
207
+ function markAppliedServerValue(value) {
208
+ if (value && typeof value === "object") {
209
+ appliedServerValues.add(value);
210
+ }
211
+ return value;
212
+ }
213
+
195
214
  export function defaultInput(context = {}) {
196
215
  const form = findForm(context);
197
216
  if (form) {
@@ -369,6 +388,30 @@ function formDataToObject(formData) {
369
388
  return output;
370
389
  }
371
390
 
391
+ function assertJsonTransportable(value, seen = new Set()) {
392
+ if (value == null || typeof value !== "object") {
393
+ return;
394
+ }
395
+ if (seen.has(value)) {
396
+ return;
397
+ }
398
+ seen.add(value);
399
+
400
+ const tag = Object.prototype.toString.call(value);
401
+ if (tag === "[object File]" || tag === "[object Blob]" || tag === "[object FormData]") {
402
+ throw new Error("Server proxy JSON transport does not support File, Blob, or FormData values yet.");
403
+ }
404
+ if (Array.isArray(value)) {
405
+ for (const item of value) {
406
+ assertJsonTransportable(item, seen);
407
+ }
408
+ return;
409
+ }
410
+ for (const item of Object.values(value)) {
411
+ assertJsonTransportable(item, seen);
412
+ }
413
+ }
414
+
372
415
  function joinEndpoint(endpoint, id) {
373
416
  return `${String(endpoint).replace(/\/$/, "")}/${encodeURIComponent(id)}`;
374
417
  }