@antelopejs/dms-frontend 0.5.6 → 0.6.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.
Files changed (71) hide show
  1. package/README.md +376 -18
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +8 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +80 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-runner.js +12 -0
  26. package/dist/workspace.d.ts +9 -0
  27. package/package.json +2 -1
  28. package/templates/vue/app-runtime.ts +58 -16
  29. package/templates/vue/client-server-guard.mjs +73 -0
  30. package/templates/vue/email-renderer.ts +17 -4
  31. package/templates/vue/email-runtime.ts +1 -1
  32. package/templates/vue/frontend-module.ts +1817 -259
  33. package/templates/vue/globals.d.ts +1 -1
  34. package/templates/vue/icon-hydration.ts +106 -1
  35. package/templates/vue/index.html +0 -1
  36. package/templates/vue/layer-app-config.ts +92 -0
  37. package/templates/vue/main.ts +10 -2
  38. package/templates/vue/package.json +4 -1
  39. package/templates/vue/server/auth/backend.mjs +13 -19
  40. package/templates/vue/server/auth/client-ip.mjs +27 -0
  41. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  42. package/templates/vue/server/auth/session.mjs +6 -97
  43. package/templates/vue/server/auth-hooks.mjs +84 -0
  44. package/templates/vue/server/backend-proxies.mjs +110 -0
  45. package/templates/vue/server/dev-private-files.mjs +99 -0
  46. package/templates/vue/server/email.mjs +11 -33
  47. package/templates/vue/server/favicon.mjs +20 -0
  48. package/templates/vue/server/inertia.mjs +48 -3
  49. package/templates/vue/server/listen-host.mjs +19 -0
  50. package/templates/vue/server/module-event.mjs +265 -0
  51. package/templates/vue/server/module-routes.mjs +513 -0
  52. package/templates/vue/server/module-server.mjs +225 -0
  53. package/templates/vue/server/module-testing.mjs +65 -0
  54. package/templates/vue/server/page-props.mjs +151 -0
  55. package/templates/vue/server/path-patterns.mjs +38 -0
  56. package/templates/vue/server/public-endpoints.mjs +69 -11
  57. package/templates/vue/server/request-routing.mjs +47 -0
  58. package/templates/vue/server/route-rules.mjs +129 -0
  59. package/templates/vue/server-module.ts +294 -0
  60. package/templates/vue/server.mjs +177 -161
  61. package/templates/vue/ssr-renderer.ts +60 -19
  62. package/templates/vue/tsconfig.json +2 -1
  63. package/templates/vue/ui-app-config.ts +18 -6
  64. package/templates/vue/ui-inertia-page.mjs +37 -0
  65. package/templates/vue/vite.config.ts +31 -10
  66. package/templates/vue/vite.email.config.ts +1 -1
  67. package/templates/vue/vite.server.config.ts +57 -0
  68. package/templates/vue/server/auth/oauth.mjs +0 -233
  69. package/templates/vue/server/auth/routes.mjs +0 -444
  70. package/templates/vue/server/render-token.mjs +0 -81
  71. package/templates/vue/server/tester.mjs +0 -227
@@ -27,18 +27,34 @@ import {
27
27
  hasInjectionContext,
28
28
  type InjectionKey,
29
29
  inject,
30
+ isRef,
31
+ type MaybeRefOrGetter,
32
+ nextTick,
33
+ onBeforeMount,
30
34
  onMounted,
31
35
  onScopeDispose,
36
+ onServerPrefetch,
32
37
  type Plugin,
33
38
  type PropType,
34
39
  type Ref,
35
40
  reactive,
36
41
  readonly,
37
42
  ref,
43
+ type ShallowRef,
44
+ shallowReactive,
45
+ shallowRef,
46
+ toRaw,
47
+ toValue,
38
48
  watch,
39
49
  } from "vue";
40
50
  import type { Composer } from "vue-i18n";
41
51
  import { useI18n as useVueI18n } from "vue-i18n";
52
+ import {
53
+ appConfigChanges,
54
+ assignInPlace,
55
+ mergeLayerAppConfigs,
56
+ withAppConfigChanges,
57
+ } from "./layer-app-config";
42
58
 
43
59
  export interface DmsPageRoute {
44
60
  displayName?: string;
@@ -52,7 +68,6 @@ export interface DmsPagePayload {
52
68
  componentName?: string;
53
69
  layoutUrl?: string;
54
70
  layout?: DmsPageLayout;
55
- shared?: DmsSharedPagePayload;
56
71
  [key: string]: unknown;
57
72
  }
58
73
  export interface DmsSharedPagePayload {
@@ -74,6 +89,11 @@ export interface DmsPageLayout {
74
89
  export interface DmsPageProps {
75
90
  path: string;
76
91
  page: DmsPagePayload;
92
+ /**
93
+ * The site layout. An Inertia once prop: a visit carries it over from the
94
+ * previous page until it expires, unless the server sends a newer one.
95
+ */
96
+ shared?: DmsSharedPagePayload;
77
97
  user?: DmsUser;
78
98
  session?: DmsSession;
79
99
  error?: DmsErrorData;
@@ -159,30 +179,145 @@ export interface DmsRouter {
159
179
  replace(to: RouteLocationRaw, options?: DmsNavigationOptions): Promise<void>;
160
180
  back(): void;
161
181
  }
182
+ export type DmsAsyncDataStatus = "idle" | "pending" | "success" | "error";
183
+ /**
184
+ * What a new run does with the one still in flight: `cancel` aborts it and
185
+ * starts over, `defer` waits for it instead.
186
+ */
187
+ export type DmsAsyncDataDedupe = "cancel" | "defer";
188
+ export interface DmsAsyncDataRefreshOptions {
189
+ dedupe?: DmsAsyncDataDedupe;
190
+ }
162
191
  export interface DmsAsyncData<T> {
163
192
  data: Ref<T | null>;
193
+ /**
194
+ * The key the data is loaded under, a reactive key's current one; none for
195
+ * a call without a key.
196
+ */
197
+ key: Readonly<Ref<string | undefined>>;
198
+ /**
199
+ * `true` while `data` is still the previous key's, shown in place of the
200
+ * default until the current key's first answer (a reactive key only).
201
+ */
202
+ placeholder: Readonly<Ref<boolean>>;
203
+ /**
204
+ * The last run's failure. It stays while the next run is in flight, and
205
+ * goes back to `null` once a run answers.
206
+ */
164
207
  error: Ref<unknown>;
165
208
  pending: Ref<boolean>;
166
- status: Ref<"idle" | "pending" | "success" | "error">;
167
- execute(): Promise<void>;
168
- refresh(): Promise<void>;
209
+ status: Ref<DmsAsyncDataStatus>;
210
+ execute(options?: DmsAsyncDataRefreshOptions): Promise<void>;
211
+ refresh(options?: DmsAsyncDataRefreshOptions): Promise<void>;
212
+ /** Aborts the run in flight and puts the data back to its default. */
213
+ clear(): void;
214
+ }
215
+ /** Usable at once, or awaited until its data is there. */
216
+ export type DmsAsyncDataResult<T> = DmsAsyncData<T> & Promise<DmsAsyncData<T>>;
217
+ export interface DmsAsyncDataContext {
218
+ /**
219
+ * Aborted once the result is no longer wanted: a newer run replaced it, it
220
+ * was cleared, or no component shows its key any more.
221
+ */
222
+ signal: AbortSignal;
223
+ }
224
+ export type DmsAsyncDataHandler<T> = (
225
+ context: DmsAsyncDataContext,
226
+ ) => Promise<T>;
227
+ /** A key, or `null` (or `undefined`) while there is nothing to load. */
228
+ export type DmsAsyncDataKey = string | null | undefined;
229
+ /** What `useDmsData` gives of a key's data. */
230
+ export interface DmsData<T> {
231
+ /**
232
+ * The data loaded under the key, shared with every caller showing it:
233
+ * `undefined` while the key has none of its own (no caller loaded it yet,
234
+ * or it shows the previous key's data meanwhile). Assigning it replaces the
235
+ * key's data; a key nobody loaded keeps none.
236
+ */
237
+ data: Ref<T | undefined>;
169
238
  }
170
- export interface UseFetchOptions<T> extends FetchOptions {
171
- immediate?: boolean;
172
- watch?: false | unknown[];
173
- default?: () => T;
174
- transform?: (value: unknown) => T | Promise<T>;
175
- pick?: string[];
176
- $fetch?: typeof ofetch;
239
+ /** What `transform` knows of the run whose result it transforms. */
240
+ export interface DmsAsyncDataTransformContext {
241
+ /** The key the data is loaded under; none for a call without a key. */
242
+ key: string | undefined;
177
243
  }
244
+ /** What `useDmsFetch`'s `transform` knows of the request it answers. */
245
+ export interface DmsFetchTransformContext extends DmsAsyncDataTransformContext {
246
+ /**
247
+ * The URL requested, as given (before the base URL and the query), even
248
+ * when a reactive URL changed since.
249
+ */
250
+ url: string;
251
+ }
252
+ /**
253
+ * The request options a ref or a getter may stand for: read again for each
254
+ * request, and part of the key the response is shared under.
255
+ */
256
+ type DmsFetchRequestSources = "method" | "query" | "params" | "body";
178
257
  export interface UseAsyncDataOptions<T> {
179
258
  default?: () => T;
259
+ /** `false` waits for `execute()` (a dialog, a drawer) before the first run. */
180
260
  immediate?: boolean;
261
+ /**
262
+ * A client navigation does not wait for the data. A server render still
263
+ * does: its HTML shows the data, and the browser takes it over.
264
+ */
181
265
  lazy?: boolean;
266
+ /** `false` leaves the data to the browser: the server renders the default. */
182
267
  server?: boolean;
183
268
  watch?: false | unknown[];
184
- transform?: (value: unknown) => T | Promise<T>;
269
+ transform?: (
270
+ value: unknown,
271
+ context: DmsAsyncDataTransformContext,
272
+ ) => T | Promise<T>;
185
273
  pick?: string[];
274
+ /**
275
+ * `true` tracks changes inside the value too. By default only the
276
+ * replacement of the whole value triggers, as in Nuxt 4.
277
+ */
278
+ deep?: boolean;
279
+ dedupe?: DmsAsyncDataDedupe;
280
+ /**
281
+ * `false` leaves the data out of `refreshDmsPageData()`, which reads again
282
+ * the data of the components on screen once the page changed (a save, a
283
+ * realtime event).
284
+ */
285
+ refreshOnPageRefresh?: boolean;
286
+ /**
287
+ * Reads again every this many milliseconds, in the browser, while the
288
+ * caller shows the data and the page is visible. A ref or a getter changes
289
+ * the interval as it changes; `0`, `null` or `undefined` stops the polling
290
+ * until it has a value again. A call outside a component (a plugin, a
291
+ * callback) polls for as long as the application runs.
292
+ */
293
+ refreshInterval?: MaybeRefOrGetter<number | null | undefined>;
294
+ }
295
+ /**
296
+ * `useDmsFetch`'s options: ofetch's request options, of which the method, the
297
+ * query, the params and the body may be refs or getters, and the async data
298
+ * options.
299
+ */
300
+ export interface UseFetchOptions<T>
301
+ extends
302
+ Omit<FetchOptions, DmsFetchRequestSources>,
303
+ Omit<UseAsyncDataOptions<T>, "transform"> {
304
+ /**
305
+ * The key the response is shared and transferred under, in place of the
306
+ * one the request gives: its method, base URL, URL, query and body. A ref
307
+ * or a getter moves the caller to the data of each key it takes; `null` or
308
+ * `undefined` requests nothing until it has a value.
309
+ */
310
+ key?: MaybeRefOrGetter<DmsAsyncDataKey>;
311
+ method?: MaybeRefOrGetter<FetchOptions["method"]>;
312
+ query?: MaybeRefOrGetter<FetchOptions["query"]>;
313
+ params?: MaybeRefOrGetter<FetchOptions["params"]>;
314
+ body?: MaybeRefOrGetter<FetchOptions["body"]>;
315
+ transform?: (
316
+ value: unknown,
317
+ context: DmsFetchTransformContext,
318
+ ) => T | Promise<T>;
319
+ /** Replaces the fetch function; it receives the run's signal. */
320
+ $fetch?: typeof ofetch;
186
321
  }
187
322
  export interface DmsErrorData {
188
323
  statusCode?: number;
@@ -191,6 +326,20 @@ export interface DmsErrorData {
191
326
  data?: unknown;
192
327
  fatal?: boolean;
193
328
  }
329
+ /** What a server render hands the browser along with its HTML. */
330
+ export interface DmsPayload {
331
+ data: Record<string, unknown>;
332
+ errors: Record<string, DmsErrorData>;
333
+ state: Record<string, unknown>;
334
+ }
335
+ export interface DmsStateOptions {
336
+ /**
337
+ * Hands the server's value to the browser, which then starts from it
338
+ * instead of calling `init` again. Never for tokens or secrets: the value
339
+ * is written into the page.
340
+ */
341
+ transfer?: boolean;
342
+ }
194
343
  export interface DmsUserSession<TUser = DmsUser, TSession = DmsSession> {
195
344
  user: Ref<TUser | null>;
196
345
  session: Ref<TSession | null>;
@@ -225,6 +374,13 @@ export type DmsI18n = Composer & {
225
374
  export type DmsPluginSetup = (context: DmsAppContext) => void | Promise<void>;
226
375
  export interface DmsMiddlewareRegistrationOptions {
227
376
  global?: boolean;
377
+ /**
378
+ * Runs at the same time as the parallel middlewares next to it, for one
379
+ * that reads nothing another middleware sets (a check against the backend,
380
+ * say). Their results still apply in registration order: the first that
381
+ * redirects or aborts wins, and the next middlewares wait for all of them.
382
+ */
383
+ parallel?: boolean;
228
384
  }
229
385
  export interface DmsPluginRegistrationOptions {
230
386
  clientOnly?: boolean;
@@ -298,6 +454,12 @@ export interface DmsFrontendModuleRegistration {
298
454
  module: DmsFrontendModule;
299
455
  options: DmsModuleOptions;
300
456
  }
457
+ /** A layer's `app/app.config.ts`, as `app-configs.generated.ts` lists them. */
458
+ export interface DmsLayerAppConfig {
459
+ /** The id of the frontend module the layer belongs to. */
460
+ module: string;
461
+ config: Record<string, unknown>;
462
+ }
301
463
  // Async middleware without a redirect naturally infers Promise<void>.
302
464
  // biome-ignore lint/suspicious/noConfusingVoidType: preserve that valid handler signature.
303
465
  export type DmsMiddlewareResult = void | false | RouteLocationRaw;
@@ -348,15 +510,52 @@ interface DmsFrontendRegistry {
348
510
  pluginSetups: DmsPluginRegistration[];
349
511
  middleware: DmsMiddleware[];
350
512
  namedMiddleware: Map<string, DmsMiddleware>;
513
+ /** The middlewares registered with `parallel: true`. */
514
+ parallelMiddleware: WeakSet<DmsMiddleware>;
351
515
  accessRedirects: Map<string, string>;
352
516
  injections: Map<string | symbol, unknown>;
353
517
  }
354
518
 
355
519
  export interface DmsFrontendRuntime {
356
520
  sharedState: Map<string, Ref<unknown>>;
521
+ /** Reactive: `useDmsData` follows a key as it appears and is dropped. */
357
522
  asyncData: Map<string, DmsAsyncData<unknown>>;
358
- asyncDataPromises: Map<string, Promise<void>>;
523
+ /** The server's data, which the browser takes over once per key. */
359
524
  hydratedAsyncData: Map<string, unknown>;
525
+ /** The server's failures, which the browser takes over once per key. */
526
+ hydratedAsyncErrors: Map<string, DmsErrorData>;
527
+ /** The state keys a server render hands to the browser. */
528
+ transferredState: Set<string>;
529
+ /**
530
+ * Whether the browser is still hydrating the page the server rendered,
531
+ * whose first render must match the server's HTML.
532
+ */
533
+ hydrating: boolean;
534
+ /**
535
+ * The runs a server render started outside any component, which no
536
+ * `onServerPrefetch` waits for.
537
+ */
538
+ serverPrefetches: Set<Promise<void>>;
539
+ /** The runs in flight, which a server render aborts once it is over. */
540
+ asyncDataRuns: Set<AbortController>;
541
+ /** The data some component or caller shows: `refreshDmsPageData`'s. */
542
+ boundAsyncData: Set<{ pageRefresh(): Promise<void> | undefined }>;
543
+ /** Orders the data no caller shows, the least recently shown first. */
544
+ asyncDataClock: number;
545
+ /**
546
+ * The account and workspace the keys belong to (`setDmsAsyncDataScope`):
547
+ * the same key of another scope is another entry.
548
+ */
549
+ asyncDataScope?: string;
550
+ /**
551
+ * Runs an async data handler; a server render marks the requests it makes
552
+ * as transferred (see `inAsyncDataHandler`).
553
+ */
554
+ runAsyncDataHandler?: <R>(run: () => R) => R;
555
+ /** Whether the code running is an async data handler's. */
556
+ inAsyncDataHandler?: () => boolean;
557
+ /** Whether to warn about what a development render does wrong. */
558
+ devWarnings?: boolean;
360
559
  runtimeHooks: Map<string, DmsRuntimeHook[]>;
361
560
  serverFetch?: typeof ofetch;
362
561
  currentError: Ref<Error | null>;
@@ -367,9 +566,12 @@ export interface DmsFrontendRuntime {
367
566
  serverRedirect?: string;
368
567
  pendingNavigation?: Promise<void>;
369
568
  hasNavigationListener: boolean;
370
- pageVersion: number;
371
569
  /** Whether the page `<Suspense>` waits on the page being navigated to. */
372
570
  pageLoading: Ref<boolean>;
571
+ /** The site layout last copied into the state, as the page props hold it. */
572
+ appliedShared?: DmsSharedPagePayload;
573
+ /** Whether Inertia is putting back a page from the browser history. */
574
+ restoringHistory?: boolean;
373
575
  /**
374
576
  * Identifies the page on screen (its path, or the error page shown at that
375
577
  * path), whose meta `route.meta` holds.
@@ -382,8 +584,13 @@ export interface DmsFrontendRuntime {
382
584
  * it once done: nothing else would, since a server app is never unmounted.
383
585
  */
384
586
  scope: EffectScope;
587
+ /** The Inertia page a server runtime renders: see `useDmsInertiaPage`. */
588
+ inertiaPage?: DmsInertiaPage;
385
589
  }
386
590
 
591
+ /** Inertia's page, as `usePage()` returns it. */
592
+ export type DmsInertiaPage = ReturnType<typeof usePage>;
593
+
387
594
  /** Creates the empty registry one setup of the frontend modules fills. */
388
595
  function createDmsFrontendRegistry(): DmsFrontendRegistry {
389
596
  return {
@@ -395,6 +602,7 @@ function createDmsFrontendRegistry(): DmsFrontendRegistry {
395
602
  pluginSetups: [],
396
603
  middleware: [],
397
604
  namedMiddleware: new Map(),
605
+ parallelMiddleware: new WeakSet(),
398
606
  accessRedirects: new Map(),
399
607
  injections: new Map(),
400
608
  };
@@ -407,6 +615,9 @@ const runtimeConfig = ref<DmsRuntimeConfig>({
407
615
  public: {},
408
616
  } as DmsRuntimeConfig);
409
617
  const appConfig = ref<Record<string, unknown>>({});
618
+ // What the last setup of the modules changed in the layers' app configs, which
619
+ // a development update of those configs keeps: see `updateDmsAppConfig`.
620
+ let appConfigSetupChanges: Record<string, unknown> = {};
410
621
  const DMS_RUNTIME_KEY: InjectionKey<DmsFrontendRuntime> = Symbol("dms-runtime");
411
622
  type DmsRuntimeContext = DmsAppContext["runWithContext"];
412
623
  const runtimeContexts = new WeakMap<DmsFrontendRuntime, DmsRuntimeContext>();
@@ -416,16 +627,23 @@ let browserRuntime: DmsFrontendRuntime | undefined;
416
627
  /** Creates mutable runtime state owned by one Vue application. */
417
628
  export function createDmsFrontendRuntime(
418
629
  serverFetch?: typeof ofetch,
419
- hydratedAsyncData: Record<string, unknown> = {},
630
+ hydratedAsyncData?: Record<string, unknown>,
420
631
  isServer = false,
421
632
  requestCookies?: string,
422
633
  ): DmsFrontendRuntime {
423
- return {
634
+ const runtime: DmsFrontendRuntime = {
424
635
  requestCookies,
425
636
  sharedState: new Map(),
426
- asyncData: new Map(),
427
- asyncDataPromises: new Map(),
428
- hydratedAsyncData: new Map(Object.entries(hydratedAsyncData)),
637
+ asyncData: shallowReactive(new Map()),
638
+ hydratedAsyncData: new Map(Object.entries(hydratedAsyncData ?? {})),
639
+ hydratedAsyncErrors: new Map(),
640
+ transferredState: new Set(),
641
+ // A browser handed the server's data, even none, hydrates its HTML.
642
+ hydrating: !isServer && hydratedAsyncData !== undefined,
643
+ serverPrefetches: new Set(),
644
+ asyncDataRuns: new Set(),
645
+ boundAsyncData: new Set(),
646
+ asyncDataClock: 0,
429
647
  runtimeHooks: new Map(),
430
648
  serverFetch,
431
649
  isServer,
@@ -439,10 +657,17 @@ export function createDmsFrontendRuntime(
439
657
  matched: [],
440
658
  }),
441
659
  hasNavigationListener: false,
442
- pageVersion: 0,
443
660
  pageLoading: ref(false),
444
661
  scope: effectScope(true),
445
662
  };
663
+ // A server render stops its scope once it has answered: whatever it still
664
+ // fetches then would reach nobody.
665
+ runtime.scope.run(() =>
666
+ onScopeDispose(() => {
667
+ for (const run of runtime.asyncDataRuns) run.abort();
668
+ }),
669
+ );
670
+ return runtime;
446
671
  }
447
672
 
448
673
  /** Makes a runtime available before application plugins are installed. */
@@ -623,15 +848,104 @@ async function runMiddleware(
623
848
  const declaredHandlers = [...declared, ...metadataNames]
624
849
  .map((name) => registry.namedMiddleware.get(String(name)))
625
850
  .filter((handler): handler is DmsMiddleware => !!handler);
626
- for (const handler of [...registry.middleware, ...declaredHandlers]) {
851
+ const run = (handler: DmsMiddleware) => {
627
852
  const runWithContext = runtimeContexts.get(runtime);
628
- const result = await (runWithContext
853
+ return runWithContext
629
854
  ? runWithContext(() => handler(to, runtime.route))
630
- : handler(to, runtime.route));
855
+ : handler(to, runtime.route);
856
+ };
857
+ for (const group of middlewareGroups([
858
+ ...registry.middleware,
859
+ ...declaredHandlers,
860
+ ])) {
861
+ // A group of parallel middlewares starts together; its results apply in
862
+ // order, as a sequence's would.
863
+ const results = await Promise.all(group.map(run));
864
+ const result = results.find((value) => value !== undefined);
631
865
  if (result !== undefined) return result;
632
866
  }
633
867
  }
634
868
 
869
+ /** The middlewares in order, each run of parallel ones as one group. */
870
+ function middlewareGroups(handlers: DmsMiddleware[]): DmsMiddleware[][] {
871
+ const groups: DmsMiddleware[][] = [];
872
+ for (const handler of handlers) {
873
+ const last = groups.at(-1);
874
+ const parallel = registry.parallelMiddleware.has(handler);
875
+ if (parallel && last && registry.parallelMiddleware.has(last[0]))
876
+ last.push(handler);
877
+ else groups.push([handler]);
878
+ }
879
+ return groups;
880
+ }
881
+
882
+ // What only a request to the server can honour: a client-side visit sends
883
+ // none and has none of these events.
884
+ const SERVER_VISIT_OPTIONS = [
885
+ "async",
886
+ "component",
887
+ "data",
888
+ "except",
889
+ "headers",
890
+ "method",
891
+ "onBefore",
892
+ "onCancel",
893
+ "onCancelToken",
894
+ "onProgress",
895
+ "onStart",
896
+ "only",
897
+ "preserveUrl",
898
+ "queryStringArrayFormat",
899
+ ] as const satisfies readonly (keyof DmsNavigationOptions)[];
900
+
901
+ /**
902
+ * Whether a visit to another query or hash of the page on screen can stay in
903
+ * the browser. The page props depend on the path alone: the server would
904
+ * answer with the same page, the site layout included. An error page is left
905
+ * through the server, which renders the page in its place, and so is the
906
+ * same path on another site.
907
+ */
908
+ function staysOnPage(
909
+ runtime: DmsFrontendRuntime,
910
+ url: string,
911
+ options: DmsNavigationOptions,
912
+ ): boolean {
913
+ return (
914
+ new URL(url, currentUrl(runtime)).origin ===
915
+ new URL(currentUrl(runtime)).origin &&
916
+ !runtime.pageKey?.startsWith("error:") &&
917
+ runtime.currentError.value === null &&
918
+ SERVER_VISIT_OPTIONS.every((name) => options[name] === undefined)
919
+ );
920
+ }
921
+
922
+ /**
923
+ * An Inertia client-side visit: the URL and the history entry change, the
924
+ * page keeps its props, and no request is made.
925
+ */
926
+ function visitInBrowser(
927
+ url: string,
928
+ options: DmsNavigationOptions,
929
+ ): Promise<void> {
930
+ const clientVisit = options.replace
931
+ ? inertiaRouter.replace
932
+ : inertiaRouter.push;
933
+ return new Promise<void>((resolve) => {
934
+ clientVisit.call(inertiaRouter, {
935
+ url,
936
+ preserveScroll: options.preserveScroll ?? true,
937
+ preserveState: options.preserveState ?? true,
938
+ viewTransition: options.viewTransition,
939
+ onSuccess: options.onSuccess,
940
+ onError: options.onError,
941
+ onFinish: (visit) => {
942
+ (options.onFinish as ((visit: unknown) => void) | undefined)?.(visit);
943
+ resolve();
944
+ },
945
+ } as Parameters<typeof inertiaRouter.push>[0]);
946
+ });
947
+ }
948
+
635
949
  async function visit(
636
950
  runtime: DmsFrontendRuntime,
637
951
  to: RouteLocationRaw,
@@ -651,6 +965,10 @@ async function visit(
651
965
  // component on every visit and its local state (open tabs, selection,
652
966
  // inputs) is lost.
653
967
  const samePath = target.path === runtime.route.path;
968
+ if (samePath && staysOnPage(runtime, url, options)) {
969
+ await visitInBrowser(url, options);
970
+ return;
971
+ }
654
972
  await new Promise<void>((resolve) =>
655
973
  inertiaRouter.visit(url, {
656
974
  ...options,
@@ -686,6 +1004,18 @@ export function useDmsRoute(pattern?: string): DmsRoute {
686
1004
  return runtime.route;
687
1005
  }
688
1006
 
1007
+ /**
1008
+ * Inertia's page, as `usePage()` returns it, for the request being rendered.
1009
+ * Inertia keeps a single page for the whole process, written by each server
1010
+ * render as it starts: read after an await, it may be another request's. Nuxt
1011
+ * UI's Link and route stub read this one instead (see `vite.config.ts`).
1012
+ */
1013
+ export function useDmsInertiaPage(): DmsInertiaPage {
1014
+ const runtime = useDmsRuntime();
1015
+ if (runtime.isServer && runtime.inertiaPage) return runtime.inertiaPage;
1016
+ return usePage();
1017
+ }
1018
+
689
1019
  export function useDmsRouter(): DmsRouter {
690
1020
  const runtime = useDmsRuntime();
691
1021
  const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) => {
@@ -710,13 +1040,58 @@ export function navigateDms(
710
1040
  return visit(useDmsRuntime(), to, options);
711
1041
  }
712
1042
 
1043
+ /**
1044
+ * The options of a request a runtime makes. A server render does not retry a
1045
+ * failed read, as ofetch would once: the page waits for it, and a backend
1046
+ * failing once mostly fails twice. A browser keeps ofetch's retries, and a
1047
+ * caller that names its own keeps them everywhere.
1048
+ */
1049
+ function runtimeFetchOptions(
1050
+ runtime: DmsFrontendRuntime,
1051
+ options: FetchOptions,
1052
+ ): FetchOptions {
1053
+ return runtime.isServer && options.retry === undefined
1054
+ ? { ...options, retry: 0 }
1055
+ : options;
1056
+ }
1057
+
1058
+ const warnedServerReads = new Set<string>();
1059
+
1060
+ /**
1061
+ * Warns, in a development server render, about a backend read no async data
1062
+ * handler makes: an immediate watcher's, a setup's own `await $fetch`. The
1063
+ * server waits for it, but the page carries none of it, so the browser reads
1064
+ * it again and may hydrate with other data.
1065
+ */
1066
+ function warnUntransferredRead(
1067
+ runtime: DmsFrontendRuntime,
1068
+ request: string,
1069
+ ): void {
1070
+ if (
1071
+ !runtime.isServer ||
1072
+ !runtime.devWarnings ||
1073
+ !runtime.inAsyncDataHandler ||
1074
+ runtime.inAsyncDataHandler() ||
1075
+ warnedServerReads.has(request)
1076
+ )
1077
+ return;
1078
+ warnedServerReads.add(request);
1079
+ console.warn(
1080
+ `DMS: ${request} was read during a server render outside useDmsFetch and useDmsAsyncData ` +
1081
+ "(an immediate watcher, a setup's own await): the page does not carry the answer, " +
1082
+ "so the browser reads it again. Load it with useDmsFetch or useDmsAsyncData, " +
1083
+ "or leave it to the browser (server: false, onMounted).",
1084
+ );
1085
+ }
1086
+
713
1087
  function createDmsFetch(defaults: FetchOptions = {}): typeof ofetch {
714
1088
  const execute = (request: string, options?: FetchOptions) => {
715
1089
  const runtime = useDmsRuntime();
716
- return (runtime.serverFetch ?? ofetch)(request, {
717
- ...defaults,
718
- ...options,
719
- });
1090
+ warnUntransferredRead(runtime, request);
1091
+ return (runtime.serverFetch ?? ofetch)(
1092
+ request,
1093
+ runtimeFetchOptions(runtime, { ...defaults, ...options }),
1094
+ );
720
1095
  };
721
1096
  return Object.assign(execute, {
722
1097
  create: (options: FetchOptions) =>
@@ -737,263 +1112,1299 @@ function pickValue<T>(value: unknown, keys?: string[]): T {
737
1112
  ) as T;
738
1113
  }
739
1114
 
740
- function fetchOptions<T>(options: UseFetchOptions<T>): FetchOptions {
741
- const {
742
- immediate: _immediate,
743
- watch: _watch,
744
- default: _default,
745
- transform: _transform,
746
- pick: _pick,
747
- $fetch: _fetch,
748
- ...requestOptions
749
- } = options;
750
- return requestOptions;
1115
+ /** JSON with sorted keys, or nothing for a value JSON cannot stand for. */
1116
+ function stableJson(value: unknown): string | undefined {
1117
+ if (value === undefined || value === null) return "null";
1118
+ if (value instanceof Date) return JSON.stringify(value);
1119
+ if (typeof value !== "object")
1120
+ return ["string", "number", "boolean"].includes(typeof value)
1121
+ ? JSON.stringify(value)
1122
+ : undefined;
1123
+ if (Array.isArray(value)) {
1124
+ const items = value.map(stableJson);
1125
+ return items.includes(undefined) ? undefined : `[${items.join(",")}]`;
1126
+ }
1127
+ const prototype = Object.getPrototypeOf(value) as unknown;
1128
+ if (prototype !== Object.prototype && prototype !== null) return undefined;
1129
+ const record = value as Record<string, unknown>;
1130
+ const members = Object.keys(record)
1131
+ .filter((name) => record[name] !== undefined)
1132
+ .sort()
1133
+ .map((name) => {
1134
+ const member = stableJson(record[name]);
1135
+ return member === undefined
1136
+ ? undefined
1137
+ : `${JSON.stringify(name)}:${member}`;
1138
+ });
1139
+ return members.includes(undefined) ? undefined : `{${members.join(",")}}`;
1140
+ }
1141
+
1142
+ const FETCH_KEY_PREFIX = "$dmsfetch:";
1143
+ const DEFAULT_FETCH_METHOD = "GET";
1144
+
1145
+ // cyrb53's mixing constants, with its two 32-bit lanes both kept: a 64-bit
1146
+ // digest, as short as Nuxt's keys and as unlikely to collide within a page.
1147
+ const HASH_SEEDS = [0xdeadbeef, 0x41c6ce57];
1148
+ const HASH_MULTIPLIERS = [2654435761, 1597334677];
1149
+ const HASH_FINALIZERS = [2246822507, 3266489909];
1150
+ const HASH_SHIFTS = [16, 13];
1151
+ const HASH_RADIX = 36;
1152
+ const HASH_LANE_LENGTH = 7;
1153
+
1154
+ /**
1155
+ * A short digest of a text, the same in a server and in a browser: pure
1156
+ * JavaScript, so no `node:crypto` reaches a browser bundle.
1157
+ */
1158
+ function hashText(text: string): string {
1159
+ let [first, second] = HASH_SEEDS;
1160
+ for (let index = 0; index < text.length; index++) {
1161
+ const code = text.charCodeAt(index);
1162
+ first = Math.imul(first ^ code, HASH_MULTIPLIERS[0]);
1163
+ second = Math.imul(second ^ code, HASH_MULTIPLIERS[1]);
1164
+ }
1165
+ first = Math.imul(first ^ (first >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
1166
+ first ^= Math.imul(second ^ (second >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
1167
+ second = Math.imul(second ^ (second >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
1168
+ second ^= Math.imul(first ^ (first >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
1169
+ return [second, first]
1170
+ .map((lane) =>
1171
+ (lane >>> 0).toString(HASH_RADIX).padStart(HASH_LANE_LENGTH, "0"),
1172
+ )
1173
+ .join("");
1174
+ }
1175
+
1176
+ /**
1177
+ * The key a request's response is shared and transferred under: a digest of
1178
+ * its method, base URL, URL, query and params, and body, the headers aside. A
1179
+ * server render writes the key into its HTML, which must not show the URL, a
1180
+ * token in its query or a body. A body only a request can read, such as a
1181
+ * FormData, gives none.
1182
+ */
1183
+ function requestKey(url: string, options: FetchOptions): string | undefined {
1184
+ // ofetch sends the params and the query together, the query winning.
1185
+ const query =
1186
+ options.query || options.params
1187
+ ? { ...options.params, ...options.query }
1188
+ : null;
1189
+ const request = stableJson([
1190
+ (options.method ?? DEFAULT_FETCH_METHOD).toUpperCase(),
1191
+ options.baseURL ?? "",
1192
+ url,
1193
+ query,
1194
+ options.body ?? null,
1195
+ ]);
1196
+ return request === undefined
1197
+ ? undefined
1198
+ : `${FETCH_KEY_PREFIX}${hashText(request)}`;
1199
+ }
1200
+
1201
+ /** The parts of a request its key is computed from. */
1202
+ export type DmsRequestKeyOptions = Pick<
1203
+ FetchOptions,
1204
+ "method" | "baseURL" | "query" | "params" | "body"
1205
+ >;
1206
+
1207
+ /**
1208
+ * The key `useDmsFetch` loads a request's response under, without the scope
1209
+ * (`setDmsAsyncDataScope`): what `refreshDmsData(keys)` takes, and what a
1210
+ * test compares with the keys it finds. `undefined` for a body only a
1211
+ * request can read, such as a FormData.
1212
+ *
1213
+ * @param url The URL as given to `useDmsFetch`, before the base URL and the query
1214
+ * @param options The method, base URL, query, params and body of the request
1215
+ */
1216
+ export function dmsRequestKey(
1217
+ url: string,
1218
+ options: DmsRequestKeyOptions = {},
1219
+ ): string | undefined {
1220
+ return requestKey(url, options);
1221
+ }
1222
+
1223
+ /** A URL to request, or nothing to request while it is null or undefined. */
1224
+ type DmsFetchRequest = string | null | undefined;
1225
+
1226
+ function isReactiveSource(value: unknown): boolean {
1227
+ return isRef(value) || typeof value === "function";
1228
+ }
1229
+
1230
+ /** The options given, without the ones left undefined. */
1231
+ function definedOptions(options: FetchOptions): FetchOptions {
1232
+ return Object.fromEntries(
1233
+ Object.entries(options).filter(([, value]) => value !== undefined),
1234
+ ) as FetchOptions;
751
1235
  }
752
1236
 
1237
+ /**
1238
+ * Fetches a URL as `useDmsAsyncData` loads its data, under the key of the
1239
+ * request, so that the callers of one request share it and a server render
1240
+ * transfers it. A ref or a getter for the URL, the method, the query, the
1241
+ * params or the body moves the caller to the key of each request; a URL that
1242
+ * is null or undefined requests nothing.
1243
+ */
753
1244
  export function useDmsFetch<T>(
754
- request: string | (() => string),
1245
+ request: MaybeRefOrGetter<DmsFetchRequest>,
755
1246
  options: UseFetchOptions<T> = {},
756
- ): DmsAsyncData<T> {
1247
+ ): DmsAsyncDataResult<T> {
757
1248
  const runtime = useDmsRuntime();
758
- const data = ref<T | null>(options.default?.() ?? null) as Ref<T | null>;
759
- const error = ref<unknown>(null);
760
- const pending = ref(false);
761
- const status = ref<"idle" | "pending" | "success" | "error">("idle");
762
- const refresh = async (): Promise<void> => {
763
- pending.value = true;
764
- status.value = "pending";
765
- error.value = null;
766
- try {
767
- const value = await (options.$fetch ?? runtime.serverFetch ?? ofetch)(
768
- typeof request === "function" ? request() : request,
769
- fetchOptions(options),
770
- );
771
- const transformed = options.transform
772
- ? await options.transform(value)
773
- : value;
774
- data.value = pickValue<T>(transformed, options.pick);
775
- status.value = "success";
776
- } catch (reason) {
777
- error.value = reason;
778
- status.value = "error";
779
- } finally {
780
- pending.value = false;
781
- }
1249
+ const {
1250
+ key,
1251
+ immediate,
1252
+ lazy,
1253
+ server,
1254
+ watch: sources,
1255
+ default: fallback,
1256
+ transform,
1257
+ pick,
1258
+ deep,
1259
+ dedupe,
1260
+ refreshOnPageRefresh,
1261
+ refreshInterval,
1262
+ $fetch: fetcher,
1263
+ method,
1264
+ query,
1265
+ params,
1266
+ body,
1267
+ ...fixedOptions
1268
+ } = options;
1269
+ const url = () => toValue(request);
1270
+ const requestOptions = (): FetchOptions =>
1271
+ definedOptions({
1272
+ ...fixedOptions,
1273
+ method: toValue(method),
1274
+ query: toValue(query),
1275
+ params: toValue(params),
1276
+ body: toValue(body),
1277
+ });
1278
+ // A `$fetch` of the caller's own applies its own retries. The transform
1279
+ // runs here, where the request a response answers is known: a reactive URL
1280
+ // may have changed since it was sent.
1281
+ const handler: DmsAsyncDataHandler<T> = async ({ signal }) => {
1282
+ const target = url() as string;
1283
+ const sent = requestOptions();
1284
+ const runKey =
1285
+ key === undefined
1286
+ ? requestKey(target, sent)
1287
+ : (toValue(key) ?? undefined);
1288
+ const value: unknown = await (fetcher
1289
+ ? fetcher(target, { ...sent, signal })
1290
+ : (runtime.serverFetch ?? ofetch)(
1291
+ target,
1292
+ runtimeFetchOptions(runtime, { ...sent, signal }),
1293
+ ));
1294
+ if (!transform) return value as T;
1295
+ return transform(value, { key: runKey, url: target });
782
1296
  };
783
- if (options.watch !== false) {
784
- const sources = [
785
- ...(typeof request === "function" ? [request] : []),
786
- ...(options.watch ?? []),
787
- ] as never[];
788
- if (sources.length) watch(sources, refresh);
789
- }
790
- if (options.immediate !== false) void refresh();
791
- return { data, error, pending, status, execute: refresh, refresh };
1297
+ const asyncOptions: UseAsyncDataOptions<T> = {
1298
+ immediate,
1299
+ lazy,
1300
+ server,
1301
+ watch: sources,
1302
+ default: fallback,
1303
+ pick,
1304
+ deep,
1305
+ dedupe,
1306
+ refreshOnPageRefresh,
1307
+ refreshInterval,
1308
+ };
1309
+ const reactiveSources = [request, method, query, params, body].filter(
1310
+ isReactiveSource,
1311
+ );
1312
+ const isReactive = reactiveSources.length > 0;
1313
+ // The request gives its key, which follows the request as it changes.
1314
+ const derived = (): string | null | undefined => {
1315
+ const target = url();
1316
+ return target == null ? null : requestKey(target, requestOptions());
1317
+ };
1318
+ if (key === undefined && derived() !== undefined)
1319
+ return useDmsAsyncData(
1320
+ isReactive ? derived : derived(),
1321
+ handler,
1322
+ asyncOptions,
1323
+ );
1324
+ // The key the caller names, or none for a body without one, stays put while
1325
+ // the request changes: the request is watched instead.
1326
+ const watched =
1327
+ isReactive && sources !== false
1328
+ ? [...reactiveSources, ...(sources ?? [])]
1329
+ : sources;
1330
+ if (key === undefined)
1331
+ return useDmsAsyncData(handler, undefined, {
1332
+ ...asyncOptions,
1333
+ watch: watched,
1334
+ });
1335
+ const named = () => (url() == null ? null : toValue(key));
1336
+ const followsKey = isReactiveSource(request) || isReactiveSource(key);
1337
+ return useDmsAsyncData(followsKey ? named : named(), handler, {
1338
+ ...asyncOptions,
1339
+ watch: watched,
1340
+ });
792
1341
  }
793
1342
 
794
1343
  interface AsyncDataBinding<T> {
795
- execute: () => Promise<T>;
1344
+ handler: DmsAsyncDataHandler<T>;
796
1345
  options: UseAsyncDataOptions<T>;
797
- active: boolean;
798
- pageVersion: number;
799
1346
  }
800
1347
 
801
- interface BoundAsyncData<T> extends DmsAsyncData<T> {
802
- bind(binding: AsyncDataBinding<T>): boolean;
1348
+ interface AsyncDataRun {
1349
+ promise: Promise<void>;
1350
+ controller: AbortController;
803
1351
  }
804
1352
 
805
- interface AsyncDataRegistration<T> {
806
- entry: BoundAsyncData<T>;
807
- shouldRefresh: boolean;
1353
+ interface AsyncDataBindingHandle {
1354
+ unbind(): void;
808
1355
  }
809
1356
 
810
- function createBoundAsyncData<T>(
811
- initial: T | null,
812
- hydrated: boolean,
813
- ): BoundAsyncData<T> {
814
- const data = ref(initial) as Ref<T | null>;
815
- const error = ref<unknown>(null);
816
- const pending = ref(false);
817
- const status = ref<DmsAsyncData<T>["status"]["value"]>(
818
- hydrated ? "success" : "idle",
819
- );
1357
+ /** What a key holds before any run of the browser's own. */
1358
+ interface AsyncDataInitialState<T> {
1359
+ data: T | null;
1360
+ error: unknown;
1361
+ status: DmsAsyncDataStatus;
1362
+ }
1363
+
1364
+ /** What the components showing one key share: its data and its runs. */
1365
+ interface AsyncDataEntry<T> {
1366
+ result: DmsAsyncData<T>;
1367
+ bind(binding: AsyncDataBinding<T>): AsyncDataBindingHandle;
1368
+ /** A run: the one in flight, or a new one. */
1369
+ load(): Promise<void>;
1370
+ /** The first run, once the current turn is over. */
1371
+ schedule(): void;
1372
+ /** Resolves once no run is in flight, including the runs that replace it. */
1373
+ settled(): Promise<void>;
1374
+ unused(): boolean;
1375
+ /** When a caller last stopped showing the data (`asyncDataClock`). */
1376
+ lastShown(): number;
1377
+ /**
1378
+ * Whether another caller shows the data and its last run answered within
1379
+ * `JOIN_WITHOUT_READ_MS`: a caller joining it then reads nothing again.
1380
+ */
1381
+ justReadForAnother(): boolean;
1382
+ running(): boolean;
1383
+ /** `refreshDmsPageData`'s run, for data a caller shows and opted into. */
1384
+ pageRefresh(): Promise<void> | undefined;
1385
+ /**
1386
+ * Holds the server's state back, as pending, until the next turn or
1387
+ * `adoptNow`, whichever comes first.
1388
+ */
1389
+ adoptLater(state: AsyncDataInitialState<T>): void;
1390
+ adoptNow(): void;
1391
+ /** Resolves once the state held back is adopted; nothing when none is. */
1392
+ adoption(): Promise<void> | undefined;
1393
+ }
1394
+
1395
+ const DEFAULT_DEDUPE: DmsAsyncDataDedupe = "cancel";
1396
+ /**
1397
+ * How many keys no component shows any more a browser keeps, the most
1398
+ * recently shown, for a return to them to show their data at once.
1399
+ */
1400
+ const MAX_UNUSED_ASYNC_DATA = 50;
1401
+ /**
1402
+ * How long after a run answered a caller that joins a key another caller
1403
+ * shows takes the data as it is, instead of reading it again: two components
1404
+ * of one page sharing a read mount a moment apart.
1405
+ */
1406
+ const JOIN_WITHOUT_READ_MS = 2000;
1407
+
1408
+ const asyncDataEntries = new WeakMap<
1409
+ DmsAsyncData<unknown>,
1410
+ AsyncDataEntry<unknown>
1411
+ >();
1412
+
1413
+ function createAsyncDataEntry<T>(
1414
+ runtime: DmsFrontendRuntime,
1415
+ key: string | undefined,
1416
+ initial: AsyncDataInitialState<T>,
1417
+ deep: boolean,
1418
+ ): AsyncDataEntry<T> {
1419
+ const data = (
1420
+ deep ? ref(initial.data) : shallowRef(initial.data)
1421
+ ) as Ref<T | null>;
1422
+ const error = ref<unknown>(initial.error);
1423
+ const status = ref<DmsAsyncDataStatus>(initial.status);
1424
+ const pending = computed(() => status.value === "pending");
820
1425
  const bindings: AsyncDataBinding<T>[] = [];
821
- let generation = 0;
822
- let lastBindingPageVersion: number | undefined;
823
- let pendingBinding: AsyncDataBinding<T> | undefined;
824
- const refresh = async () => {
825
- const binding = bindings.at(-1);
826
- if (!binding) return;
827
- const request = ++generation;
828
- pendingBinding = binding;
829
- pending.value = true;
1426
+ let run: AsyncDataRun | undefined;
1427
+ let scheduled: Promise<void> | undefined;
1428
+ let heldBack: AsyncDataInitialState<T> | undefined;
1429
+ let adoption: Promise<void> | undefined;
1430
+ let lastShown = 0;
1431
+ // When the last run answered, to spare a caller joining at once a second read.
1432
+ let answeredAt = 0;
1433
+ const adoptNow = () => {
1434
+ if (!heldBack) return;
1435
+ data.value = heldBack.data;
1436
+ error.value = heldBack.error;
1437
+ status.value = heldBack.status;
1438
+ heldBack = undefined;
1439
+ };
1440
+ const handle = (binding: AsyncDataBinding<T>, signal: AbortSignal) => {
1441
+ const call = () => binding.handler({ signal });
1442
+ return runtime.runAsyncDataHandler
1443
+ ? runtime.runAsyncDataHandler(call)
1444
+ : call();
1445
+ };
1446
+ const start = (binding: AsyncDataBinding<T>): Promise<void> => {
1447
+ heldBack = undefined;
1448
+ run?.controller.abort();
1449
+ const controller = new AbortController();
1450
+ const current: AsyncDataRun = { controller, promise: Promise.resolve() };
1451
+ run = current;
1452
+ runtime.asyncDataRuns.add(controller);
1453
+ // The last failure stays until this run answers, as in Nuxt.
830
1454
  status.value = "pending";
831
- error.value = null;
832
- const current = () => binding.active && request === generation;
833
- try {
834
- const value = await binding.execute();
835
- const transformed = binding.options.transform
836
- ? await binding.options.transform(value)
837
- : value;
838
- if (!current()) return;
839
- data.value = pickValue<T>(transformed, binding.options.pick);
840
- status.value = "success";
841
- } catch (reason) {
842
- if (!current()) return;
843
- error.value = reason;
844
- status.value = "error";
845
- } finally {
846
- if (current()) pending.value = false;
847
- }
1455
+ // A run another one replaced, or that nobody waits for any more, settles
1456
+ // nothing, even when its handler ignores the signal.
1457
+ current.promise = (async () => {
1458
+ try {
1459
+ const value = await handle(binding, controller.signal);
1460
+ const transformed = binding.options.transform
1461
+ ? await binding.options.transform(value, { key })
1462
+ : value;
1463
+ if (run !== current) return;
1464
+ data.value = pickValue<T>(transformed, binding.options.pick);
1465
+ error.value = null;
1466
+ status.value = "success";
1467
+ answeredAt = Date.now();
1468
+ } catch (reason) {
1469
+ if (run !== current) return;
1470
+ // The data of the last success stays on screen next to the failure.
1471
+ error.value = reason;
1472
+ status.value = "error";
1473
+ } finally {
1474
+ runtime.asyncDataRuns.delete(controller);
1475
+ if (run === current) run = undefined;
1476
+ }
1477
+ })();
1478
+ return current.promise;
848
1479
  };
849
- return {
1480
+ const refresh = (options: DmsAsyncDataRefreshOptions = {}) => {
1481
+ const binding = bindings.at(-1);
1482
+ if (!binding) return Promise.resolve();
1483
+ const dedupe = options.dedupe ?? binding.options.dedupe ?? DEFAULT_DEDUPE;
1484
+ return run && dedupe === "defer" ? run.promise : start(binding);
1485
+ };
1486
+ const load = (): Promise<void> => {
1487
+ if (run) return run.promise;
1488
+ const binding = bindings.at(-1);
1489
+ return binding ? start(binding) : Promise.resolve();
1490
+ };
1491
+ const result: DmsAsyncData<T> = {
850
1492
  data,
1493
+ key: computed(() => key),
1494
+ placeholder: computed(() => false),
851
1495
  error,
852
1496
  pending,
853
1497
  status,
854
1498
  execute: refresh,
855
1499
  refresh,
1500
+ clear() {
1501
+ heldBack = undefined;
1502
+ run?.controller.abort();
1503
+ run = undefined;
1504
+ data.value = bindings.at(-1)?.options.default?.() ?? null;
1505
+ error.value = null;
1506
+ status.value = "idle";
1507
+ },
1508
+ };
1509
+ const entry: AsyncDataEntry<T> = {
1510
+ result,
856
1511
  bind(binding) {
857
- const shouldRefresh =
858
- bindings.length === 0 &&
859
- lastBindingPageVersion !== undefined &&
860
- lastBindingPageVersion !== binding.pageVersion;
861
- lastBindingPageVersion = binding.pageVersion;
862
1512
  bindings.push(binding);
863
- if (binding.options.watch && binding.options.watch.length)
864
- watch(binding.options.watch as never[], refresh);
865
- if (getCurrentScope())
866
- onScopeDispose(() => {
867
- binding.active = false;
868
- bindings.splice(bindings.indexOf(binding), 1);
869
- if (pendingBinding === binding && pending.value) {
870
- generation++;
871
- pending.value = false;
872
- status.value = data.value === null ? "idle" : "success";
873
- }
874
- });
875
- return shouldRefresh;
1513
+ runtime.boundAsyncData.add(entry);
1514
+ const unbind = () => {
1515
+ const index = bindings.indexOf(binding);
1516
+ if (index === -1) return;
1517
+ bindings.splice(index, 1);
1518
+ if (bindings.length > 0) return;
1519
+ lastShown = ++runtime.asyncDataClock;
1520
+ runtime.boundAsyncData.delete(entry);
1521
+ if (!run) return;
1522
+ run.controller.abort();
1523
+ run = undefined;
1524
+ // Back to what the last answer left.
1525
+ status.value =
1526
+ error.value != null
1527
+ ? "error"
1528
+ : data.value === null
1529
+ ? "idle"
1530
+ : "success";
1531
+ };
1532
+ return { unbind };
1533
+ },
1534
+ load,
1535
+ schedule() {
1536
+ // Started after the caller's turn, an explicit refresh in that turn
1537
+ // replaces this first run instead of cancelling it mid-request.
1538
+ scheduled ??= Promise.resolve().then(() => {
1539
+ scheduled = undefined;
1540
+ return load();
1541
+ });
1542
+ },
1543
+ async settled() {
1544
+ await scheduled;
1545
+ await adoption;
1546
+ while (run) await run.promise;
1547
+ },
1548
+ unused: () => bindings.length === 0,
1549
+ lastShown: () => lastShown,
1550
+ justReadForAnother: () =>
1551
+ bindings.length > 1 && Date.now() - answeredAt < JOIN_WITHOUT_READ_MS,
1552
+ running: () => run !== undefined,
1553
+ pageRefresh() {
1554
+ const wanted = bindings.some(
1555
+ (binding) => binding.options.refreshOnPageRefresh !== false,
1556
+ );
1557
+ // Data a caller waits to `execute()` stays unread.
1558
+ if (!wanted || (status.value === "idle" && !run)) return undefined;
1559
+ return refresh();
876
1560
  },
1561
+ adoptLater(state) {
1562
+ heldBack = state;
1563
+ status.value = "pending";
1564
+ adoption = Promise.resolve().then(adoptNow);
1565
+ },
1566
+ adoptNow,
1567
+ adoption: () => (heldBack ? adoption : undefined),
1568
+ };
1569
+ return entry;
1570
+ }
1571
+
1572
+ /**
1573
+ * The state the server's render left a key in, if it rendered the key: its
1574
+ * data, or its failure next to the default it rendered.
1575
+ */
1576
+ function serverAsyncDataState<T>(
1577
+ runtime: DmsFrontendRuntime,
1578
+ key: string,
1579
+ fallback: AsyncDataInitialState<T>,
1580
+ ): AsyncDataInitialState<T> | undefined {
1581
+ if (runtime.hydratedAsyncData.has(key))
1582
+ return {
1583
+ data: runtime.hydratedAsyncData.get(key) as T,
1584
+ error: null,
1585
+ status: "success",
1586
+ };
1587
+ const failure = runtime.hydratedAsyncErrors.get(key);
1588
+ if (!failure) return undefined;
1589
+ return {
1590
+ data: fallback.data,
1591
+ error: createDmsError(failure),
1592
+ status: "error",
877
1593
  };
878
1594
  }
879
1595
 
880
- function createAsyncData<T>(
1596
+ /**
1597
+ * The key an entry is stored and transferred under: the caller's key, in
1598
+ * the runtime's scope (`setDmsAsyncDataScope`).
1599
+ */
1600
+ function scopedKey(runtime: DmsFrontendRuntime, key: string): string {
1601
+ return runtime.asyncDataScope === undefined
1602
+ ? key
1603
+ : `${runtime.asyncDataScope}|${key}`;
1604
+ }
1605
+
1606
+ function asyncDataEntry<T>(
881
1607
  runtime: DmsFrontendRuntime,
882
- keyOrHandler: string | (() => Promise<T>),
883
- handler?: () => Promise<T>,
884
- options: UseAsyncDataOptions<T> = {},
885
- ): AsyncDataRegistration<T> {
886
- const execute =
887
- handler ?? (typeof keyOrHandler === "function" ? keyOrHandler : undefined);
888
- if (!execute) throw new Error("useDmsAsyncData requires a handler");
889
- const key = typeof keyOrHandler === "string" ? keyOrHandler : undefined;
890
- const hasHydratedData = key ? runtime.hydratedAsyncData.has(key) : false;
891
- const hydratedData = key ? runtime.hydratedAsyncData.get(key) : undefined;
892
- const cached = key
893
- ? (runtime.asyncData.get(key) as BoundAsyncData<T> | undefined)
1608
+ key: string | undefined,
1609
+ options: UseAsyncDataOptions<T>,
1610
+ inComponent: boolean,
1611
+ ): AsyncDataEntry<T> {
1612
+ const stored = key === undefined ? undefined : scopedKey(runtime, key);
1613
+ const shared =
1614
+ stored === undefined ? undefined : runtime.asyncData.get(stored);
1615
+ if (shared) return asyncDataEntries.get(shared) as AsyncDataEntry<T>;
1616
+ const fallback: AsyncDataInitialState<T> = {
1617
+ data: options.default?.() ?? null,
1618
+ error: null,
1619
+ status: "idle",
1620
+ };
1621
+ const server =
1622
+ stored === undefined
1623
+ ? undefined
1624
+ : serverAsyncDataState<T>(runtime, stored, fallback);
1625
+ const entry = createAsyncDataEntry<T>(
1626
+ runtime,
1627
+ key,
1628
+ server && !inComponent ? server : fallback,
1629
+ options.deep === true,
1630
+ );
1631
+ // A component's setup saw the server's run in flight, not its result: the
1632
+ // browser holds the result back as long, see `startAsyncData`.
1633
+ if (server && inComponent) entry.adoptLater(server);
1634
+ if (stored === undefined) return entry;
1635
+ runtime.hydratedAsyncData.delete(stored);
1636
+ runtime.hydratedAsyncErrors.delete(stored);
1637
+ const result = entry.result as DmsAsyncData<unknown>;
1638
+ runtime.asyncData.set(stored, result);
1639
+ asyncDataEntries.set(result, entry as AsyncDataEntry<unknown>);
1640
+ return entry;
1641
+ }
1642
+
1643
+ /**
1644
+ * Loads data once for every component that shows the same key, with Nuxt's
1645
+ * semantics: a server render waits for it before rendering, and transfers it,
1646
+ * whether or not the caller awaits it; `lazy` only spares client navigations
1647
+ * the wait; `server: false` leaves it to the browser.
1648
+ *
1649
+ * In the browser, data loaded before is shown at once and read again in the
1650
+ * background on each mount and each return to its key (stale while
1651
+ * revalidate), except while the page the server rendered hydrates: its data
1652
+ * is the server's, just read.
1653
+ *
1654
+ * A call without a key cannot be transferred: the server leaves it to the
1655
+ * browser, as `server: false` does, so the page hydrates with what it shows.
1656
+ */
1657
+ export function useDmsAsyncData<T>(
1658
+ keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
1659
+ handler?: DmsAsyncDataHandler<T>,
1660
+ callerOptions: UseAsyncDataOptions<T> = {},
1661
+ ): DmsAsyncDataResult<T> {
1662
+ const runtime = useDmsRuntime();
1663
+ // A call outside any scope (a plugin past its first await, a callback)
1664
+ // belongs to the application: its watchers and its polling live as long.
1665
+ if (getCurrentScope() || !runtime.scope.active)
1666
+ return createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions);
1667
+ return runtime.scope.run(() =>
1668
+ createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions),
1669
+ )!;
1670
+ }
1671
+
1672
+ function createAsyncDataCall<T>(
1673
+ runtime: DmsFrontendRuntime,
1674
+ keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
1675
+ handler: DmsAsyncDataHandler<T> | undefined,
1676
+ callerOptions: UseAsyncDataOptions<T>,
1677
+ ): DmsAsyncDataResult<T> {
1678
+ // With a handler second, the first argument is the key: a string, or a ref
1679
+ // or getter whose changes move the caller to the data of another key.
1680
+ const keySource = handler
1681
+ ? (keyOrHandler as MaybeRefOrGetter<DmsAsyncDataKey>)
894
1682
  : undefined;
895
- const entry =
896
- cached ??
897
- createBoundAsyncData<T>(
898
- hasHydratedData ? (hydratedData as T) : (options.default?.() ?? null),
899
- hasHydratedData,
1683
+ const execute = handler ?? keyOrHandler;
1684
+ if (typeof execute !== "function")
1685
+ throw new Error("useDmsAsyncData requires a handler");
1686
+ const keyless = keySource === undefined;
1687
+ if (keyless && runtime.isServer && runtime.devWarnings)
1688
+ console.warn(
1689
+ "DMS: useDmsAsyncData without a key, or useDmsFetch with a body it cannot key, " +
1690
+ "is left to the browser: give it a key for the server to render it.",
900
1691
  );
901
- const shouldRefresh = entry.bind({
902
- execute,
903
- options,
904
- active: true,
905
- pageVersion: runtime.pageVersion,
1692
+ const options: UseAsyncDataOptions<T> = keyless
1693
+ ? { ...callerOptions, server: false }
1694
+ : callerOptions;
1695
+ const inComponent = getCurrentInstance() !== null;
1696
+ const consumer = shallowRef(
1697
+ attachAsyncData(
1698
+ runtime,
1699
+ keyless ? undefined : (toValue(keySource) ?? null),
1700
+ execute as DmsAsyncDataHandler<T>,
1701
+ options,
1702
+ inComponent,
1703
+ ),
1704
+ );
1705
+ if (getCurrentScope()) onScopeDispose(() => consumer.value.unbind());
1706
+ if (options.watch && options.watch.length)
1707
+ refreshOnWatchedChange(consumer, options.watch);
1708
+ // Registered in the browser too, though only a server calls it: Vue makes
1709
+ // a component that prefetches an async boundary, which shifts the ids
1710
+ // `useId()` gives the components after it, and both sides must agree. It
1711
+ // waits for the key the caller is on by then, which its setup may have set.
1712
+ let prefetched = false;
1713
+ if (inComponent && options.immediate !== false && options.server !== false)
1714
+ onServerPrefetch(() => {
1715
+ prefetched = true;
1716
+ return consumer.value.entry.settled();
1717
+ });
1718
+ pollAsyncData(runtime, consumer, options);
1719
+ const { entry } = consumer.value;
1720
+ const waited = startAsyncData(runtime, entry, options, inComponent);
1721
+ const result =
1722
+ keySource == null || typeof keySource === "string"
1723
+ ? entry.result
1724
+ : followAsyncDataKey(
1725
+ runtime,
1726
+ consumer,
1727
+ keySource,
1728
+ execute as DmsAsyncDataHandler<T>,
1729
+ options,
1730
+ inComponent,
1731
+ () => !prefetched,
1732
+ );
1733
+ return Object.assign(
1734
+ Promise.resolve(waited).then(() => result),
1735
+ result,
1736
+ );
1737
+ }
1738
+
1739
+ /**
1740
+ * Reads the data again when a watched source changes, but when the caller's
1741
+ * key moved in the same turn: the new key's own read already has the change.
1742
+ */
1743
+ function refreshOnWatchedChange<T>(
1744
+ consumer: ShallowRef<AsyncDataConsumer<T>>,
1745
+ sources: unknown[],
1746
+ ): void {
1747
+ let moved = false;
1748
+ watch(
1749
+ () => consumer.value.entry,
1750
+ () => {
1751
+ moved = true;
1752
+ void nextTick(() => {
1753
+ moved = false;
1754
+ });
1755
+ },
1756
+ { flush: "sync" },
1757
+ );
1758
+ watch(sources as never[], () => {
1759
+ if (!moved) void consumer.value.entry.result.refresh();
906
1760
  });
907
- if (key) runtime.asyncData.set(key, entry as DmsAsyncData<unknown>);
908
- return { entry, shouldRefresh };
909
1761
  }
910
1762
 
911
- export async function useDmsAsyncData<T>(
912
- keyOrHandler: string | (() => Promise<T>),
913
- handler?: () => Promise<T>,
914
- options: UseAsyncDataOptions<T> = {},
915
- ): Promise<DmsAsyncData<T>> {
916
- const runtime = useDmsRuntime();
917
- const key = typeof keyOrHandler === "string" ? keyOrHandler : undefined;
918
- const cached = key ? runtime.asyncData.get(key) : undefined;
919
- const isHydrated = key ? runtime.hydratedAsyncData.has(key) : false;
920
- const { entry, shouldRefresh } = createAsyncData(
921
- runtime,
922
- keyOrHandler,
923
- handler,
924
- options,
1763
+ /**
1764
+ * Reads the data again every `refreshInterval`, in the browser, following
1765
+ * the interval as it changes: none, `0` or `null` stops it.
1766
+ */
1767
+ function pollAsyncData<T>(
1768
+ runtime: DmsFrontendRuntime,
1769
+ consumer: ShallowRef<AsyncDataConsumer<T>>,
1770
+ options: UseAsyncDataOptions<T>,
1771
+ ): void {
1772
+ const interval = options.refreshInterval;
1773
+ if (runtime.isServer || interval === undefined) return;
1774
+ let timer: ReturnType<typeof setInterval> | undefined;
1775
+ const stop = () => {
1776
+ clearInterval(timer);
1777
+ timer = undefined;
1778
+ };
1779
+ watch(
1780
+ () => toValue(interval),
1781
+ (period) => {
1782
+ stop();
1783
+ if (period && period > 0)
1784
+ timer = setInterval(() => pollOnce(consumer, options), period);
1785
+ },
1786
+ { immediate: true },
925
1787
  );
926
- if (key) runtime.hydratedAsyncData.delete(key);
927
- const pending =
928
- key && entry.pending.value ? runtime.asyncDataPromises.get(key) : undefined;
929
- if (pending) await pending;
930
- if (
931
- (!cached || entry.status.value === "idle" || shouldRefresh) &&
932
- !isHydrated &&
933
- !pending &&
934
- options.immediate !== false
935
- ) {
936
- const execution = entry.execute();
937
- if (key) runtime.asyncDataPromises.set(key, execution);
938
- await execution;
939
- if (key && runtime.asyncDataPromises.get(key) === execution)
940
- runtime.asyncDataPromises.delete(key);
1788
+ if (getCurrentScope()) onScopeDispose(stop);
1789
+ }
1790
+
1791
+ function pollOnce<T>(
1792
+ consumer: ShallowRef<AsyncDataConsumer<T>>,
1793
+ options: UseAsyncDataOptions<T>,
1794
+ ): void {
1795
+ if (typeof document !== "undefined" && document.visibilityState === "hidden")
1796
+ return;
1797
+ const result = consumer.value.entry.result;
1798
+ // Data a caller waits to `execute()` is not read before it is.
1799
+ if (result.status.value === "idle" && options.immediate === false) return;
1800
+ void result.refresh({ dedupe: "defer" });
1801
+ }
1802
+
1803
+ /**
1804
+ * Starts the run a caller needs, where its side needs it, and returns what
1805
+ * awaiting the call waits for.
1806
+ */
1807
+ function startAsyncData<T>(
1808
+ runtime: DmsFrontendRuntime,
1809
+ entry: AsyncDataEntry<T>,
1810
+ options: UseAsyncDataOptions<T>,
1811
+ inComponent: boolean,
1812
+ ): Promise<void> | undefined {
1813
+ const immediate = options.immediate !== false;
1814
+ const onServer = options.server !== false;
1815
+ const idle = entry.result.status.value === "idle";
1816
+ if (runtime.isServer) {
1817
+ if (!immediate || !onServer) return undefined;
1818
+ if (idle && options.lazy) entry.schedule();
1819
+ else if (idle) void entry.load();
1820
+ const settled = entry.settled();
1821
+ if (!inComponent) runtime.serverPrefetches.add(settled);
1822
+ return settled;
941
1823
  }
942
- return entry;
1824
+ // The server's render had the data once the setup awaited it, and before
1825
+ // the component rendered: handed over at the same points, it gives what
1826
+ // the setup derives from it, and so the HTML, the same values.
1827
+ const adoption = entry.adoption();
1828
+ if (adoption) {
1829
+ if (inComponent) onBeforeMount(entry.adoptNow);
1830
+ return adoption;
1831
+ }
1832
+ if (!immediate) return undefined;
1833
+ const waitForData = () =>
1834
+ !options.lazy && entry.result.data.value === null
1835
+ ? entry.settled()
1836
+ : undefined;
1837
+ if (entry.running()) return waitForData();
1838
+ if (!idle) {
1839
+ // Loaded before: shown at once and read again in the background, except
1840
+ // while the page the server rendered hydrates with the server's data, or
1841
+ // when another caller of the page has just read it.
1842
+ if (!runtime.hydrating && !entry.justReadForAnother()) void entry.load();
1843
+ return waitForData();
1844
+ }
1845
+ // The server rendered the default in place of this data: the browser's
1846
+ // first render must show it too, before the request leaves.
1847
+ if (runtime.hydrating && (options.lazy || !onServer)) {
1848
+ if (inComponent) onMounted(() => void entry.load());
1849
+ else entry.schedule();
1850
+ } else if (options.lazy) entry.schedule();
1851
+ else void entry.load();
1852
+ return !options.lazy && entry.result.pending.value
1853
+ ? entry.settled()
1854
+ : undefined;
1855
+ }
1856
+
1857
+ interface AsyncDataConsumer<T> extends AsyncDataBindingHandle {
1858
+ entry: AsyncDataEntry<T>;
1859
+ /** Whether no other component showed the key when this one came. */
1860
+ alone: boolean;
1861
+ }
1862
+
1863
+ /**
1864
+ * Binds a caller to the data of its key. A `null` key has no data to load:
1865
+ * the caller holds its default, and nothing runs until it has a key.
1866
+ */
1867
+ function attachAsyncData<T>(
1868
+ runtime: DmsFrontendRuntime,
1869
+ key: string | null | undefined,
1870
+ handler: DmsAsyncDataHandler<T>,
1871
+ options: UseAsyncDataOptions<T>,
1872
+ inComponent: boolean,
1873
+ ): AsyncDataConsumer<T> {
1874
+ const entry = asyncDataEntry(runtime, key ?? undefined, options, inComponent);
1875
+ const alone = entry.unused();
1876
+ if (key === null) return { entry, alone, unbind() {} };
1877
+ return { entry, alone, ...entry.bind({ handler, options }) };
943
1878
  }
944
1879
 
945
- export function useDmsLazyAsyncData<T>(
946
- keyOrHandler: string | (() => Promise<T>),
947
- handler?: () => Promise<T>,
948
- options: UseAsyncDataOptions<T> = {},
1880
+ /**
1881
+ * Moves a caller to the data of each key its reactive key takes, and returns
1882
+ * refs that follow it there. Until the new key's data is in, the previous
1883
+ * data stays on screen rather than the default flashing in its place: the
1884
+ * caller's refs show it, and `placeholder` says so, while the new key's own
1885
+ * data stays its own, so a run that fails or that a switch away aborts never
1886
+ * leaves it another key's data. A key loaded before shows its own data at
1887
+ * once, and is read again in the background.
1888
+ *
1889
+ * A new key is read where the first one would have been. A server reads it
1890
+ * neither for `server: false` nor for `immediate: false`, and otherwise only
1891
+ * while its render still waits for the caller: until the prefetch of the
1892
+ * caller's component, or, outside a component, as a call made then. Past
1893
+ * that point the server renders the default and the browser reads the key. A
1894
+ * browser hydrating the server's HTML reads a key the server did not render
1895
+ * once the component is mounted, so that its first render matches the HTML.
1896
+ */
1897
+ function followAsyncDataKey<T>(
1898
+ runtime: DmsFrontendRuntime,
1899
+ consumer: ShallowRef<AsyncDataConsumer<T>>,
1900
+ keySource: MaybeRefOrGetter<DmsAsyncDataKey>,
1901
+ handler: DmsAsyncDataHandler<T>,
1902
+ options: UseAsyncDataOptions<T>,
1903
+ inComponent: boolean,
1904
+ serverWaits: () => boolean,
949
1905
  ): DmsAsyncData<T> {
950
- const runtime = useDmsRuntime();
951
- const key = typeof keyOrHandler === "string" ? keyOrHandler : undefined;
952
- const { entry, shouldRefresh } = createAsyncData(
953
- runtime,
954
- keyOrHandler,
955
- handler,
956
- options,
1906
+ // A read held back until the hydrating component is mounted.
1907
+ let held = false;
1908
+ let mounted = !(inComponent && runtime.hydrating);
1909
+ if (!mounted)
1910
+ onMounted(() => {
1911
+ mounted = true;
1912
+ const { entry } = consumer.value;
1913
+ if (held && entry.result.status.value === "idle") void entry.load();
1914
+ held = false;
1915
+ });
1916
+ const startOnServer = (entry: AsyncDataEntry<T>) => {
1917
+ if (
1918
+ options.server === false ||
1919
+ options.immediate === false ||
1920
+ !serverWaits()
1921
+ )
1922
+ return;
1923
+ void entry.load();
1924
+ if (!inComponent) runtime.serverPrefetches.add(entry.settled());
1925
+ };
1926
+ const current = () => consumer.value.entry.result;
1927
+ // The previous key's data, shown until the current key settles.
1928
+ const carried = shallowRef<{ value: T | null } | undefined>();
1929
+ const settled = (result: DmsAsyncData<T>) =>
1930
+ result.status.value === "success" || result.status.value === "error";
1931
+ const shownData = (): T | null => {
1932
+ const result = current();
1933
+ return carried.value && !settled(result)
1934
+ ? carried.value.value
1935
+ : result.data.value;
1936
+ };
1937
+ watch(
1938
+ () => settled(current()),
1939
+ (done) => {
1940
+ if (done) carried.value = undefined;
1941
+ },
1942
+ { flush: "sync" },
957
1943
  );
958
- if (key) runtime.hydratedAsyncData.delete(key);
959
- if (
960
- options.immediate !== false &&
961
- (entry.status.value === "idle" || shouldRefresh)
962
- )
963
- queueMicrotask(() => {
964
- if (
965
- entry.status.value === "idle" ||
966
- (shouldRefresh && !entry.pending.value)
967
- )
968
- void entry.execute();
1944
+ // Data written to the current key from elsewhere (`useDmsData`, another
1945
+ // caller of the key) is its own: it replaces what is carried.
1946
+ watch(
1947
+ () => [current(), current().data.value] as const,
1948
+ ([result], [previous]) => {
1949
+ if (result === previous) carried.value = undefined;
1950
+ },
1951
+ { flush: "sync" },
1952
+ );
1953
+ watch(
1954
+ () => toValue(keySource) ?? null,
1955
+ (key) => {
1956
+ const previous = current();
1957
+ const shown = shownData();
1958
+ const wasRunning = previous.pending.value;
1959
+ // Past the setup, the server's data for the new key, if it rendered
1960
+ // any, is taken over at once.
1961
+ const next = attachAsyncData(runtime, key, handler, options, false);
1962
+ const { entry } = next;
1963
+ const carry =
1964
+ next.alone && entry.result.status.value === "idle" && shown !== null;
1965
+ // Unbound first: the previous key's run, aborted, settles it, which
1966
+ // must not drop what is carried to the next key.
1967
+ consumer.value.unbind();
1968
+ consumer.value = next;
1969
+ carried.value = carry ? { value: shown } : undefined;
1970
+ held = false;
1971
+ // `watch: false` keeps the caller on the key it names, without a run.
1972
+ if (key === null || options.watch === false) return;
1973
+ if (runtime.isServer) return startOnServer(entry);
1974
+ const wanted =
1975
+ options.immediate !== false || shown !== null || wasRunning;
1976
+ if (!wanted) return;
1977
+ if (!mounted) held = true;
1978
+ else if (!inComponent && runtime.hydrating) entry.schedule();
1979
+ else void entry.load();
1980
+ },
1981
+ { flush: "sync" },
1982
+ );
1983
+ const through = <V>(read: (result: DmsAsyncData<T>) => Ref<V>): Ref<V> =>
1984
+ computed({
1985
+ get: () => read(current()).value,
1986
+ set: (value) => {
1987
+ read(current()).value = value;
1988
+ },
969
1989
  });
970
- return entry;
1990
+ return {
1991
+ data: computed({
1992
+ get: shownData,
1993
+ set: (value) => {
1994
+ carried.value = undefined;
1995
+ current().data.value = value;
1996
+ },
1997
+ }),
1998
+ key: computed(() => current().key.value),
1999
+ placeholder: computed(
2000
+ () => carried.value !== undefined && !settled(current()),
2001
+ ),
2002
+ error: through((result) => result.error),
2003
+ pending: computed(() => current().pending.value),
2004
+ status: through((result) => result.status),
2005
+ execute: (refreshOptions) => current().refresh(refreshOptions),
2006
+ refresh: (refreshOptions) => current().refresh(refreshOptions),
2007
+ clear: () => {
2008
+ carried.value = undefined;
2009
+ current().clear();
2010
+ },
2011
+ };
2012
+ }
2013
+
2014
+ /** What a paged read keeps under its key: the pages loaded, in order. */
2015
+ export interface DmsPagedDataValue<TPage, TCursor> {
2016
+ pages: TPage[];
2017
+ /** The cursor of the page after the last one; `null` once there is none. */
2018
+ next: TCursor | null;
2019
+ }
2020
+ /** What a paged read's handler receives. */
2021
+ export interface DmsPagedDataContext<TCursor> extends DmsAsyncDataContext {
2022
+ /** The page to read: `initialCursor` for the first one. */
2023
+ cursor: TCursor | undefined;
2024
+ /** Its position, from `0`. */
2025
+ index: number;
2026
+ }
2027
+ export type DmsPagedDataHandler<TPage, TCursor> = (
2028
+ context: DmsPagedDataContext<TCursor>,
2029
+ ) => Promise<TPage>;
2030
+ export interface UseDmsPagedDataOptions<TPage, TCursor> extends Omit<
2031
+ UseAsyncDataOptions<DmsPagedDataValue<TPage, TCursor>>,
2032
+ "default" | "transform" | "pick"
2033
+ > {
2034
+ /** The cursor of the page after `page`, or `null` (or `undefined`) for none. */
2035
+ nextCursor(page: TPage, pages: TPage[]): TCursor | null | undefined;
2036
+ /** The first page's cursor; none by default. */
2037
+ initialCursor?: TCursor;
2038
+ }
2039
+ export interface DmsPagedData<TPage, TCursor> extends DmsAsyncData<
2040
+ DmsPagedDataValue<TPage, TCursor>
2041
+ > {
2042
+ /** The pages loaded so far, in order; none before the first answer. */
2043
+ pages: Readonly<Ref<TPage[]>>;
2044
+ /** Whether `loadMore()` has a page to read. */
2045
+ hasMore: Readonly<Ref<boolean>>;
2046
+ /** `true` while `loadMore()` reads its page. */
2047
+ loadingMore: Readonly<Ref<boolean>>;
2048
+ /**
2049
+ * Reads the next page and appends it under the same key. It waits for a
2050
+ * read in flight first, does nothing once there is no next page, and puts
2051
+ * a failure in `error`, keeping the pages loaded.
2052
+ */
2053
+ loadMore(): Promise<void>;
2054
+ }
2055
+ export type DmsPagedDataResult<TPage, TCursor> = DmsPagedData<TPage, TCursor> &
2056
+ Promise<DmsPagedData<TPage, TCursor>>;
2057
+
2058
+ type PagedValue<TPage, TCursor> = DmsPagedDataValue<TPage, TCursor>;
2059
+
2060
+ /** Reads `count` pages from the first, as long as each has a next one. */
2061
+ async function readPages<TPage, TCursor>(
2062
+ handler: DmsPagedDataHandler<TPage, TCursor>,
2063
+ options: UseDmsPagedDataOptions<TPage, TCursor>,
2064
+ count: number,
2065
+ signal: AbortSignal,
2066
+ ): Promise<PagedValue<TPage, TCursor>> {
2067
+ const pages: TPage[] = [];
2068
+ let cursor: TCursor | undefined = options.initialCursor;
2069
+ for (let index = 0; index < count; index++) {
2070
+ const page = await handler({ cursor, index, signal });
2071
+ pages.push(page);
2072
+ const next = options.nextCursor(page, pages);
2073
+ if (next == null) return { pages, next: null };
2074
+ cursor = next;
2075
+ }
2076
+ return { pages, next: cursor ?? null };
2077
+ }
2078
+
2079
+ /** Resolves once `result` has no run in flight. */
2080
+ function untilSettled<T>(result: DmsAsyncData<T>): Promise<void> {
2081
+ if (!result.pending.value) return Promise.resolve();
2082
+ return new Promise((resolve) => {
2083
+ const stop = watch(result.pending, (pending) => {
2084
+ if (pending) return;
2085
+ stop();
2086
+ resolve();
2087
+ });
2088
+ });
2089
+ }
2090
+
2091
+ /**
2092
+ * A list read page by page under one key: "load more", an infinite list.
2093
+ * The first page loads as `useDmsAsyncData` loads its data (a server render
2094
+ * waits for it and transfers it), and `loadMore()` appends the next one to
2095
+ * the same key, so the pages a component loaded show at once on a return to
2096
+ * it. A revalidation (a mount, `refresh()`, `refreshDmsPageData()`, the
2097
+ * polling) reads every page loaded so far again, from the first, and
2098
+ * replaces them once all are in. Put what filters the list in the key: a new
2099
+ * key starts again from its first page.
2100
+ *
2101
+ * @param key The key, or a ref or getter for it
2102
+ * @param handler Reads one page, from the cursor it receives
2103
+ * @param options `nextCursor`, `initialCursor` and the async data options
2104
+ */
2105
+ export function useDmsPagedData<TPage, TCursor = unknown>(
2106
+ key: MaybeRefOrGetter<DmsAsyncDataKey>,
2107
+ handler: DmsPagedDataHandler<TPage, TCursor>,
2108
+ options: UseDmsPagedDataOptions<TPage, TCursor>,
2109
+ ): DmsPagedDataResult<TPage, TCursor> {
2110
+ const {
2111
+ nextCursor: _next,
2112
+ initialCursor: _initial,
2113
+ ...asyncOptions
2114
+ } = options;
2115
+ let shown: DmsAsyncData<PagedValue<TPage, TCursor>> | undefined;
2116
+ const loadedCount = () =>
2117
+ shown && !shown.placeholder.value
2118
+ ? Math.max(1, shown.data.value?.pages.length ?? 0)
2119
+ : 1;
2120
+ const call = useDmsAsyncData<PagedValue<TPage, TCursor>>(
2121
+ key,
2122
+ ({ signal }) => readPages(handler, options, loadedCount(), signal),
2123
+ asyncOptions,
2124
+ );
2125
+ shown = call;
2126
+ const result: DmsPagedData<TPage, TCursor> = Object.assign(
2127
+ {},
2128
+ call as DmsAsyncData<PagedValue<TPage, TCursor>>,
2129
+ pagedControls(call, handler, options),
2130
+ );
2131
+ return Object.assign(
2132
+ call.then(() => result),
2133
+ result,
2134
+ );
2135
+ }
2136
+
2137
+ function pagedControls<TPage, TCursor>(
2138
+ call: DmsAsyncData<PagedValue<TPage, TCursor>>,
2139
+ handler: DmsPagedDataHandler<TPage, TCursor>,
2140
+ options: UseDmsPagedDataOptions<TPage, TCursor>,
2141
+ ) {
2142
+ const loadingMore = ref(false);
2143
+ let controller: AbortController | undefined;
2144
+ const abort = () => {
2145
+ controller?.abort();
2146
+ controller = undefined;
2147
+ loadingMore.value = false;
2148
+ };
2149
+ watch(call.key, abort);
2150
+ if (getCurrentScope()) onScopeDispose(abort);
2151
+ const hasMore = computed(
2152
+ () => !call.placeholder.value && (call.data.value?.next ?? null) !== null,
2153
+ );
2154
+ const loadMore = async (): Promise<void> => {
2155
+ if (call.pending.value) await untilSettled(call);
2156
+ const before = call.data.value;
2157
+ if (!before) return call.execute();
2158
+ if (before.next === null || controller) return;
2159
+ const current = new AbortController();
2160
+ controller = current;
2161
+ loadingMore.value = true;
2162
+ try {
2163
+ const page = await handler({
2164
+ cursor: before.next,
2165
+ index: before.pages.length,
2166
+ signal: current.signal,
2167
+ });
2168
+ if (controller !== current || call.data.value !== before) return;
2169
+ const pages = [...before.pages, page];
2170
+ call.data.value = {
2171
+ pages,
2172
+ next: options.nextCursor(page, pages) ?? null,
2173
+ };
2174
+ } catch (reason) {
2175
+ if (controller === current) call.error.value = reason;
2176
+ } finally {
2177
+ if (controller === current) abort();
2178
+ }
2179
+ };
2180
+ return {
2181
+ pages: computed(() => call.data.value?.pages ?? []),
2182
+ hasMore,
2183
+ loadingMore: readonly(loadingMore),
2184
+ loadMore,
2185
+ };
2186
+ }
2187
+
2188
+ /**
2189
+ * The data another caller loads under `key`, as Nuxt's `useNuxtData`: to
2190
+ * update it in place without reading it again, from what a write answered
2191
+ * (an optimistic update) or what a realtime event says changed. It follows
2192
+ * the key in the runtime's scope (`setDmsAsyncDataScope`): `undefined` until
2193
+ * a caller loads the key, its data once there, `undefined` again once the
2194
+ * browser lets the key go (past the most recent keys it keeps) or the scope
2195
+ * changes. It does not load the key, nor keep it.
2196
+ */
2197
+ export function useDmsData<T>(
2198
+ key: MaybeRefOrGetter<DmsAsyncDataKey>,
2199
+ ): DmsData<T> {
2200
+ const runtime = useDmsRuntime();
2201
+ const entry = () => {
2202
+ const name = toValue(key);
2203
+ const result =
2204
+ name == null
2205
+ ? undefined
2206
+ : runtime.asyncData.get(scopedKey(runtime, String(name)));
2207
+ return result && asyncDataEntries.get(result);
2208
+ };
2209
+ // A component after the caller, in the page or in its subtree, renders the
2210
+ // data the browser hydrates it with: the server waits for the caller's run
2211
+ // too. One before it has none on either side. Whether the key is there as
2212
+ // the setup runs is the same on both sides, and so are the async
2213
+ // boundaries `useId()` counts.
2214
+ const loaded = entry();
2215
+ if (loaded && getCurrentInstance()) onServerPrefetch(() => loaded.settled());
2216
+ return {
2217
+ data: computed({
2218
+ get() {
2219
+ // An entry only ever holds its own key's data: a caller showing the
2220
+ // previous key's data meanwhile keeps it in its own refs.
2221
+ return (entry()?.result.data.value ?? undefined) as T | undefined;
2222
+ },
2223
+ set(value) {
2224
+ const current = entry();
2225
+ if (!current) return;
2226
+ current.result.data.value = value ?? null;
2227
+ },
2228
+ }),
2229
+ };
971
2230
  }
972
2231
 
2232
+ /** Reads the data of the keys given, or of every key, again. */
973
2233
  export async function refreshDmsData(keys?: string | string[]): Promise<void> {
974
2234
  const runtime = useDmsRuntime();
975
2235
  const selected = keys
976
- ? Array.isArray(keys)
977
- ? keys
978
- : [keys]
2236
+ ? (Array.isArray(keys) ? keys : [keys]).map((key) =>
2237
+ scopedKey(runtime, key),
2238
+ )
979
2239
  : [...runtime.asyncData.keys()];
980
2240
  await Promise.all(
981
2241
  selected.map((key) => runtime.asyncData.get(key)?.refresh()),
982
2242
  );
983
2243
  }
984
2244
 
2245
+ /**
2246
+ * Reads again the data the components on screen show, but those that opted
2247
+ * out (`refreshOnPageRefresh: false`) and those waiting for `execute()`:
2248
+ * what a page calls once something changed what it shows, after a save or
2249
+ * on a realtime event, instead of each component refreshing its own.
2250
+ */
2251
+ export async function refreshDmsPageData(): Promise<void> {
2252
+ const runtime = useDmsRuntime();
2253
+ await Promise.all(
2254
+ [...runtime.boundAsyncData].map((entry) => entry.pageRefresh()),
2255
+ );
2256
+ }
2257
+
2258
+ /**
2259
+ * Puts the keys of the async data in a scope, such as a digest of the
2260
+ * account and the workspace: the same key in another scope is another
2261
+ * entry, never the data of the previous one. Moving to another scope drops
2262
+ * the data of every key, aborting its runs; the components on screen then
2263
+ * show their default until they load again. Call it on both sides before
2264
+ * the page renders, with the same value, so the browser finds the data the
2265
+ * server transferred; the scope is written in the page with each key, so a
2266
+ * digest rather than an identifier.
2267
+ *
2268
+ * @param scope The scope, or `undefined` for none
2269
+ */
2270
+ export function setDmsAsyncDataScope(scope: string | undefined): void {
2271
+ const runtime = useDmsRuntime();
2272
+ if (runtime.asyncDataScope === scope) return;
2273
+ runtime.asyncDataScope = scope;
2274
+ for (const result of runtime.asyncData.values()) result.clear();
2275
+ runtime.asyncData.clear();
2276
+ }
2277
+
2278
+ /**
2279
+ * Drops the data no component shows any more, but the most recently shown:
2280
+ * a return to a key shows its data at once, and the data does not pile up
2281
+ * over a session.
2282
+ */
2283
+ function dropUnusedAsyncData(runtime: DmsFrontendRuntime): void {
2284
+ const unused = [...runtime.asyncData]
2285
+ .map(([key, result]) => ({ key, entry: asyncDataEntries.get(result) }))
2286
+ .filter(({ entry }) => entry?.unused())
2287
+ .sort((left, right) => right.entry!.lastShown() - left.entry!.lastShown());
2288
+ for (const { key } of unused.slice(MAX_UNUSED_ASYNC_DATA))
2289
+ runtime.asyncData.delete(key);
2290
+ }
2291
+
985
2292
  export function serializeDmsAsyncData(
986
2293
  runtime: DmsFrontendRuntime,
987
2294
  ): Record<string, unknown> {
988
2295
  return Object.fromEntries(
989
2296
  [...runtime.asyncData.entries()]
990
2297
  .filter(([, entry]) => entry.status.value === "success")
991
- .map(([key, entry]) => [key, entry.data.value]),
2298
+ .map(([key, entry]) => [key, toRaw(entry.data.value)]),
992
2299
  );
993
2300
  }
994
2301
 
995
- export function useDmsState<T>(key: string, init?: () => T): Ref<T> {
2302
+ const HTTP_ERROR_STATUS = { min: 400, max: 599 };
2303
+ const SERVER_FAILURE_STATUS = 500;
2304
+ const FETCH_ERROR_NAME = "FetchError";
2305
+
2306
+ /** The fields of a failure, under the names `createDmsError` and ofetch use. */
2307
+ interface DmsFailure extends DmsErrorData {
2308
+ name?: string;
2309
+ status?: number;
2310
+ statusText?: string;
2311
+ }
2312
+
2313
+ /**
2314
+ * What of a failure a page may carry, by the rule the error page follows: an
2315
+ * HTTP failure keeps its status, status text and message, and its body if it
2316
+ * is a 4xx, anything else
2317
+ * is a server failure whose cause only reaches the server log. A fetch error
2318
+ * names the URL it requested, which on a server is the backend's own address:
2319
+ * its status text stands for it.
2320
+ */
2321
+ function transferredError(key: string, reason: unknown): DmsErrorData {
2322
+ const failure = (reason ?? {}) as DmsFailure;
2323
+ const statusCode = failure.statusCode ?? failure.status;
2324
+ if (
2325
+ statusCode === undefined ||
2326
+ !Number.isInteger(statusCode) ||
2327
+ statusCode < HTTP_ERROR_STATUS.min ||
2328
+ statusCode > HTTP_ERROR_STATUS.max
2329
+ ) {
2330
+ console.error(`DMS async data "${key}" failed`, reason);
2331
+ return { statusCode: SERVER_FAILURE_STATUS };
2332
+ }
2333
+ const statusMessage = failure.statusMessage ?? failure.statusText;
2334
+ const transferred: DmsErrorData = {
2335
+ statusCode,
2336
+ statusMessage,
2337
+ message:
2338
+ failure.name === FETCH_ERROR_NAME ? statusMessage : failure.message,
2339
+ };
2340
+ // A 5xx body is the backend's own account of its failure, a stack or a
2341
+ // query at times: only a 4xx body, written for the client, is transferred.
2342
+ if (statusCode < SERVER_FAILURE_STATUS) transferred.data = failure.data;
2343
+ return transferred;
2344
+ }
2345
+
2346
+ /**
2347
+ * What a page's error page shows of an expected failure (a 4xx or 5xx): its
2348
+ * status code, status text and message. A fetch error's message names the URL
2349
+ * it requested, on a server the backend's own address: its status text
2350
+ * stands for it.
2351
+ */
2352
+ export function pageErrorData(failure: Error & DmsErrorData): DmsErrorData {
2353
+ return {
2354
+ statusCode: failure.statusCode,
2355
+ message:
2356
+ failure.name === FETCH_ERROR_NAME
2357
+ ? failure.statusMessage
2358
+ : failure.message,
2359
+ statusMessage: failure.statusMessage,
2360
+ };
2361
+ }
2362
+
2363
+ /**
2364
+ * Everything a server render hands the browser: the data and the failures of
2365
+ * its keys, and the state keys opted into. Read as the HTML is complete.
2366
+ */
2367
+ export function serializeDmsPayload(runtime: DmsFrontendRuntime): DmsPayload {
2368
+ const errors = [...runtime.asyncData.entries()]
2369
+ .filter(([, entry]) => entry.status.value === "error")
2370
+ .map(([key, entry]): [string, DmsErrorData] => [
2371
+ key,
2372
+ transferredError(key, entry.error.value),
2373
+ ]);
2374
+ const state = [...runtime.transferredState]
2375
+ .map((key): [string, unknown] => [
2376
+ key,
2377
+ toRaw(runtime.sharedState.get(key)?.value),
2378
+ ])
2379
+ .filter(([, value]) => value !== undefined);
2380
+ return {
2381
+ data: serializeDmsAsyncData(runtime),
2382
+ errors: Object.fromEntries(errors),
2383
+ state: Object.fromEntries(state),
2384
+ };
2385
+ }
2386
+
2387
+ /** Starts a browser runtime from what the server rendered. */
2388
+ export function restoreDmsPayload(
2389
+ runtime: DmsFrontendRuntime,
2390
+ payload: Partial<DmsPayload>,
2391
+ ): void {
2392
+ runtime.hydrating = true;
2393
+ for (const [key, value] of Object.entries(payload.data ?? {}))
2394
+ runtime.hydratedAsyncData.set(key, value);
2395
+ for (const [key, failure] of Object.entries(payload.errors ?? {}))
2396
+ runtime.hydratedAsyncErrors.set(key, failure);
2397
+ for (const [key, value] of Object.entries(payload.state ?? {}))
2398
+ runtime.sharedState.set(key, ref(value));
2399
+ }
2400
+
2401
+ export function useDmsState<T>(
2402
+ key: string,
2403
+ init?: () => T,
2404
+ options: DmsStateOptions = {},
2405
+ ): Ref<T> {
996
2406
  const runtime = useDmsRuntime();
2407
+ if (options.transfer) runtime.transferredState.add(key);
997
2408
  if (!runtime.sharedState.has(key))
998
2409
  runtime.sharedState.set(key, ref(init?.()));
999
2410
  return runtime.sharedState.get(key) as Ref<T>;
@@ -1004,7 +2415,7 @@ export function useDmsRuntimeConfig(): DmsRuntimeConfig {
1004
2415
  export function useDmsAppConfig(): DmsAppConfig {
1005
2416
  return appConfig.value as DmsAppConfig;
1006
2417
  }
1007
- export function defineAppConfig<T extends Record<string, unknown>>(
2418
+ export function defineDmsAppConfig<T extends Record<string, unknown>>(
1008
2419
  config: T,
1009
2420
  ): T {
1010
2421
  return config;
@@ -1015,18 +2426,34 @@ export function defineDmsMiddleware(handler: DmsMiddleware): DmsMiddleware {
1015
2426
  export function defineDmsPageMeta(meta: Record<string, unknown>): void {
1016
2427
  const runtime = useDmsRuntime();
1017
2428
  Object.assign(runtime.route.meta, meta);
1018
- const navigation = runMiddleware(runtime, runtime.route).then(
1019
- async (result) => {
2429
+ // A middleware that throws fails the page, as in Nuxt: the error page
2430
+ // shows in its place with the error's status code, which a server render
2431
+ // answers with. A redirect another navigation decided still wins.
2432
+ const navigation = runMiddleware(runtime, runtime.route)
2433
+ .then(async (result) => {
1020
2434
  if (result !== undefined && result !== false)
1021
2435
  await visit(runtime, result);
1022
- },
1023
- );
2436
+ })
2437
+ .catch((reason) => failPage(runtime, reason));
1024
2438
  runtime.pendingNavigation = runtime.pendingNavigation
1025
2439
  ? Promise.all([runtime.pendingNavigation, navigation]).then(() => undefined)
1026
2440
  : navigation;
1027
2441
  void runtime.pendingNavigation;
1028
- }
1029
- export function abortNavigation(): false {
2442
+ // A server renders the page's subtree once its middleware is done, so the
2443
+ // state a middleware sets is the one the HTML shows and the payload carries.
2444
+ // A failure shows the error page in its place (`failPage`). Registered
2445
+ // in the browser too, where only a server calls it: both sides must agree
2446
+ // on the async boundaries `useId()` counts.
2447
+ if (getCurrentInstance())
2448
+ onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
2449
+ }
2450
+ function ignoreSettlement(): void {}
2451
+ /** Shows the error page in place of the page, keeping the first failure. */
2452
+ function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
2453
+ runtime.currentError.value ??=
2454
+ reason instanceof Error ? reason : new Error(String(reason));
2455
+ }
2456
+ export function abortDmsNavigation(): false {
1030
2457
  return false;
1031
2458
  }
1032
2459
  export const useHead = useUnhead;
@@ -1250,7 +2677,7 @@ export function useDmsCookie<T = string | null>(
1250
2677
  return runtime.sharedState.get(key) as Ref<T>;
1251
2678
  }
1252
2679
 
1253
- export function createError(
2680
+ export function createDmsError(
1254
2681
  input: string | DmsErrorData,
1255
2682
  ): Error & DmsErrorData {
1256
2683
  const details = typeof input === "string" ? { message: input } : input;
@@ -1259,22 +2686,59 @@ export function createError(
1259
2686
  details,
1260
2687
  );
1261
2688
  }
1262
- export function showError(input: string | DmsErrorData): Error & DmsErrorData {
1263
- const error = createError(input);
2689
+ export function showDmsError(
2690
+ input: string | DmsErrorData,
2691
+ ): Error & DmsErrorData {
2692
+ const error = createDmsError(input);
1264
2693
  useDmsRuntime().currentError.value = error;
1265
2694
  return error;
1266
2695
  }
1267
- export function useError(): Ref<(Error & DmsErrorData) | null> {
2696
+ export function useDmsError(): Ref<(Error & DmsErrorData) | null> {
1268
2697
  return useDmsRuntime().currentError as Ref<(Error & DmsErrorData) | null>;
1269
2698
  }
1270
- export async function clearError(options?: {
2699
+ export async function clearDmsError(options?: {
1271
2700
  redirect?: string;
1272
2701
  }): Promise<void> {
1273
2702
  useDmsRuntime().currentError.value = null;
1274
2703
  if (options?.redirect) await navigateDms(options.redirect);
1275
2704
  }
1276
2705
 
1277
- export function useUserSession<
2706
+ function sessionIdentity(session: unknown): string {
2707
+ const { accountId, activeTenantId } = (session ?? {}) as Partial<DmsSession>;
2708
+ return `${accountId ?? ""}\n${activeTenantId ?? ""}`;
2709
+ }
2710
+
2711
+ /**
2712
+ * Drops the pages Inertia prefetched (a hovered link) once the session is
2713
+ * another account's or workspace's, or none: each carries the user, the page
2714
+ * and the site layout of the session it was fetched with, and a visit would
2715
+ * show them as current.
2716
+ */
2717
+ function forgetPrefetchedPages(): void {
2718
+ if (typeof window !== "undefined") inertiaRouter.flushAll();
2719
+ }
2720
+
2721
+ /**
2722
+ * Warms what a prefetched page renders (its page, layout and block
2723
+ * components) once Inertia has its props, so following the link does not wait
2724
+ * for those chunks after the props.
2725
+ */
2726
+ export function preloadPrefetchedDmsPages(): () => void {
2727
+ return inertiaRouter.on("prefetched", (event) => {
2728
+ const data = event.detail.response.data as unknown;
2729
+ let page: { props?: DmsPageProps } | undefined;
2730
+ try {
2731
+ page = (typeof data === "string" ? JSON.parse(data) : data) as
2732
+ | { props?: DmsPageProps }
2733
+ | undefined;
2734
+ } catch {
2735
+ return;
2736
+ }
2737
+ if (page?.props?.page) void preloadDmsPage(page.props).catch(() => {});
2738
+ });
2739
+ }
2740
+
2741
+ export function useDmsUserSession<
1278
2742
  TUser = DmsUser,
1279
2743
  TSession = DmsSession,
1280
2744
  >(): DmsUserSession<TUser, TSession> {
@@ -1285,17 +2749,20 @@ export function useUserSession<
1285
2749
  session,
1286
2750
  loggedIn: computed(() => user.value !== null),
1287
2751
  fetch: async () => {
2752
+ const before = sessionIdentity(session.value);
1288
2753
  const value = await ofetch<{
1289
2754
  user?: TUser;
1290
2755
  session?: TSession;
1291
2756
  }>("/api/_auth/session", { method: "POST" });
1292
2757
  user.value = value.user ?? null;
1293
2758
  session.value = value.session ?? null;
2759
+ if (sessionIdentity(session.value) !== before) forgetPrefetchedPages();
1294
2760
  },
1295
2761
  clear: async () => {
1296
2762
  await ofetch("/api/_auth/session", { method: "DELETE" });
1297
2763
  user.value = null;
1298
2764
  session.value = null;
2765
+ forgetPrefetchedPages();
1299
2766
  },
1300
2767
  };
1301
2768
  }
@@ -1314,9 +2781,15 @@ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
1314
2781
  * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
1315
2782
  * in step with it. Cleared before the boundary unmounts, so a page left while
1316
2783
  * pending never leaves the state set.
2784
+ *
2785
+ * They also end the hydration: the page is on screen once its suspense
2786
+ * resolves, and the components after it once the tick is over, so the
2787
+ * browser has then hydrated what the server rendered. A hydrating suspense
2788
+ * never reports itself pending: the server rendered the page resolved.
1317
2789
  */
1318
2790
  export function dmsPageSuspenseProps(): Record<string, () => void> {
1319
- const loading = useDmsRuntime().pageLoading;
2791
+ const runtime = useDmsRuntime();
2792
+ const loading = runtime.pageLoading;
1320
2793
  const settle = () => {
1321
2794
  loading.value = false;
1322
2795
  };
@@ -1324,7 +2797,10 @@ export function dmsPageSuspenseProps(): Record<string, () => void> {
1324
2797
  onPending: () => {
1325
2798
  loading.value = true;
1326
2799
  },
1327
- onResolve: settle,
2800
+ onResolve: () => {
2801
+ settle();
2802
+ if (runtime.hydrating) void nextTick(() => endHydration(runtime));
2803
+ },
1328
2804
  onVnodeBeforeUnmount: settle,
1329
2805
  };
1330
2806
  }
@@ -1381,7 +2857,6 @@ export function registerDmsComponent(name: string, component: Component): void {
1381
2857
  export function resolveDmsComponent(name: string): Component | undefined {
1382
2858
  return registry.components.get(normalizeDmsComponentName(name))?.component;
1383
2859
  }
1384
- export const getDmsComponent = resolveDmsComponent;
1385
2860
 
1386
2861
  /**
1387
2862
  * Records, by registered name, every registered async component a server
@@ -1445,7 +2920,7 @@ export async function resolveDmsAsyncComponents(
1445
2920
  ),
1446
2921
  );
1447
2922
  }
1448
- export async function preloadComponents(names: string[]): Promise<void> {
2923
+ export async function preloadDmsComponents(names: string[]): Promise<void> {
1449
2924
  const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
1450
2925
  await Promise.all(
1451
2926
  names.map((name) =>
@@ -1453,7 +2928,6 @@ export async function preloadComponents(names: string[]): Promise<void> {
1453
2928
  ),
1454
2929
  );
1455
2930
  }
1456
- export const prefetchComponents = preloadComponents;
1457
2931
  export function normalizeDmsName(name: string): string {
1458
2932
  return name
1459
2933
  .replace(/^lazy/i, "")
@@ -1477,6 +2951,9 @@ function collectRenderedComponentNames(
1477
2951
  for (const key of ["component", "componentName"]) {
1478
2952
  if (typeof definition[key] === "string") names.add(definition[key]);
1479
2953
  }
2954
+ // A child places a component definition: `{ id, component: { … } }`.
2955
+ if (typeof definition.component === "object")
2956
+ collectRenderedComponentNames(definition.component, names);
1480
2957
  collectRenderedComponentNames(definition.children, names);
1481
2958
  }
1482
2959
 
@@ -1554,7 +3031,7 @@ export function getDmsLayoutProps(
1554
3031
  * Whether a page carries an older session of the same account than the one
1555
3032
  * the app holds: Inertia restores a page from history, or serves it from the
1556
3033
  * prefetch cache, with the props it was fetched with, and those must not undo
1557
- * a later `useUserSession().fetch()`.
3034
+ * a later `useDmsUserSession().fetch()`.
1558
3035
  */
1559
3036
  function isStaleSession(
1560
3037
  incoming: DmsSession | undefined,
@@ -1568,6 +3045,61 @@ function isStaleSession(
1568
3045
  );
1569
3046
  }
1570
3047
 
3048
+ /**
3049
+ * Copies the site layout of the page props into the state. A visit carries
3050
+ * the layout it was sent over from page to page (a once prop): the same copy
3051
+ * is applied once, so the state keeps what a later refresh wrote into it, and
3052
+ * `dms-lastRefreshTime` tells when the layout was last received.
3053
+ */
3054
+ export function hydrateDmsSharedProps(
3055
+ shared: DmsSharedPagePayload | undefined,
3056
+ ): void {
3057
+ const runtime = useDmsRuntime();
3058
+ if (!shared || shared === runtime.appliedShared) return;
3059
+ // A page from the history carries the layout it was shown with, which the
3060
+ // state has replaced since: see `keepSharedOverHistory`.
3061
+ if (runtime.restoringHistory) return;
3062
+ runtime.appliedShared = shared;
3063
+ useDmsState<unknown>("dms-siteLayout", () => undefined).value =
3064
+ shared.siteLayout;
3065
+ useDmsState<unknown>("dms-pageTree", () => undefined).value =
3066
+ shared.siteLayoutTree;
3067
+ useDmsState<unknown>("dms-quickActions", () => undefined).value =
3068
+ shared.quickActions;
3069
+ useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
3070
+ useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
3071
+ useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
3072
+ }
3073
+
3074
+ /**
3075
+ * Keeps the newest site layout when Back or Forward puts a page back. The
3076
+ * page carries the layout it was shown with, which a later reload replaced
3077
+ * (a menu change the backend announced); it is not copied into the state, and
3078
+ * the page is given the current one, so the next visit carries that over
3079
+ * rather than the older one.
3080
+ */
3081
+ export function keepSharedOverHistory(): () => void {
3082
+ const runtime = useDmsRuntime();
3083
+ const onPopstate = (event: PopStateEvent) => {
3084
+ // Inertia restores only the entries it wrote, which hold a page.
3085
+ if ((event.state as { page?: unknown } | null)?.page)
3086
+ runtime.restoringHistory = true;
3087
+ };
3088
+ window.addEventListener("popstate", onPopstate);
3089
+ const stopListening = inertiaRouter.on("navigate", (event) => {
3090
+ if (!runtime.restoringHistory) return;
3091
+ runtime.restoringHistory = false;
3092
+ const current = runtime.appliedShared;
3093
+ const restored = (event.detail.page.props as Partial<DmsPageProps>).shared;
3094
+ if (current && restored !== current)
3095
+ inertiaRouter.replaceProp("shared", current);
3096
+ });
3097
+ return () => {
3098
+ window.removeEventListener("popstate", onPopstate);
3099
+ stopListening();
3100
+ };
3101
+ }
3102
+
1571
3103
  export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1572
3104
  const runtime = useDmsRuntime();
1573
3105
  if (url) updateRoute(runtime, url);
@@ -1579,7 +3111,7 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1579
3111
  runtime.pageKey = pageKey;
1580
3112
  runtime.route.meta = {};
1581
3113
  }
1582
- runtime.pageVersion++;
3114
+ dropUnusedAsyncData(runtime);
1583
3115
  runtime.currentError.value = null;
1584
3116
  const session = useDmsState<DmsSession | null>("dms-session", () => null);
1585
3117
  if (!isStaleSession(props.session, session.value)) {
@@ -1587,18 +3119,7 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1587
3119
  props.user ?? null;
1588
3120
  session.value = props.session ?? null;
1589
3121
  }
1590
- const shared = props.page.shared;
1591
- if (shared) {
1592
- useDmsState<unknown>("dms-siteLayout", () => undefined).value =
1593
- shared.siteLayout;
1594
- useDmsState<unknown>("dms-pageTree", () => undefined).value =
1595
- shared.siteLayoutTree;
1596
- useDmsState<unknown>("dms-quickActions", () => undefined).value =
1597
- shared.quickActions;
1598
- useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
1599
- useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
1600
- useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
1601
- }
3122
+ hydrateDmsSharedProps(props.shared);
1602
3123
  const layoutUrl = props.page.route?.layoutUrl ?? props.page.layoutUrl;
1603
3124
  const pageLayouts = useDmsState<Record<string, DmsPageLayout>>(
1604
3125
  "dms-pageLayouts",
@@ -1609,6 +3130,16 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1609
3130
  pageLayouts.value =
1610
3131
  layoutUrl && props.page.layout ? { [layoutUrl]: props.page.layout } : {};
1611
3132
  }
3133
+ /**
3134
+ * Ends the hydration of the page the server rendered. The server's data no
3135
+ * component took over is dropped: a later page must not show it as current.
3136
+ */
3137
+ function endHydration(runtime: DmsFrontendRuntime): void {
3138
+ runtime.hydrating = false;
3139
+ runtime.hydratedAsyncData.clear();
3140
+ runtime.hydratedAsyncErrors.clear();
3141
+ }
3142
+
1612
3143
  export function getDmsErrorPage(): Component | undefined {
1613
3144
  return registry.errorPage?.component;
1614
3145
  }
@@ -1694,6 +3225,7 @@ function createSdk(
1694
3225
  if (!target.namedMiddleware.has(name))
1695
3226
  target.namedMiddleware.set(name, handler);
1696
3227
  if (options.global) target.middleware.push(handler);
3228
+ if (options.parallel) target.parallelMiddleware.add(handler);
1697
3229
  },
1698
3230
  registerAccessRedirect: (code, path) => {
1699
3231
  if (!target.accessRedirects.has(code))
@@ -1717,16 +3249,22 @@ function createSdk(
1717
3249
  * replace, so the server kept rendering the old ones, and every list gained the
1718
3250
  * same entries once more. A render still in progress keeps reading a complete
1719
3251
  * registry: the new one is only published once every module is set up.
3252
+ *
3253
+ * `useDmsAppConfig()` holds the layers' app configs merged, highest precedence
3254
+ * first, before the first module is set up: a project's layer overrides the
3255
+ * DMS's, which fills in the rest. A setup may still read and extend it.
1720
3256
  */
1721
3257
  export async function setupFrontendModules(
1722
3258
  registrations: DmsFrontendModuleRegistration[],
3259
+ layerAppConfigs: DmsLayerAppConfig[] = [],
1723
3260
  ): Promise<void> {
1724
3261
  const next = createDmsFrontendRegistry();
1725
- // Modules read and extend these two while they are set up (the DMS layout
1726
- // merges its app config into `useDmsAppConfig()`), so they restart empty
1727
- // here rather than being published with the registry.
3262
+ const configs = layerAppConfigs.map(({ config }) => config);
3263
+ // Modules read and extend these two while they are set up, so they restart
3264
+ // here rather than being published with the registry: the public options
3265
+ // empty, the app config from the layers alone.
1728
3266
  runtimeConfig.value.public = {} as PublicRuntimeConfig;
1729
- appConfig.value = {};
3267
+ appConfig.value = mergeLayerAppConfigs(configs);
1730
3268
  for (const registration of registrations) {
1731
3269
  runtimeConfig.value.public = defu(
1732
3270
  runtimeConfig.value.public,
@@ -1734,9 +3272,29 @@ export async function setupFrontendModules(
1734
3272
  );
1735
3273
  await registration.module.setup(createSdk(next, registration));
1736
3274
  }
3275
+ appConfigSetupChanges = appConfigChanges(
3276
+ mergeLayerAppConfigs(configs),
3277
+ appConfig.value,
3278
+ );
1737
3279
  registry = next;
1738
3280
  }
1739
3281
 
3282
+ /**
3283
+ * Development only: applies the layers' app configs, edited while the page
3284
+ * is open, to `useDmsAppConfig()`, with what the setup of the modules changed
3285
+ * in the previous ones still on top. The object is updated in place, records
3286
+ * included, since components hold it: the modules are not set up again.
3287
+ */
3288
+ export function updateDmsAppConfig(layerAppConfigs: DmsLayerAppConfig[]): void {
3289
+ const layered = mergeLayerAppConfigs(
3290
+ layerAppConfigs.map(({ config }) => config),
3291
+ );
3292
+ assignInPlace(
3293
+ appConfig.value,
3294
+ withAppConfigChanges(layered, appConfigSetupChanges),
3295
+ );
3296
+ }
3297
+
1740
3298
  export async function installDmsPlugins(
1741
3299
  app: App,
1742
3300
  i18n: Composer,
@@ -1769,10 +3327,7 @@ export async function installDmsPlugins(
1769
3327
  if (browserRuntime === runtime) browserRuntime = undefined;
1770
3328
  });
1771
3329
  }
1772
- app.config.errorHandler = (reason) => {
1773
- runtime.currentError.value ??=
1774
- reason instanceof Error ? reason : new Error(String(reason));
1775
- };
3330
+ app.config.errorHandler = (reason) => failPage(runtime, reason);
1776
3331
  app.config.globalProperties.$dms = appContext;
1777
3332
  Reflect.set(app, "$dms", appContext);
1778
3333
  Reflect.set(app, "$i18n", i18n);
@@ -1812,6 +3367,9 @@ export async function installDmsPlugins(
1812
3367
  app.runWithContext(() => registration.setup(appContext)),
1813
3368
  );
1814
3369
  }
3370
+ // What plugins start without awaiting it is still part of the page a
3371
+ // server renders: its HTML shows it, so the render waits for it.
3372
+ if (runtime.isServer) await Promise.all(runtime.serverPrefetches);
1815
3373
  return async () => {
1816
3374
  for (const callback of hooks.get("app:mounted") ?? []) await callback();
1817
3375
  };