@tinvar/react 0.0.0-stage → 0.4.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
package/src/router.js ADDED
@@ -0,0 +1,3555 @@
1
+ // The client router: navigation, history, scroll, and the wire protocol.
2
+ //
3
+ // tinvar-spec.md, "Client router and components": the visit API mirrors
4
+ // Inertia's "so the same mental model applies; only the payload differs".
5
+ // The payload is an RSC stream, and turning one into React elements needs the
6
+ // application's own client bundle -- the module instance that hydrated the
7
+ // page, or client components resolve to a different React and nothing works.
8
+ // So, as with deferred boundaries, mounting is a callback the app supplies
9
+ // and this module never imports React.
10
+ //
11
+ // What lives here is everything that is the same for every application:
12
+ // which requests to send and with which headers, what each control response
13
+ // means, when history is pushed or replaced, where the scroll goes, and
14
+ // which pages are fetched before they are asked for.
15
+
16
+ import { csrfToken } from './csrf.js';
17
+ import { markDocumentLoad, reloadUnlessLoading } from './document-load.js';
18
+ import { hasFiles, toFormData } from './form-data.js';
19
+ import { createOnceStore } from './once-store.js';
20
+ import { PrefetchCache, cacheWindow } from './prefetch-cache.js';
21
+ import { createRememberStore, importHistoryKey, keepNewest, tabStorage } from './remember-store.js';
22
+ import { purgeResumeStore } from './resume-store.js';
23
+
24
+ export const RSC_CONTENT_TYPE = 'text/x-component';
25
+ export const ROUTE_TOKEN_HEADER = 'x-route-token';
26
+
27
+ /** "Prefetch requests skip side effects like previous-URL tracking." */
28
+ export const PREFETCH_PURPOSE = 'prefetch';
29
+
30
+ /**
31
+ * Marks a background request -- a poll -- so the server records no visit
32
+ * for it. A poll re-asks for the page already up; one asking for the whole
33
+ * page is neither a prefetch nor a partial reload, and without this it would
34
+ * read to the server as the person coming back to that page.
35
+ */
36
+ export const BACKGROUND_HEADER = 'x-background';
37
+
38
+ /** Where an action's response lists the cache tags it invalidated. */
39
+ export const INVALIDATED_TAGS_HEADER = 'x-invalidated-tags';
40
+
41
+ /**
42
+ * Where a page's payload carries the session's history key, when the page's
43
+ * history is encrypted; a document carries it in its page metadata (ADR 0019).
44
+ */
45
+ export const HISTORY_KEY_HEADER = 'x-history-key';
46
+
47
+ /** How many X-Redirects one visit will follow before loading the document. */
48
+ export const MAX_REDIRECTS = 10;
49
+
50
+ /**
51
+ * How long restoring a scroll position keeps trying, in milliseconds, while
52
+ * the page is still too short to reach it.
53
+ */
54
+ export const SCROLL_RESTORE_MS = 2000;
55
+
56
+ /** Input that means the user is moving the page, and a restore should stop. */
57
+ const USER_SCROLL_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
58
+
59
+ /**
60
+ * The attribute that marks an inner scroller -- a sidebar, a pane, a table
61
+ * that scrolls inside the page -- whose position the router keeps with the
62
+ * window's: "`scroll-region` attribute for inner scrollers".
63
+ */
64
+ export const SCROLL_REGION_ATTRIBUTE = 'scroll-region';
65
+
66
+ /**
67
+ * The events the forms layer emits through the router (announce()): an
68
+ * action call starting, its files uploading, and its answer applied.
69
+ */
70
+ const ANNOUNCED = new Set(['actionStart', 'progress', 'actionSettle']);
71
+
72
+ function noop() {}
73
+
74
+ /**
75
+ * Events the router emits. `cancelable` ones can be stopped by a listener.
76
+ *
77
+ * Cancelling is a UI hook -- show a toast instead of reloading, confirm
78
+ * before leaving a dirty form -- and never a security control. Any script on
79
+ * the page can listen on the document and cancel, and the server must hold
80
+ * every line regardless: a version the client declines to reload for is
81
+ * still refused on its next request, and a page the client is stopped from
82
+ * leaving was authorised when it was rendered. A 401 is the one outcome a
83
+ * listener cannot cancel.
84
+ */
85
+ const CANCELABLE = new Set(['before', 'location', 'httpException', 'networkError', 'error']);
86
+
87
+ /** The app's mount threw: the payload arrived but could not be rendered. */
88
+ class MountFailed extends Error {
89
+ constructor(cause) {
90
+ super('The page could not be mounted.', { cause });
91
+ this.name = 'MountFailed';
92
+ }
93
+ }
94
+
95
+ /**
96
+ * What a visit's `onError` is handed when the server answered with a status
97
+ * that is not a page: 404, 403, 500. The router's `httpException` event
98
+ * says the same, and is the one that decides -- cancelled, the router loads
99
+ * nothing; not, it loads the URL as a document to show the server's page.
100
+ */
101
+ export class VisitHttpError extends Error {
102
+ /** @param {string} url @param {number} status */
103
+ constructor(url, status) {
104
+ super(`The server answered ${status}.`);
105
+ this.name = 'VisitHttpError';
106
+ this.url = url;
107
+ this.status = status;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * What a visit's `onError` is handed when no answer came: the connection
113
+ * dropped, the server could not be reached. `networkError` decides what the
114
+ * router does, as `httpException` does for a status.
115
+ */
116
+ export class VisitNetworkError extends Error {
117
+ /** @param {string} url @param {unknown} cause */
118
+ constructor(url, cause) {
119
+ super('The server could not be reached.', { cause });
120
+ this.name = 'VisitNetworkError';
121
+ this.url = url;
122
+ }
123
+ }
124
+
125
+ export class VisitCancelled extends Error {
126
+ constructor() {
127
+ super('The visit was cancelled.');
128
+ this.name = 'VisitCancelled';
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @typedef {{
134
+ * method?: 'get',
135
+ * data?: Record<string, unknown>,
136
+ * replace?: boolean,
137
+ * preserveScroll?: boolean | ((page: { url: string, view: string | null, partial: boolean }) => boolean),
138
+ * headers?: Record<string, string>,
139
+ * background?: boolean,
140
+ * only?: readonly string[],
141
+ * except?: readonly string[],
142
+ * reset?: readonly string[],
143
+ * preserveUrl?: boolean,
144
+ * signal?: AbortSignal,
145
+ * showProgress?: boolean,
146
+ * viewTransition?: boolean | ((transition: ViewTransition) => void),
147
+ * fresh?: boolean,
148
+ * refreshOnce?: boolean,
149
+ * preserveState?: boolean | ((page: { url: string, view: string | null, partial: boolean }) => boolean),
150
+ * onSuccess?: (page: { url: string, view: string | null, partial: boolean }) => unknown,
151
+ * onError?: (error: unknown) => unknown,
152
+ * instant?: boolean | string,
153
+ * }} VisitOptions
154
+ *
155
+ * `instant` shows, while the page is fetched, the layouts on screen with a
156
+ * layout's loading boundary below them (ADR 0092): the boundary of the
157
+ * layout directly above the page given true, of the layout by that key
158
+ * given a string. Only for a visit that is not answered from the prefetch
159
+ * cache, and never for a partial reload, a background request, or back and
160
+ * forward. A layout on screen with no such boundary makes it an ordinary
161
+ * visit.
162
+ *
163
+ * `data` is the query: merged into the URL's query string, each key
164
+ * replacing whatever the URL said under it (see withData()). Pages are GET
165
+ * only, so it is never a body.
166
+ *
167
+ * `preserveScroll` keeps the window and the scroll regions where they are
168
+ * rather than at the top. A function decides once the response is in, told
169
+ * what `preserveState`'s is.
170
+ *
171
+ * `onSuccess` is this visit's own `success`: the page was mounted.
172
+ * `onError` hears each way the visit failed, as the router's three channels
173
+ * do: the payload arrived and could not be rendered (`error`, handed what
174
+ * the mount threw), the server answered a status that is not a page
175
+ * (`httpException`, handed a VisitHttpError with the status), or no answer
176
+ * came (`networkError`, a VisitNetworkError with what the fetch threw).
177
+ * Either may return a promise, and the visit's `finish` -- and the promise
178
+ * visit() returns -- waits for it, as Inertia's does. It is told, never
179
+ * asked: what the router does next -- a document load to show the server's
180
+ * page -- is the event's to cancel. A GET carries no validation errors, so
181
+ * Inertia's `onError(errors)` has nothing to hear; a failed action's stay on
182
+ * its form.
183
+ *
184
+ * `signal` is the caller's own cancel token: aborting it cancels this visit,
185
+ * or this background request, and nothing else.
186
+ *
187
+ * `fresh` asks the server even when a prefetched copy of the page is kept:
188
+ * for the visit after an action, which may have changed whose session this
189
+ * is in a way no script can see.
190
+ *
191
+ * `refreshOnce` claims none of the #[Once] content this client keeps, so
192
+ * the server renders those fields again: for a live refresh, which says
193
+ * something the page read has changed -- and a once field is something it
194
+ * read, whose own tags may not name what changed.
195
+ *
196
+ * `only` and `except` ask for a partial reload: dot paths, `<segment>` or
197
+ * `<segment>.<field>`, where a segment is a layout's key or `page`. The
198
+ * server recomputes the segments they name, and the payload carries only
199
+ * those (ADR 0015).
200
+ *
201
+ * `reset` names merged fields -- #[Merge] lists and Scroll pages, by dot
202
+ * path -- that this response replaces rather than adds to: what a filter
203
+ * that changes the list asks for (X-Reset, ADR 0020).
204
+ *
205
+ * `preserveUrl` fetches the URL without moving the address bar to it: no
206
+ * entry pushed or replaced, and the page on screen stays the one the bar
207
+ * names. For a background request it lets a partial payload of the page on
208
+ * screen, fetched at another of its URLs -- the next page of a list -- be
209
+ * merged into it.
210
+ *
211
+ * @typedef {{
212
+ * cacheFor?: number | string | readonly [number | string, number | string],
213
+ * cacheTags?: string | readonly string[],
214
+ * method?: string,
215
+ * }} PrefetchOptions
216
+ *
217
+ * `preserveState` keeps the page's client components' state where React
218
+ * matches them (true), or mounts the page fresh (false): the page segment,
219
+ * or the segments a partial reload carries, while layouts keep theirs, as
220
+ * Inertia's persistent layouts do. A function decides once the response is
221
+ * in. False by default for a visit that shows a whole page, as Inertia's;
222
+ * true for reload(), a partial reload, a background request and an
223
+ * action's re-render (ADR 0045).
224
+ *
225
+ * @typedef {{
226
+ * url: string,
227
+ * routeToken: string | null,
228
+ * signal: AbortSignal,
229
+ * partial?: boolean,
230
+ * preserveState?: boolean,
231
+ * }} Page
232
+ *
233
+ * `signal` aborts once a later navigation supersedes this one. A mount that
234
+ * is still decoding can stop early; whatever it does, the router commits
235
+ * nothing for a superseded page after the mount returns. `partial` says the
236
+ * payload is the segments a partial reload recomputed, to merge into the
237
+ * page on screen, not a page. `preserveState` false asks for it to be
238
+ * committed fresh.
239
+ *
240
+ * `prepare` is the mount in two steps, as createPageRoot() hands it:
241
+ * decode the payload, then commit it. Used in place of `mount` where given,
242
+ * so a view transition holds the page for the commit alone.
243
+ *
244
+ * @typedef {{
245
+ * mount?: (stream: ReadableStream<Uint8Array>, page: Page) => void | Promise<void>,
246
+ * prepare?: (stream: ReadableStream<Uint8Array>, page: Page) => Promise<{ commit: () => Promise<void> } | null | undefined>,
247
+ * version?: string,
248
+ * fetch?: typeof fetch,
249
+ * window?: Window,
250
+ * initialRouteToken?: string | null,
251
+ * initialView?: string | null,
252
+ * hardNavigate?: (url: string) => void,
253
+ * storage?: Storage | null,
254
+ * resumeStore?: import('./resume-store.js').ResumeStore,
255
+ * initialHistoryKey?: string | null,
256
+ * endpoints?: { matches(url: URL | string): boolean },
257
+ * instant?: (under: true | string) => (() => void) | null,
258
+ * }} RouterOptions
259
+ *
260
+ * `instant` shows an instant visit's loading boundary and hands back what
261
+ * takes it away, as createPageRoot()'s does; without it, an instant visit
262
+ * is an ordinary one.
263
+ *
264
+ * `storage` is where useRemember() keeps its values: the tab's
265
+ * sessionStorage unless given, and memory alone given null.
266
+ *
267
+ * `initialHistoryKey` is the first page's history key, read from the page
268
+ * metadata if not given: what the router seals the first page's remembered
269
+ * state with (ADR 0019).
270
+ *
271
+ * `endpoints` is the application's #[Get], #[Post] and #[Endpoint] routes --
272
+ * codegen's `endpoint` -- whose URLs are no page's (ADR 0074). A link to one
273
+ * is left to the browser, a visit to one is a full load, and none is ever
274
+ * fetched with `RSC: 1` or prefetched: what answers is a download or JSON,
275
+ * not a payload.
276
+ *
277
+ * @typedef {{
278
+ * page: number,
279
+ * key: string | null,
280
+ * index: number,
281
+ * top: number,
282
+ * first: number,
283
+ * last: number,
284
+ * window: boolean,
285
+ * }} ScrollAnchor
286
+ *
287
+ * Where a merged list was read to: the item at the top of what the reader
288
+ * saw -- its page, its key where the list could tell, and its place on its
289
+ * page -- how far down the screen or the list's scroll container its top
290
+ * was, the pages that were loaded, and whether the list scrolls with the
291
+ * window.
292
+ */
293
+
294
+ /** The page's view, on a payload; sent back as X-Page with a partial reload. */
295
+ export const PAGE_HEADER = 'x-page';
296
+
297
+ /** As many once keys as the server reads from X-Have-Once, and as many bytes. */
298
+ const MAX_HELD_KEYS = 64;
299
+ const MAX_HELD_BYTES = 4000;
300
+
301
+ /** Where once-shared values' holds go back to the server (ADR 0065), and how many it reads. */
302
+ const SHARED_HOLDS_HEADER = 'X-Have-Shared';
303
+ const MAX_SHARED_HOLDS = 16;
304
+ const MAX_SHARED_BYTES = 8000;
305
+
306
+ /** Marks a payload holding only what a partial reload recomputed. */
307
+ export const PARTIAL_HEADER = 'x-partial';
308
+
309
+ /**
310
+ * The token a page's live subscription is scoped to, on a payload: what the
311
+ * render read (Tinvar\Live). Handed on in the `success` event's
312
+ * `liveToken`, for the live module.
313
+ */
314
+ export const LIVE_TOKEN_HEADER = 'x-live-token';
315
+
316
+ /** The id of the element the renderer writes the page metadata into. */
317
+ export const PAGE_METADATA_ID = 'tinvar-page';
318
+
319
+ /**
320
+ * The initial page metadata the renderer wrote into the document: the
321
+ * page's URL, the build it was rendered for, and its route token. A
322
+ * document load cannot read response headers, so this is the only way the
323
+ * first page's route token and the client's own build reach the router.
324
+ *
325
+ * Null when the element is missing or is not what the renderer writes --
326
+ * the document is the app's, and nothing in it is assumed well-formed.
327
+ *
328
+ * @param {Document} [doc]
329
+ * @returns {{ url: string, version: string, routeToken: string | null, view: string | null, historyKey: string | null, route: string | null } | null}
330
+ */
331
+ export function readPageMetadata(doc = globalThis.document) {
332
+ // The first JSON data block with the id, in document order. The renderer
333
+ // writes its block at the very start of the document, so nothing the page
334
+ // renders can precede it -- and an element that merely shares the id, or
335
+ // a later block planted in page content, is passed over rather than read.
336
+ if (typeof doc?.querySelector !== 'function') return null;
337
+ const element = doc.querySelector(`script[type="application/json"]#${PAGE_METADATA_ID}`);
338
+ if (!element) return null;
339
+
340
+ let data;
341
+ try {
342
+ data = JSON.parse(element.textContent ?? '');
343
+ } catch {
344
+ return null;
345
+ }
346
+
347
+ if (typeof data?.url !== 'string' || typeof data.version !== 'string') return null;
348
+
349
+ return {
350
+ url: data.url,
351
+ version: data.version,
352
+ routeToken: typeof data.routeToken === 'string' ? data.routeToken : null,
353
+ view: typeof data.view === 'string' ? data.view : null,
354
+ historyKey: typeof data.historyKey === 'string' ? data.historyKey : null,
355
+ // What the page's own reports are filed under: its route's name, or its
356
+ // view where it has none (ADR 0129).
357
+ route: typeof data.route === 'string' ? data.route : null,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Takes the history key out of the page metadata block, leaving the rest:
363
+ * once the router has imported it as a key it cannot export, the block
364
+ * would otherwise go on holding the raw bytes for as long as the document
365
+ * lasts. The block is the renderer's, written before the document; React
366
+ * renders nothing of it, so nothing hydrates against what it held.
367
+ *
368
+ * @param {Document | undefined} doc
369
+ */
370
+ function forgetMetadataKey(doc) {
371
+ if (typeof doc?.querySelector !== 'function') return;
372
+ const element = doc.querySelector(`script[type="application/json"]#${PAGE_METADATA_ID}`);
373
+ if (!element) return;
374
+ try {
375
+ const data = JSON.parse(element.textContent ?? '');
376
+ if (data === null || typeof data !== 'object' || !('historyKey' in data)) return;
377
+ delete data.historyKey;
378
+ element.textContent = JSON.stringify(data);
379
+ } catch {
380
+ // Not what the renderer writes; nothing of ours to take out.
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Which session a route token was minted for: the session hash, the user
386
+ * hash, the tenant and the panel a route token carries (TokenClaims), as one
387
+ * string to compare. Null without a token, or for one this does not
388
+ * understand.
389
+ *
390
+ * Read without verifying -- the payload is plain base64url JSON, and nothing
391
+ * here trusts it. It only answers whether something this client is holding
392
+ * belongs to the session it is talking to now. "Client-held data ... is
393
+ * bound to (user, tenant) ... Any mismatch between stored and current user
394
+ * or tenant also purges it." The user hash names who is signed in and the
395
+ * auth epoch the application keeps for them: a role changed, impersonation
396
+ * started or stopped, with the session id kept, is a change of binding too.
397
+ *
398
+ * @param {string | null | undefined} token
399
+ * @returns {string | null}
400
+ */
401
+ export function bindingOf(token) {
402
+ if (typeof token !== 'string') return null;
403
+ const body = token.split('.')[1];
404
+ if (!body) return null;
405
+
406
+ try {
407
+ const claims = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
408
+ if (typeof claims?.s !== 'string' || claims.s === '') return null;
409
+ return JSON.stringify([claims.s, String(claims.u ?? ''), String(claims.y ?? ''), String(claims.n ?? '')]);
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * The cache tags an action's response says it invalidated, from its
417
+ * `X-Invalidated-Tags` header. Accepts the response, its headers, or the
418
+ * header's value.
419
+ *
420
+ * @param {Response | Headers | { headers?: Headers } | string | null | undefined} source
421
+ * @returns {string[]}
422
+ */
423
+ export function invalidatedTags(source) {
424
+ const headers = typeof source?.get === 'function' ? source : source?.headers;
425
+ const value = typeof source === 'string' ? source : headers?.get?.(INVALIDATED_TAGS_HEADER);
426
+ return typeof value === 'string' ? tagsOf(value.split(',')) : [];
427
+ }
428
+
429
+ /**
430
+ * `cacheTags` as given -- one tag or several -- as a list of distinct,
431
+ * non-empty tags.
432
+ *
433
+ * @param {string | readonly string[] | null | undefined} tags
434
+ * @returns {string[]}
435
+ */
436
+ function tagsOf(tags) {
437
+ const listed = typeof tags === 'string' ? [tags] : Array.isArray(tags) ? tags : [];
438
+ return [...new Set(listed.map((tag) => String(tag).trim()).filter((tag) => tag !== ''))];
439
+ }
440
+
441
+ /**
442
+ * Whether a visit asks for a partial reload.
443
+ *
444
+ * @param {{ only?: readonly string[], except?: readonly string[] }} opts
445
+ */
446
+ function isPartial(opts) {
447
+ return opts.only !== undefined || opts.except !== undefined;
448
+ }
449
+
450
+ /**
451
+ * Partial-reload paths as sent: trimmed, non-empty, each once. A comma
452
+ * would split one path into two on the server, so a path holding one is
453
+ * refused here rather than read as two.
454
+ *
455
+ * @param {readonly string[]} paths
456
+ * @returns {string[]}
457
+ */
458
+ function pathsOf(paths) {
459
+ const out = [];
460
+ for (const raw of paths) {
461
+ const path = String(raw).trim();
462
+ if (path.includes(','))
463
+ throw new TypeError(`[tinvar:client] '${path}' is not one path: it holds a comma.`);
464
+ if (path !== '' && !out.includes(path)) out.push(path);
465
+ }
466
+ return out;
467
+ }
468
+
469
+ /**
470
+ * What the store kept for an entry under a session, as a page's record
471
+ * holds it: each value with that session.
472
+ *
473
+ * @param {Record<string, unknown>} stored
474
+ * @param {string | null} session
475
+ * @returns {Map<string, { session: string, value: unknown }>}
476
+ */
477
+ function valuesOf(stored, session) {
478
+ const values = new Map();
479
+ if (session === null) return values;
480
+ for (const [key, value] of Object.entries(stored)) values.set(key, { session, value });
481
+ return values;
482
+ }
483
+
484
+ /** Every router that is up, for flushInvalidated() and revokeSession(). */
485
+ const live = new Set();
486
+
487
+ /**
488
+ * Ends the session on this client, for whatever heard the server's 401:
489
+ * "Client-held data ... is cleared, with a full page load, on ... session
490
+ * revocation, which the server signals with a 401" (tinvar-spec.md). The
491
+ * router given -- or, without one, every router that is up -- revokes what
492
+ * it holds; and what useRemember() kept in the tab's storage is wiped
493
+ * either way, so a page with no router on it, or one whose router kept its
494
+ * values elsewhere, leaves nothing behind for the next document to read.
495
+ *
496
+ * Only the clearing: the caller makes the page load that must follow.
497
+ *
498
+ * @param {{ revoke: () => void } | null | undefined} [router]
499
+ */
500
+ export function revokeSession(router) {
501
+ if (router) router.revoke();
502
+ else for (const each of live) each.revoke();
503
+ createRememberStore(tabStorage(globalThis.window)).wipe();
504
+ // And every resumable upload remembered: each was the session's.
505
+ purgeResumeStore(null);
506
+ }
507
+
508
+ /**
509
+ * Clears the history of every router that is up -- or of the router given
510
+ * -- and wipes what useRemember() kept in the tab's storage either way: what
511
+ * logging out calls before the page it loads next. See Router.clearHistory().
512
+ *
513
+ * @param {{ clearHistory: () => void } | null | undefined} [router]
514
+ */
515
+ export function clearHistory(router) {
516
+ if (router) router.clearHistory();
517
+ else for (const each of live) each.clearHistory();
518
+ createRememberStore(tabStorage(globalThis.window)).wipe();
519
+ purgeResumeStore(null);
520
+ }
521
+
522
+ /**
523
+ * Flushes what an action invalidated from every router on the page: each
524
+ * prefetched page carrying a tag the response's `X-Invalidated-Tags` names.
525
+ *
526
+ * tinvar-spec.md, "Caching and invalidation": "an action's response lists
527
+ * the server-side query tags it invalidated (`X-Invalidated-Tags`), and the
528
+ * client flushes matching prefetch entries automatically. Inertia makes
529
+ * developers repeat `invalidateCacheTags` on each form." Whatever sends
530
+ * actions -- a form helper, an app's own fetch -- hands each response here,
531
+ * once, and no form names a tag.
532
+ *
533
+ * @param {Response | Headers | { headers?: Headers } | string | null | undefined} source
534
+ * @returns {string[]} the tags it named
535
+ */
536
+ export function flushInvalidated(source) {
537
+ const tags = invalidatedTags(source);
538
+ if (tags.length > 0) {
539
+ for (const router of live) router.flushByCacheTags(tags);
540
+ }
541
+ return tags;
542
+ }
543
+
544
+ /**
545
+ * Whether a page keeps its client components' state, or its scroll
546
+ * position: what the visit said -- a function deciding once the response is
547
+ * in -- or else `fallback`. A function that throws is reported and decides
548
+ * nothing: the page it was asked about is still mounted, as a view
549
+ * transition's that throws is.
550
+ *
551
+ * @param {VisitOptions['preserveState'] | VisitOptions['preserveScroll']} option
552
+ * @param {{ url: string, view: string | null, partial: boolean }} page
553
+ * @param {boolean} fallback
554
+ * @returns {boolean}
555
+ */
556
+ function preserves(option, page, fallback) {
557
+ if (typeof option === 'function') {
558
+ try {
559
+ return option(page) === true;
560
+ } catch (error) {
561
+ if (typeof globalThis.reportError === 'function') globalThis.reportError(error);
562
+ else console.error(error);
563
+ return fallback;
564
+ }
565
+ }
566
+ return typeof option === 'boolean' ? option : fallback;
567
+ }
568
+
569
+ /**
570
+ * Runs a visit's `onSuccess` or `onError`, waiting for the promise it may
571
+ * return. What it throws or rejects with is its own bug, reported as a
572
+ * listener's is; the visit has already come out the way it did.
573
+ *
574
+ * @param {((...args: any[]) => unknown) | undefined} callback
575
+ * @param {...unknown} args
576
+ */
577
+ async function runCallback(callback, ...args) {
578
+ if (typeof callback !== 'function') return;
579
+ try {
580
+ await callback(...args);
581
+ } catch (error) {
582
+ if (typeof globalThis.reportError === 'function') globalThis.reportError(error);
583
+ else console.error(error);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * A GET visit's URL with its `data` merged into the query string, as
589
+ * Inertia deep-merges a GET's: a plain object merges into what the URL
590
+ * holds under its name, field by field and as deep as it goes, so
591
+ * `{ filter: { status } }` replaces `filter[status]` and keeps
592
+ * `filter[sort]`; anything else -- a value, a list, null -- replaces the
593
+ * name and everything under it (`status` and `status[...]` alike), as
594
+ * deepmerge overwrites an array. The rest of the query stays, the fragment
595
+ * too. A key whose value is undefined takes the field out; an empty object
596
+ * changes nothing.
597
+ *
598
+ * Written as toFormData() writes a body -- `tags[]` for a list,
599
+ * `filter[status]` for a field, booleans as 1 and 0, null as an empty
600
+ * string -- so PHP reads the same nesting from a query as from a form. A
601
+ * file has no place in a URL: data holding one is the caller's mistake,
602
+ * said to the caller, rather than sent as "[object File]".
603
+ *
604
+ * @param {URL} url
605
+ * @param {unknown} data
606
+ * @returns {URL}
607
+ */
608
+ function withData(url, data) {
609
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
610
+ throw new TypeError("[tinvar:client] A visit's data is an object of query fields.");
611
+ }
612
+ if (hasFiles(data)) {
613
+ throw new TypeError('[tinvar:client] A visit is a GET, which cannot carry a file; send it to an action.');
614
+ }
615
+
616
+ const query = url.searchParams;
617
+ for (const replaced of replacedNames(data, null)) {
618
+ for (const name of [...query.keys()]) {
619
+ if (name === replaced || name.startsWith(`${replaced}[`)) query.delete(name);
620
+ }
621
+ }
622
+ for (const [name, value] of toFormData(/** @type {Record<string, unknown>} */ (data))) {
623
+ query.append(name, String(value));
624
+ }
625
+ return url;
626
+ }
627
+
628
+ /**
629
+ * The query names a visit's data replaces: each leaf's, where a leaf is
630
+ * anything but a plain object -- a plain object is merged into, by its own
631
+ * leaves. Named as toFormData() writes them, `filter[status]`.
632
+ *
633
+ * @param {Record<string, unknown>} data
634
+ * @param {string | null} prefix
635
+ * @returns {string[]}
636
+ */
637
+ function replacedNames(data, prefix) {
638
+ const out = [];
639
+ for (const [key, value] of Object.entries(data)) {
640
+ const name = prefix === null ? key : `${prefix}[${key}]`;
641
+ if (isPlainObject(value))
642
+ out.push(...replacedNames(/** @type {Record<string, unknown>} */ (value), name));
643
+ else out.push(name);
644
+ }
645
+ return out;
646
+ }
647
+
648
+ /** @param {unknown} value */
649
+ function isPlainObject(value) {
650
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
651
+ const proto = Object.getPrototypeOf(value);
652
+ return proto === Object.prototype || proto === null;
653
+ }
654
+
655
+ /**
656
+ * Scroll region positions as history state holds them: one `[left, top]`
657
+ * for each region, by its place in the document, or null for a place with
658
+ * nothing usable. History state is the page's to write as much as the
659
+ * router's, so nothing read back from it is taken on trust.
660
+ *
661
+ * @param {unknown} stored
662
+ * @returns {(readonly [number, number] | null)[] | null}
663
+ */
664
+ function regionsOf(stored) {
665
+ if (!Array.isArray(stored)) return null;
666
+ return stored.map((position) =>
667
+ Array.isArray(position) && Number.isFinite(position[0]) && Number.isFinite(position[1])
668
+ ? /** @type {const} */ ([Number(position[0]), Number(position[1])])
669
+ : null,
670
+ );
671
+ }
672
+
673
+ export function createRouter(/** @type {RouterOptions} */ options) {
674
+ const win = options.window ?? globalThis.window;
675
+ // Read only for what was not passed: a router given all of it needs
676
+ // nothing from the document, and may be running against one that has no
677
+ // DOM.
678
+ const metadata =
679
+ options.version !== undefined &&
680
+ options.initialRouteToken !== undefined &&
681
+ options.initialView !== undefined &&
682
+ options.initialHistoryKey !== undefined
683
+ ? null
684
+ : readPageMetadata(win.document);
685
+
686
+ // Passed explicitly, or read from the page. Without one every client
687
+ // navigation would be refused as a build mismatch, so its absence is an
688
+ // error now rather than a reload loop later.
689
+ const version = options.version ?? metadata?.version;
690
+ if (typeof version !== 'string') {
691
+ throw new Error('[tinvar:client] No build version: pass `version` or render the page metadata.');
692
+ }
693
+ const doFetch = options.fetch ?? win.fetch.bind(win);
694
+ const listeners = new Map();
695
+
696
+ /** Set by destroy(); after it the router does nothing at all. */
697
+ let destroyed = false;
698
+
699
+ /**
700
+ * Set by a 401; after it the router sends nothing more. A full load is on
701
+ * its way, and anything sent meanwhile would only ask again with the
702
+ * session the server has already refused.
703
+ */
704
+ let revoked = false;
705
+
706
+ /** Forgets the load of the page on screen a 401 waits to make; see revoke(). */
707
+ let stopReload = () => {};
708
+
709
+ /** @type {AbortController | null} */
710
+ let inFlight = null;
711
+ /**
712
+ * The visit whose page is up, whose payload may still be streaming after
713
+ * the visit is over: aborted once a page supersedes it, so the page left
714
+ * is read no further (Page.signal).
715
+ *
716
+ * @type {AbortController | null}
717
+ */
718
+ let showing = null;
719
+
720
+ /** Each visit's URL, for its `finish` event. */
721
+ const visitUrls = new WeakMap();
722
+ /** Visits whose `finish` has been emitted. */
723
+ const finished = new WeakSet();
724
+
725
+ /** Background requests in flight -- polls, deferred refreshes. */
726
+ const background = new Set();
727
+
728
+ /** Every background request, in flight or not, so its `finish` can say so. */
729
+ const backgrounds = new WeakSet();
730
+
731
+ /**
732
+ * How many background requests have been made, and the number of the
733
+ * newest that has been mounted. Polls can overlap, and their answers can
734
+ * arrive out of order; one older than what is on screen is never mounted.
735
+ */
736
+ let requested = 0;
737
+ let newestMounted = 0;
738
+ /** @type {WeakMap<AbortController, number>} */
739
+ const requestedAs = new WeakMap();
740
+ /** The background mounts that are an action's re-render; see applyPayload(). */
741
+ const applying = new WeakSet();
742
+
743
+ /** The route token for the page currently mounted; see routeTokens(). */
744
+ let routeToken =
745
+ options.initialRouteToken !== undefined ? options.initialRouteToken : (metadata?.routeToken ?? null);
746
+
747
+ /**
748
+ * The session the client is holding data for, from the last route token
749
+ * a page came with; see bindingOf(). Null until a page carries one, and
750
+ * kept across pages that do not: a page with no token says nothing about
751
+ * whose session this is, so it neither confirms nor changes it.
752
+ */
753
+ let binding = bindingOf(routeToken);
754
+
755
+ /**
756
+ * The view of the page on screen, sent as X-Page so a partial reload
757
+ * "applies only to the same page". Null where the server does not say,
758
+ * and then no partial reload is asked for: the server would render the
759
+ * page whole without it anyway.
760
+ */
761
+ let view = options.initialView !== undefined ? options.initialView : (metadata?.view ?? null);
762
+
763
+ /** Pages fetched ahead of a click; see prefetch(). */
764
+ const cache = new PrefetchCache();
765
+
766
+ /**
767
+ * The history key each copy of a prefetched page came with, imported: the
768
+ * cache keeps no raw key in its headers (see withoutKey()).
769
+ *
770
+ * @type {WeakMap<Response, import('./remember-store.js').HistoryKey>}
771
+ */
772
+ const importedKeys = new WeakMap();
773
+
774
+ /**
775
+ * What pages rendered from #[Once] fields, kept so a later request can
776
+ * list them in X-Have-Once and be sent without them; see once-store.js.
777
+ */
778
+ const once = createOnceStore();
779
+
780
+ /**
781
+ * Background requests loading another page of a merged list into the page
782
+ * on screen -- `preserveUrl`. Not refreshes of what is shown, so a newer
783
+ * poll does not make one stale; see handle().
784
+ */
785
+ const merging = new WeakSet();
786
+
787
+ /**
788
+ * The URL of the page that is mounted, without its fragment. Two history
789
+ * entries that differ only in fragment are the same document.
790
+ */
791
+ let mounted = stripHash(win.location.href);
792
+
793
+ // The browser's own restoration fights ours: it restores to where the
794
+ // OLD document was, before our mount has replaced the content.
795
+ if ('scrollRestoration' in win.history) {
796
+ win.history.scrollRestoration = 'manual';
797
+ }
798
+
799
+ /**
800
+ * Scroll positions, by history entry.
801
+ *
802
+ * Recorded as the user scrolls rather than when a visit leaves the page,
803
+ * because back and forward leave a page too and the router hears of them
804
+ * only AFTER the browser has moved to the new entry -- too late to save
805
+ * anything onto the old one. Saving at that point is what broke forward:
806
+ * the entry left by a back press was never saved, and a visit made while
807
+ * a back was pending wrote the old page's position onto the new entry.
808
+ *
809
+ * Each entry carries a key in its state. `entry` is the key of the entry
810
+ * whose page is on screen, and null while a back or forward is pending,
811
+ * when the page on screen belongs to no entry at all. Positions are also
812
+ * persisted into history.state, debounced (browsers throttle
813
+ * replaceState), so they survive a reload of the document.
814
+ *
815
+ * This map, and the two below, hold only the newest entries, as many as
816
+ * remembered state keeps (keepNewest()): a long session's every visit
817
+ * stayed in memory. An entry dropped here falls back to what its
818
+ * history.state holds.
819
+ */
820
+ const positions = new Map();
821
+
822
+ /**
823
+ * The scroll regions' positions, by history entry: one `[left, top]` for
824
+ * each element carrying a `scroll-region` attribute, in document order.
825
+ * Recorded with the window's, as the user scrolls one, for the same reason,
826
+ * and persisted beside it. Matched to the regions of the page shown again
827
+ * by place, as Inertia matches them: nothing else names a region, and the
828
+ * page an entry shows renders its regions in the same order each time.
829
+ *
830
+ * @type {Map<string, (readonly [number, number] | null)[]>}
831
+ */
832
+ const regionPositions = new Map();
833
+
834
+ /**
835
+ * Where each merged list on an entry's page was read to, by history entry
836
+ * and then by the list's dot path: the item in view, how far down the
837
+ * screen it was, and the pages that were loaded (see infinite-scroll.js).
838
+ * Recorded with the scroll position, for the same reason, and persisted
839
+ * with it -- as places alone, the item's key kept only here (placesOf()).
840
+ * Forgotten with the session. A raw position cannot bring the reader back
841
+ * to a list loaded a page at a time: the page arrived at holds only the
842
+ * page its URL names, so the same offset lands a page or more away.
843
+ *
844
+ * @type {Map<string, Record<string, ScrollAnchor>>}
845
+ */
846
+ const anchors = new Map();
847
+
848
+ /**
849
+ * The anchors of the entry a back, a forward or a reload arrived at, for
850
+ * the lists of the page being mounted for it to take, once each. A list
851
+ * that takes one in the window puts the reader back itself, so the raw
852
+ * position is not restored over it; nor is a scroll region's over the
853
+ * one a list takes in its own container (`containers`).
854
+ *
855
+ * @type {{ anchors: Record<string, ScrollAnchor>, taken: Set<string>, window: boolean, containers: Set<Element> } | null}
856
+ */
857
+ let restored = null;
858
+
859
+ /** @type {string | null} */
860
+ let entry = null;
861
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
862
+ let persistTimer;
863
+ /** Foreground mounts in progress; scroll is not recorded during any. */
864
+ let mounting = 0;
865
+ /**
866
+ * Instant visits showing a loading boundary: the page under it is not the
867
+ * one being left, and a boundary shorter than how far it was scrolled
868
+ * clamps the scroll -- so where it was is kept before the boundary goes
869
+ * up, and nothing is read for it while the boundary shows.
870
+ */
871
+ let boundaries = 0;
872
+ /**
873
+ * The scroll restore in progress, if any: the window position it is still
874
+ * reaching for -- null once a list has taken the window over -- and how
875
+ * to stop it. See restoreScroll().
876
+ *
877
+ * @type {{ position: readonly number[] | null, stop: () => void } | null}
878
+ */
879
+ let restoring = null;
880
+
881
+ // Where this entry was left, if this document is a reload of it or was
882
+ // reached by back or forward from another document. Read before the entry
883
+ // is claimed, which records the position the page has now.
884
+ const left = isReturn(win) ? objectState().tinvar?.scroll : undefined;
885
+ const leftRegions = isReturn(win) ? regionsOf(objectState().tinvar?.regions) : null;
886
+ if (isReturn(win)) restored = arrivedAt(objectState().tinvar?.anchors);
887
+
888
+ // The first entry has no state of ours, so a later popstate back to it
889
+ // would have nothing to restore from.
890
+ entry = claimEntry();
891
+ keepNewest(positions, entry, [win.scrollX, win.scrollY]);
892
+ if (leftRegions !== null) keepNewest(regionPositions, entry, leftRegions);
893
+ if (restored !== null) keepNewest(anchors, entry, restored.anchors);
894
+
895
+ /**
896
+ * The URL of the first page, for its `navigate`, and whether that is
897
+ * still to be emitted: once `ready` has settled, so a listener the app adds
898
+ * straight after creating the router hears it -- or before any other
899
+ * event, should one come first, so it is always the first a listener
900
+ * hears. See announceInitial().
901
+ */
902
+ const initialUrl = win.location.href;
903
+ let initialPending = true;
904
+
905
+ /** What seals and opens remembered state, for a page whose history is encrypted. */
906
+ const crypto = win.crypto ?? globalThis.crypto;
907
+
908
+ /**
909
+ * Where useRemember() values are kept, by history entry, for one session;
910
+ * see remember-store.js for why not in history.state.
911
+ */
912
+ const store = createRememberStore(
913
+ options.storage !== undefined ? options.storage : tabStorage(win),
914
+ crypto,
915
+ );
916
+
917
+ /**
918
+ * Where resumable uploads are remembered (ADR 0143): each record is the
919
+ * session's that began it, so every record of another session -- its file
920
+ * handle with it -- goes wherever what useRemember() kept is wiped, and
921
+ * when the router starts, for a session that changed or ended while no
922
+ * page of this tab was open.
923
+ */
924
+ const resumeStore = options.resumeStore;
925
+ // A page with no token is no session, as adoptBinding() reads it: a load
926
+ // after a sign-out keeps nothing the user before left.
927
+ purgeResumeStore(binding, resumeStore);
928
+
929
+ /**
930
+ * What useRemember() keeps, for the page on screen: by key, each value
931
+ * with the session it was set under. Written to the store under its
932
+ * history entry's key, debounced with the scroll position, so back and
933
+ * forward -- and a reload of the document -- bring it back.
934
+ *
935
+ * One record per page shown. A page being mounted gets its own
936
+ * (`incoming`) before its entry exists, so what its components remember
937
+ * as they mount goes to the entry about to be made, and what the page
938
+ * being left remembers meanwhile goes to the entry it belongs to.
939
+ *
940
+ * `session` is the binding of the page the record belongs to -- for a page
941
+ * being mounted, the one its response came with -- and null where there
942
+ * is none, when nothing is remembered.
943
+ *
944
+ * `key` is the history key the page came with, imported, and null for a
945
+ * page whose history is not encrypted (ADR 0019). What a page with one
946
+ * remembers is sealed with it in storage, and never written in the clear.
947
+ * `restoring` is set while what the page's entry kept is still being
948
+ * opened, which nothing may overwrite until it has been. `sealed` is what
949
+ * was last sent to be sealed, so the same values are not sealed again.
950
+ *
951
+ * @typedef {{
952
+ * entry: string | null,
953
+ * session: string | null,
954
+ * values: Map<string, { session: string, value: unknown }>,
955
+ * key: import('./remember-store.js').HistoryKey | null,
956
+ * restoring: boolean,
957
+ * sealed?: string,
958
+ * }} Remembered
959
+ */
960
+ /** @type {Remembered} */
961
+ let remembered = {
962
+ entry,
963
+ session: binding,
964
+ values: valuesOf(store.read(entry, binding), binding),
965
+ key: historyKeyOf(
966
+ options.initialHistoryKey !== undefined ? options.initialHistoryKey : (metadata?.historyKey ?? null),
967
+ ),
968
+ restoring: false,
969
+ };
970
+ /** @type {Remembered | null} */
971
+ let incoming = null;
972
+
973
+ // Imported, the raw key has no more use here, and is not left where
974
+ // anything on the page could go on reading it for the document's life:
975
+ // not in the metadata block, nor in what this router read from it
976
+ // (ADR 0019).
977
+ if (metadata !== null) metadata.historyKey = null;
978
+ forgetMetadataKey(win.document);
979
+
980
+ /**
981
+ * Settles once what the first page's entry kept has been read back. A
982
+ * reload of an encrypted entry finds it sealed, and opening it takes a
983
+ * moment: an application awaits this before hydrating, so its components
984
+ * mount with what was remembered, as they would from an entry kept in
985
+ * the clear -- for which it has already settled.
986
+ */
987
+ const ready = store.isSealed(entry, binding) ? restore(remembered) : Promise.resolve();
988
+
989
+ // "`navigate` with `type: 'initial' | 'visit' | 'history'`": the first
990
+ // page's, once what its entry remembered is back -- see `initialPending`.
991
+ void ready.then(announceInitial, announceInitial);
992
+
993
+ /**
994
+ * Emits the first page's `navigate`, once. The page is already on screen,
995
+ * hydrating, when the router is made: what this says is that it is the
996
+ * page shown, for whatever counts pages -- analytics -- to count the first
997
+ * as it counts the rest.
998
+ */
999
+ function announceInitial() {
1000
+ if (!initialPending) return;
1001
+ initialPending = false;
1002
+ // A router made against a window with no DOM -- given everything it
1003
+ // needs, so it reads nothing from the document -- has nothing to emit
1004
+ // an event on, and this one comes unasked.
1005
+ if (typeof win.CustomEvent !== 'function' || typeof win.document?.dispatchEvent !== 'function') return;
1006
+ emit('navigate', { url: initialUrl, type: 'initial' });
1007
+ }
1008
+
1009
+ /**
1010
+ * @param {string} name
1011
+ * @param {Record<string, unknown>} [detail]
1012
+ * @returns {boolean} false if a listener cancelled it
1013
+ */
1014
+ function emit(name, detail = {}) {
1015
+ // A visit unwinding after destroy() must not reach the page: no events,
1016
+ // and -- since a cancelled event is what stops a full load -- report
1017
+ // every one as cancelled.
1018
+ if (destroyed) return false;
1019
+
1020
+ // The first page is shown before anything happens on it: a visit made
1021
+ // straight after the router, before `ready` settled, is still the second
1022
+ // page to a listener.
1023
+ if (initialPending) announceInitial();
1024
+
1025
+ const event = new win.CustomEvent(`tinvar:${name}`, {
1026
+ detail,
1027
+ cancelable: CANCELABLE.has(name),
1028
+ });
1029
+
1030
+ // A listener's exception is the listener's bug, not the visit's. One
1031
+ // thrown on `success` became a networkError -- and a full reload -- after
1032
+ // history had been pushed; one thrown on `start` left the visit's slot
1033
+ // held for good. Reported the way dispatchEvent reports its own
1034
+ // listeners', and the visit carries on.
1035
+ for (const fn of listeners.get(name) ?? []) {
1036
+ try {
1037
+ fn(event);
1038
+ } catch (error) {
1039
+ if (typeof win.reportError === 'function') win.reportError(error);
1040
+ else console.error(error);
1041
+ }
1042
+ }
1043
+
1044
+ // Each event is also a native CustomEvent, so code outside the app's
1045
+ // module graph -- analytics, a progress bar -- can listen without
1046
+ // importing the router.
1047
+ return win.document.dispatchEvent(event) && !event.defaultPrevented;
1048
+ }
1049
+
1050
+ /**
1051
+ * A full page load: the remedy for a build mismatch and the fallback for
1052
+ * anything this router cannot handle in place.
1053
+ */
1054
+ function hardNavigate(url) {
1055
+ if (destroyed) return;
1056
+
1057
+ // http and https only, checked HERE -- the one place every full load
1058
+ // goes through -- rather than at each caller. A javascript: URL has
1059
+ // origin "null", so it failed the same-origin check in visit() and was
1060
+ // handed straight to location.assign, which runs it. The same was true
1061
+ // of an X-Location from a response and of any future caller. data: and
1062
+ // blob: are refused for the same reason: they are documents the page
1063
+ // chose the content of, not places.
1064
+ let target;
1065
+ try {
1066
+ target = new URL(url, win.location.href);
1067
+ } catch {
1068
+ return;
1069
+ }
1070
+
1071
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
1072
+ console.error(`[tinvar:client] Refusing to navigate to a ${target.protocol} URL.`);
1073
+ return;
1074
+ }
1075
+
1076
+ // Marked first, so what ends the session on this client while it
1077
+ // loads leaves it to arrive (document-load.js).
1078
+ markDocumentLoad(target.href);
1079
+ (options.hardNavigate ?? ((u) => win.location.assign(u)))(target.href);
1080
+ }
1081
+
1082
+ /**
1083
+ * The session is gone. "Session revocation, which the server signals with
1084
+ * a 401", is cleared "with a full page load" (tinvar-spec.md, "Client-held
1085
+ * data"), and the load is what reaches the server's login redirect.
1086
+ *
1087
+ * Not the app's to cancel: an error rendered in place left the protected
1088
+ * page on screen and its route token in hand. And not skipped for a
1089
+ * background request, the one exception to "a background request never
1090
+ * navigates": a poll that heard the session was gone and did nothing left
1091
+ * the page up, still polling, until the user next clicked.
1092
+ *
1093
+ * What the session held goes at once, either way. The page on screen --
1094
+ * loaded again for a background request (a poll, a prefetch, a list's
1095
+ * next page) or for a visit to the page itself (reload(), a link to it)
1096
+ * -- leaves a document load already begun to arrive: an action that
1097
+ * ended the session sent the browser on, and the request went out
1098
+ * meanwhile. Loaded over it, the page on screen is the page being left,
1099
+ * and the browser landed back on it (document-load.js). A visit to
1100
+ * another page, back and forward among them, loads the page it asked for
1101
+ * at once: it is where the user went, the latest navigation, which
1102
+ * replaces a load begun before it as a later navigation does in the
1103
+ * browser.
1104
+ *
1105
+ * @param {string | null} href the page a visit asked for; null for the
1106
+ * page on screen
1107
+ * @param {AbortSignal} signal the request that got the 401
1108
+ */
1109
+ function revoke(href, signal) {
1110
+ forgetSession(signal);
1111
+
1112
+ if (href === null) {
1113
+ stopReload();
1114
+ stopReload = reloadUnlessLoading(hardNavigate, win);
1115
+ return;
1116
+ }
1117
+
1118
+ // Without the fragment when it is this document. Assigning the URL the
1119
+ // document already has, fragment and all, is a fragment navigation: the
1120
+ // browser scrolls to it and loads nothing.
1121
+ const page = stripHash(href);
1122
+ hardNavigate(page === stripHash(win.location.href) ? page : href);
1123
+ }
1124
+
1125
+ /**
1126
+ * Drops everything held for the session, and sends nothing more: what a
1127
+ * 401 calls for, short of the page load that follows it.
1128
+ *
1129
+ * @param {AbortSignal | null} signal the request that got the 401, left to
1130
+ * unwind on its own; null when the 401 came to something else
1131
+ */
1132
+ function forgetSession(signal) {
1133
+ revoked = true;
1134
+
1135
+ // Dropped now rather than when the document unloads: an action sent in
1136
+ // between would carry it, and a click in between would be shown a page
1137
+ // prefetched under the session that has just ended.
1138
+ routeToken = null;
1139
+ cache.clear();
1140
+ once.clear();
1141
+
1142
+ // And everything useRemember() kept, for every entry of this tab: the
1143
+ // session it was kept for is the one that ended. Wiped, not merely left
1144
+ // unread.
1145
+ binding = null;
1146
+ remembered.values.clear();
1147
+ remembered.session = null;
1148
+ remembered.key = null;
1149
+ incoming = null;
1150
+ clearTimeout(persistTimer);
1151
+ store.wipe();
1152
+ purgeResumeStore(null, resumeStore);
1153
+ // And where its lists were read to, which names the items read.
1154
+ forgetAnchors();
1155
+
1156
+ // Nothing else in flight may mount over the page or take its token.
1157
+ if (inFlight && inFlight.signal !== signal) interrupt();
1158
+ for (const c of [...background]) {
1159
+ if (c.signal === signal) continue;
1160
+ background.delete(c);
1161
+ c.abort();
1162
+ finish(c, true);
1163
+ }
1164
+
1165
+ // For what holds client-held data the router does not: the live
1166
+ // module's tokens and its stream.
1167
+ emit('revoke');
1168
+ }
1169
+
1170
+ /**
1171
+ * Aborts the visit in flight and finishes it NOW, flagged as interrupted.
1172
+ *
1173
+ * Its own `finish` used to arrive once its fetch had unwound -- after the
1174
+ * next visit's `start` -- so a progress bar that hides on `finish` hid in
1175
+ * the middle of the visit that replaced it.
1176
+ */
1177
+ function interrupt() {
1178
+ const c = inFlight;
1179
+ if (!c) return;
1180
+ inFlight = null;
1181
+ c.abort();
1182
+ finish(c, true);
1183
+ }
1184
+
1185
+ /** Emits a visit's `finish`, once. */
1186
+ function finish(controller, interrupted) {
1187
+ if (finished.has(controller)) return;
1188
+ finished.add(controller);
1189
+ emit('finish', {
1190
+ url: visitUrls.get(controller),
1191
+ ...(interrupted ? { interrupted: true } : {}),
1192
+ ...(backgrounds.has(controller) ? { background: true } : {}),
1193
+ });
1194
+ }
1195
+
1196
+ /** $controller's page is up: the page it replaced is read no further. */
1197
+ function supersede(controller) {
1198
+ const left = showing;
1199
+ showing = controller;
1200
+ if (left !== null && left !== controller) left.abort();
1201
+ }
1202
+
1203
+ /** Aborts every background request and, as interrupt() does, finishes each now. */
1204
+ function abortBackground() {
1205
+ const cancelled = [...background];
1206
+ background.clear();
1207
+ for (const c of cancelled) {
1208
+ c.abort();
1209
+ finish(c, true);
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Runs a page's commit inside a view transition, when a visit asked for
1215
+ * one and the browser has them; otherwise just runs it.
1216
+ *
1217
+ * The browser snapshots the old page, holds rendering while the update
1218
+ * runs, and animates to the new page once its promise settles -- which is
1219
+ * when the page has committed. Started only once the payload is decoded,
1220
+ * where the app gave the router `prepare`, so the page is held for the
1221
+ * commit alone: not for the network, and not for the decode. An app that
1222
+ * gave only `mount` has the page held for both. Whatever the update
1223
+ * throws comes back out as it would without a transition: the transition
1224
+ * is skipped, and the visit reports it.
1225
+ *
1226
+ * `viewTransition` may be a function, handed the ViewTransition to add
1227
+ * classes or wait on.
1228
+ *
1229
+ * @param {VisitOptions['viewTransition']} option
1230
+ * @param {() => Promise<void>} update
1231
+ * @returns {Promise<void>}
1232
+ */
1233
+ async function withViewTransition(option, update) {
1234
+ const doc =
1235
+ /** @type {Document & { startViewTransition?: (update: () => Promise<void>) => ViewTransition }} */ (
1236
+ win.document
1237
+ );
1238
+ if (!option || typeof doc.startViewTransition !== 'function') return update();
1239
+
1240
+ const transition = doc.startViewTransition(update);
1241
+
1242
+ // Both reject when the update throws or the transition is skipped. That
1243
+ // is reported once, through updateCallbackDone; left unhandled here, each
1244
+ // would also surface as an unhandled rejection.
1245
+ transition.ready.catch(noop);
1246
+ transition.finished.catch(noop);
1247
+
1248
+ if (typeof option === 'function') {
1249
+ try {
1250
+ option(transition);
1251
+ } catch (error) {
1252
+ if (typeof win.reportError === 'function') win.reportError(error);
1253
+ else console.error(error);
1254
+ }
1255
+ }
1256
+
1257
+ await transition.updateCallbackDone;
1258
+ }
1259
+
1260
+ /**
1261
+ * A visit's payload as the mount reads it, reported as it is read: the
1262
+ * RSC events. `streamStart` as the router hands it over, `streamChunk` as
1263
+ * each chunk arrives, and `streamEnd` once all of it has -- or, with
1264
+ * `interrupted`, once it never will: the visit was superseded or
1265
+ * cancelled, the connection dropped, the mount stopped reading, or the page
1266
+ * could not be mounted (stop()).
1267
+ *
1268
+ * A streamed page commits at its shell, and that is where its `finish`
1269
+ * comes ("with streaming, finish at the first meaningful flush"); its
1270
+ * Suspense content goes on arriving after. `streamEnd` is when the last of
1271
+ * it has.
1272
+ *
1273
+ * Sizes only, never the bytes: a payload is what the page was rendered
1274
+ * from, and every script on the page can listen for a native event.
1275
+ *
1276
+ * @param {ReadableStream<Uint8Array>} body
1277
+ * @param {{ url: string, background?: true }} detail what each event says of the request
1278
+ * @param {AbortSignal} signal the visit's, aborted once it is superseded
1279
+ * @returns {{ stream: ReadableStream<Uint8Array>, stop: () => void }}
1280
+ */
1281
+ function observe(body, detail, signal) {
1282
+ const reader = body.getReader();
1283
+ let received = 0;
1284
+ let ended = false;
1285
+
1286
+ /** @param {boolean} interrupted */
1287
+ const end = (interrupted) => {
1288
+ if (ended) return;
1289
+ ended = true;
1290
+ signal.removeEventListener('abort', stop);
1291
+ emit('streamEnd', { ...detail, bytes: received, ...(interrupted ? { interrupted: true } : {}) });
1292
+ };
1293
+ function stop() {
1294
+ if (ended) return;
1295
+ end(true);
1296
+ reader.cancel().catch(noop);
1297
+ }
1298
+
1299
+ emit('streamStart', detail);
1300
+ if (signal.aborted) stop();
1301
+ else signal.addEventListener('abort', stop, { once: true });
1302
+
1303
+ // The controller refuses a close or an enqueue once the mount has
1304
+ // cancelled its side; what is left then is only to stop reading.
1305
+ const settle = (/** @type {() => void} */ fn) => {
1306
+ try {
1307
+ fn();
1308
+ } catch {
1309
+ // The reader has gone.
1310
+ }
1311
+ };
1312
+
1313
+ const stream = new ReadableStream({
1314
+ async pull(controller) {
1315
+ /** @type {ReadableStreamReadResult<Uint8Array>} */
1316
+ let chunk;
1317
+ try {
1318
+ chunk = await reader.read();
1319
+ } catch (error) {
1320
+ end(true);
1321
+ settle(() => controller.error(error));
1322
+ return;
1323
+ }
1324
+ if (chunk.done) {
1325
+ end(false);
1326
+ settle(() => controller.close());
1327
+ return;
1328
+ }
1329
+ received += chunk.value.byteLength;
1330
+ emit('streamChunk', { ...detail, bytes: chunk.value.byteLength, received });
1331
+ settle(() => controller.enqueue(chunk.value));
1332
+ },
1333
+ cancel(reason) {
1334
+ end(true);
1335
+ return reader.cancel(reason);
1336
+ },
1337
+ });
1338
+
1339
+ return { stream, stop };
1340
+ }
1341
+
1342
+ /**
1343
+ * Mounts a page: through the app's `prepare` -- decode, then commit, the
1344
+ * commit alone inside the view transition the visit asked for -- or its
1345
+ * one-step `mount`, inside the transition whole.
1346
+ *
1347
+ * @param {ReadableStream<Uint8Array>} body
1348
+ * @param {Page} page
1349
+ * @param {VisitOptions['viewTransition']} [transition]
1350
+ */
1351
+ async function mountPage(body, page, transition) {
1352
+ // Raced against the signal: a superseded visit is over the moment it is
1353
+ // superseded, whatever the app's mount does. A mount that never settled
1354
+ // -- a commit callback React dropped when two transitions merged --
1355
+ // otherwise held the visit open forever, and with it the mount count
1356
+ // that suspends scroll recording, so no position was saved again.
1357
+ let onAbort;
1358
+ const aborted = new Promise((_, reject) => {
1359
+ onAbort = () => reject(new VisitCancelled());
1360
+ if (page.signal.aborted) onAbort();
1361
+ else page.signal.addEventListener('abort', onAbort, { once: true });
1362
+ });
1363
+
1364
+ try {
1365
+ if (typeof options.prepare === 'function') {
1366
+ const prepared = await Promise.race([options.prepare(body, page), aborted]);
1367
+ if (page.signal.aborted) throw new VisitCancelled();
1368
+ await withViewTransition(transition, () => Promise.race([prepared?.commit(), aborted]));
1369
+ } else {
1370
+ await withViewTransition(transition, () => Promise.race([options.mount(body, page), aborted]));
1371
+ }
1372
+ } catch (error) {
1373
+ if (error instanceof VisitCancelled) throw error;
1374
+ if (page.signal.aborted) throw new VisitCancelled();
1375
+ throw new MountFailed(error);
1376
+ } finally {
1377
+ page.signal.removeEventListener('abort', onAbort);
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Whether a URL may be fetched as a page. Every URL the router fetches --
1383
+ * a visit's and every redirect it follows -- passes through here, so the
1384
+ * two rules cannot be applied to one and forgotten on the other (as they
1385
+ * were, briefly, for X-Redirect).
1386
+ *
1387
+ * @param {URL} url
1388
+ */
1389
+ function fetchable(url) {
1390
+ // Only http(s). A blob: URL carries the app's own origin, so the origin
1391
+ // check admits it -- and the router would fetch and mount content the
1392
+ // page built itself, not the server. hardNavigate() refuses the same
1393
+ // schemes, so a refused URL ends there.
1394
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
1395
+
1396
+ // Only this origin. Anything else is an ordinary link to somewhere
1397
+ // else, and sending RSC headers -- or the CSRF token -- to another
1398
+ // origin would be the wrong thing to do even if it answered.
1399
+ if (url.origin !== win.location.origin) return false;
1400
+
1401
+ // Not an endpoint's URL, which answers with a file or JSON rather than
1402
+ // a page: the browser loads it (ADR 0074).
1403
+ return !isEndpoint(url);
1404
+ }
1405
+
1406
+ /**
1407
+ * Whether a URL is one of the application's endpoints, by the table
1408
+ * codegen wrote: on this origin, at a path one of them answers.
1409
+ *
1410
+ * @param {URL | string} href
1411
+ */
1412
+ function isEndpoint(href) {
1413
+ if (options.endpoints === undefined) return false;
1414
+ let url;
1415
+ try {
1416
+ url = new URL(String(href), win.location.href);
1417
+ } catch {
1418
+ return false;
1419
+ }
1420
+ return url.origin === win.location.origin && options.endpoints.matches(url);
1421
+ }
1422
+
1423
+ /**
1424
+ * Scrolls to the element a fragment names.
1425
+ *
1426
+ * @param {string} hash
1427
+ * @returns {boolean} whether there was one
1428
+ */
1429
+ function scrollToFragment(hash) {
1430
+ if (!hash) return false;
1431
+
1432
+ // A malformed escape (`#%E0`) makes decodeURIComponent throw, and the
1433
+ // throw -- after the page had mounted -- was reported as a network
1434
+ // failure and reloaded the page. Such a fragment names nothing.
1435
+ let id;
1436
+ try {
1437
+ id = decodeURIComponent(hash.slice(1));
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+
1442
+ const anchor = win.document.getElementById(id);
1443
+ anchor?.scrollIntoView();
1444
+ return anchor !== null;
1445
+ }
1446
+
1447
+ /**
1448
+ * The current entry's state, as something this router can add a field to.
1449
+ * An app may have stored a string or a number there, and spreading one
1450
+ * turned 'app-state' into {0: 'a', 1: 'p', ...}. A non-object is kept
1451
+ * whole under `value` instead.
1452
+ */
1453
+ function objectState() {
1454
+ const state = win.history.state;
1455
+ if (state === null || state === undefined) return {};
1456
+ if (typeof state === 'object' && !Array.isArray(state)) return state;
1457
+ return { value: state };
1458
+ }
1459
+
1460
+ /**
1461
+ * Whether the page on screen is the current history entry's. Not after a
1462
+ * back or forward that ended without mounting -- handled in place,
1463
+ * cancelled -- which leaves the browser on an entry whose page never
1464
+ * arrived. Scroll is not recorded in that state either: `entry` stays null
1465
+ * until a page is mounted again.
1466
+ */
1467
+ function showingCurrentEntry() {
1468
+ return stripHash(win.location.href) === mounted;
1469
+ }
1470
+
1471
+ /**
1472
+ * Gives the current history entry a key if it has none -- the first
1473
+ * entry, or one made by an in-page anchor or by code other than this
1474
+ * router -- and returns it.
1475
+ *
1476
+ * @returns {string}
1477
+ */
1478
+ function claimEntry() {
1479
+ const state = objectState();
1480
+ const key = state.tinvar?.key;
1481
+ if (typeof key === 'string') return key;
1482
+
1483
+ const fresh = newKey();
1484
+ win.history.replaceState(
1485
+ { ...state, tinvar: { ...(state.tinvar ?? {}), key: fresh, scroll: [win.scrollX, win.scrollY] } },
1486
+ '',
1487
+ );
1488
+ return fresh;
1489
+ }
1490
+
1491
+ function onScroll() {
1492
+ if (entry === null || mounting > 0 || boundaries > 0) return;
1493
+ keepNewest(positions, entry, [win.scrollX, win.scrollY]);
1494
+ schedulePersist();
1495
+ }
1496
+
1497
+ /**
1498
+ * A scroll inside the page: one of its scroll regions moved. Heard on the
1499
+ * document in the capture phase, since an element's scroll does not
1500
+ * bubble; every region is read, for its place among the others.
1501
+ *
1502
+ * @param {Event} event
1503
+ */
1504
+ function onRegionScroll(event) {
1505
+ if (entry === null || mounting > 0 || boundaries > 0) return;
1506
+ const target = /** @type {Element | null} */ (event.target);
1507
+ if (typeof target?.hasAttribute !== 'function' || !target.hasAttribute(SCROLL_REGION_ATTRIBUTE)) return;
1508
+ keepNewest(regionPositions, entry, readRegions());
1509
+ schedulePersist();
1510
+ }
1511
+
1512
+ /** The page's scroll regions, in document order; none without a DOM. */
1513
+ function regionsOnPage() {
1514
+ const doc = win.document;
1515
+ if (typeof doc?.querySelectorAll !== 'function') return [];
1516
+ return [...doc.querySelectorAll(`[${SCROLL_REGION_ATTRIBUTE}]`)];
1517
+ }
1518
+
1519
+ /** @returns {(readonly [number, number])[]} where each scroll region is scrolled to */
1520
+ function readRegions() {
1521
+ return regionsOnPage().map((region) => /** @type {const} */ ([region.scrollLeft, region.scrollTop]));
1522
+ }
1523
+
1524
+ /** @param {Element} region @param {number} left @param {number} top */
1525
+ function scrollRegion(region, left, top) {
1526
+ if (typeof region.scrollTo === 'function') {
1527
+ region.scrollTo(left, top);
1528
+ } else {
1529
+ region.scrollLeft = left;
1530
+ region.scrollTop = top;
1531
+ }
1532
+ }
1533
+
1534
+ /**
1535
+ * Scrolls each region on the page to the position saved for its place,
1536
+ * and says whether every one is there. A place the page has no region for
1537
+ * yet -- its content still streaming in -- is not there; one a list puts
1538
+ * back by its own anchor is left to the list.
1539
+ *
1540
+ * @param {readonly (readonly [number, number] | null)[]} saved
1541
+ * @param {ReadonlySet<Element>} skip
1542
+ */
1543
+ function scrollRegionsTo(saved, skip) {
1544
+ const regions = regionsOnPage();
1545
+ let there = true;
1546
+ saved.forEach((position, i) => {
1547
+ if (position === null) return;
1548
+ const region = regions[i];
1549
+ if (region === undefined) {
1550
+ there = false;
1551
+ return;
1552
+ }
1553
+ if (skip.has(region)) return;
1554
+ scrollRegion(region, position[0], position[1]);
1555
+ if (Math.abs(region.scrollLeft - position[0]) >= 1 || Math.abs(region.scrollTop - position[1]) >= 1) {
1556
+ there = false;
1557
+ }
1558
+ });
1559
+ return there;
1560
+ }
1561
+
1562
+ /** "Scroll: reset on navigation", the scroll regions with the window. */
1563
+ function resetRegions() {
1564
+ for (const region of regionsOnPage()) scrollRegion(region, 0, 0);
1565
+ }
1566
+
1567
+ /**
1568
+ * One replaceState for whatever has changed since the last: browsers
1569
+ * rate-limit replaceState, and scrolling and typing both change state many
1570
+ * times a second.
1571
+ */
1572
+ function schedulePersist() {
1573
+ clearTimeout(persistTimer);
1574
+ persistTimer = setTimeout(persist, 100);
1575
+ }
1576
+
1577
+ /**
1578
+ * Writes the scroll position into the current entry's state -- only onto
1579
+ * the entry it belongs to: after a back or forward, history.state is
1580
+ * already the one arrived at -- and what useRemember() kept into the
1581
+ * store, under its page's entry.
1582
+ */
1583
+ function persist() {
1584
+ clearTimeout(persistTimer);
1585
+ storeRemembered(remembered);
1586
+
1587
+ const state = win.history.state;
1588
+ if (entry === null || state?.tinvar?.key !== entry) return;
1589
+ const tinvar = { ...state.tinvar, scroll: positions.get(entry) };
1590
+ const regions = regionPositions.get(entry);
1591
+ if (regions !== undefined) tinvar.regions = regions;
1592
+ const lists = anchors.get(entry);
1593
+ if (lists !== undefined) tinvar.anchors = placesOf(lists);
1594
+ win.history.replaceState({ ...state, tinvar }, '');
1595
+ }
1596
+
1597
+ /**
1598
+ * Anchors as history state may hold them: where the item was -- its page,
1599
+ * its place on it, how far down -- never which item it was. A key is
1600
+ * what the page rendered it under, an order number or a customer's id,
1601
+ * and history state is kept in the clear, written to disk with the tab,
1602
+ * read by whatever reads the tab after the session -- what ADR 0019 keeps
1603
+ * remembered state sealed against. The key stays in memory, for a back or
1604
+ * forward within the document; a reload finds the item by its place.
1605
+ *
1606
+ * @param {Record<string, ScrollAnchor>} lists
1607
+ * @returns {Record<string, ScrollAnchor>}
1608
+ */
1609
+ function placesOf(lists) {
1610
+ /** @type {Record<string, ScrollAnchor>} */
1611
+ const places = {};
1612
+ for (const [id, anchor] of Object.entries(lists)) places[id] = { ...anchor, key: null };
1613
+ return places;
1614
+ }
1615
+
1616
+ /**
1617
+ * Forgets where every list was read to, for every entry, and takes the
1618
+ * current entry's out of its state: the session they were recorded under
1619
+ * has ended, or is no longer this one. Other entries' states hold places
1620
+ * alone (placesOf()), and the router can write only the entry it is on.
1621
+ */
1622
+ function forgetAnchors() {
1623
+ anchors.clear();
1624
+ restored = null;
1625
+ const state = win.history.state;
1626
+ if (state?.tinvar?.anchors === undefined) return;
1627
+ const { anchors: _, ...tinvar } = state.tinvar;
1628
+ win.history.replaceState({ ...state, tinvar }, '');
1629
+ }
1630
+
1631
+ /**
1632
+ * The anchors an entry's state holds, as the lists of its page may take
1633
+ * them -- only what has the shape of one, since history state is
1634
+ * anyone's to write.
1635
+ *
1636
+ * @param {unknown} stored
1637
+ * @returns {{ anchors: Record<string, ScrollAnchor>, taken: Set<string>, window: boolean, containers: Set<Element> } | null}
1638
+ */
1639
+ function arrivedAt(stored) {
1640
+ if (stored === null || typeof stored !== 'object') return null;
1641
+ /** @type {Record<string, ScrollAnchor>} */
1642
+ const found = {};
1643
+ for (const [id, anchor] of Object.entries(stored)) {
1644
+ if (isAnchor(anchor)) found[id] = anchor;
1645
+ }
1646
+ return Object.keys(found).length === 0
1647
+ ? null
1648
+ : { anchors: found, taken: new Set(), window: false, containers: new Set() };
1649
+ }
1650
+
1651
+ /**
1652
+ * Writes a page's remembered values to the store, under its entry and its
1653
+ * session.
1654
+ *
1655
+ * A value goes with the session it was set under, and one set under
1656
+ * another session than its page's is dropped rather than written: the
1657
+ * binding is the one taken when the value was set, never the one in force
1658
+ * when the debounce happens to fire, so a sign-out and a sign-in as
1659
+ * someone else in between cannot carry it across. A page bound to no
1660
+ * session, or to one that is no longer this router's, keeps nothing.
1661
+ *
1662
+ * @param {Remembered} record
1663
+ */
1664
+ function storeRemembered(record) {
1665
+ if (record.entry === null || record.session === null || record.session !== binding) return;
1666
+ // Its entry's values are still being opened; writing now would replace
1667
+ // them with whatever little has been set since. See restore().
1668
+ if (record.restoring) return;
1669
+
1670
+ /** @type {Record<string, unknown>} */
1671
+ const values = {};
1672
+ for (const [key, kept] of record.values) {
1673
+ if (!belongs(kept, record)) {
1674
+ record.values.delete(key);
1675
+ continue;
1676
+ }
1677
+ values[key] = kept.value;
1678
+ }
1679
+
1680
+ // Sealed values already on their way are not sent again. A second seal
1681
+ // of the same values -- the debounce, pagehide -- would supersede the
1682
+ // first, and one that pagehide starts never lands: what the first was
1683
+ // about to write would be lost with it.
1684
+ if (record.key !== null) {
1685
+ const sending = sealedAs(record.entry, values);
1686
+ if (sending !== null && sending === record.sealed) return;
1687
+ record.sealed = sending ?? undefined;
1688
+ }
1689
+ void store.write(record.entry, record.session, values, record.key);
1690
+ }
1691
+
1692
+ /**
1693
+ * What a write of an entry's values amounts to, to tell one from the
1694
+ * next; null for values JSON cannot hold, which are never taken as
1695
+ * already sent.
1696
+ *
1697
+ * @param {string} entry
1698
+ * @param {Record<string, unknown>} values
1699
+ */
1700
+ function sealedAs(entry, values) {
1701
+ try {
1702
+ return JSON.stringify([entry, values]);
1703
+ } catch {
1704
+ return null;
1705
+ }
1706
+ }
1707
+
1708
+ /**
1709
+ * Whether a remembered value may be shown or written for a page: it was
1710
+ * set under that page's session, and that session is one.
1711
+ *
1712
+ * @param {{ session: string }} kept
1713
+ * @param {Remembered} record
1714
+ */
1715
+ function belongs(kept, record) {
1716
+ return record.session !== null && kept.session === record.session;
1717
+ }
1718
+
1719
+ /**
1720
+ * A page's history key, imported, from the header or metadata it came
1721
+ * in; null for a page that came with none. Imported for each page rather
1722
+ * than kept by its text: the router holds the key only as a CryptoKey it
1723
+ * cannot export.
1724
+ *
1725
+ * @param {string | null | undefined} encoded
1726
+ * @returns {import('./remember-store.js').HistoryKey | null}
1727
+ */
1728
+ function historyKeyOf(encoded) {
1729
+ return typeof encoded === 'string' && encoded !== '' ? importHistoryKey(encoded, crypto) : null;
1730
+ }
1731
+
1732
+ /**
1733
+ * Opens what a page's entry kept sealed, into its record.
1734
+ *
1735
+ * Anything the page remembered while this ran is newer than what the
1736
+ * entry kept, and stays; it could not be written meanwhile, so it is
1737
+ * written now. A record forgotten meanwhile -- a 401, clearHistory() --
1738
+ * takes nothing back.
1739
+ *
1740
+ * @param {Remembered} record
1741
+ * @returns {Promise<void>}
1742
+ */
1743
+ async function restore(record) {
1744
+ record.restoring = true;
1745
+ /** @type {Record<string, unknown>} */
1746
+ let kept = {};
1747
+ try {
1748
+ kept = await store.open(String(record.entry), record.session, record.key);
1749
+ } catch {
1750
+ // Storage that throws keeps nothing to read back; `ready` still settles.
1751
+ } finally {
1752
+ record.restoring = false;
1753
+ }
1754
+ if (destroyed || record.session === null) return;
1755
+
1756
+ const setMeanwhile = record.values.size > 0;
1757
+ for (const [key, value] of Object.entries(kept)) {
1758
+ if (!record.values.has(key)) record.values.set(key, { session: record.session, value });
1759
+ }
1760
+ if (setMeanwhile) storeRemembered(record);
1761
+ }
1762
+
1763
+ /** The record a component mounting now remembers into; see `remembered`. */
1764
+ function rememberScope() {
1765
+ return incoming ?? remembered;
1766
+ }
1767
+
1768
+ /**
1769
+ * What was remembered under a key, for the page a component belongs to --
1770
+ * undefined when nothing was, or when it was under another session, which
1771
+ * forgets it.
1772
+ *
1773
+ * @param {string} key
1774
+ * @param {Remembered} [scope]
1775
+ */
1776
+ function rememberedValue(key, scope = rememberScope()) {
1777
+ const kept = scope.values.get(key);
1778
+ if (kept === undefined) return undefined;
1779
+ if (!belongs(kept, scope)) {
1780
+ scope.values.delete(key);
1781
+ return undefined;
1782
+ }
1783
+ return kept.value;
1784
+ }
1785
+
1786
+ /**
1787
+ * Remembers a value under a key, for the page a component belongs to,
1788
+ * bound to the session in force now.
1789
+ *
1790
+ * Without a session -- no page has carried a route token -- nothing is
1791
+ * remembered at all: a value bound to no one would be readable by whoever
1792
+ * signs in next.
1793
+ *
1794
+ * @param {string} key
1795
+ * @param {unknown} value
1796
+ * @param {Remembered} [scope]
1797
+ */
1798
+ function rememberValue(key, value, scope = rememberScope()) {
1799
+ if (destroyed || revoked || scope.session === null) return;
1800
+ // The session taken now, with the value, and never looked up again: a
1801
+ // value is only ever bound to the session it was set under.
1802
+ scope.values.set(key, { session: scope.session, value });
1803
+ // A page still mounting has no entry yet; its values are written when
1804
+ // its entry is made.
1805
+ if (scope !== remembered) return;
1806
+ // Sealed values are written at once rather than on the debounce:
1807
+ // sealing is asynchronous, and a write that pagehide is the first to
1808
+ // start never finishes before the document goes.
1809
+ if (scope.key !== null) storeRemembered(scope);
1810
+ schedulePersist();
1811
+ }
1812
+
1813
+ /**
1814
+ * Remembers where this entry was scrolled to, before leaving it -- and
1815
+ * writes what useRemember() kept for it, which a debounce due after the
1816
+ * new entry exists would no longer reach.
1817
+ */
1818
+ function saveScroll() {
1819
+ if (entry === null) return;
1820
+ if (boundaries === 0) {
1821
+ keepNewest(positions, entry, [win.scrollX, win.scrollY]);
1822
+ keepNewest(regionPositions, entry, readRegions());
1823
+ }
1824
+ persist();
1825
+ }
1826
+
1827
+ /** Where an entry was left, by key, falling back to what its state says. */
1828
+ function savedScroll(key, fallback) {
1829
+ return (key !== undefined && positions.get(key)) || fallback;
1830
+ }
1831
+
1832
+ /** Where an entry's scroll regions were left, by key, falling back to its state. */
1833
+ function savedRegions(key, fallback) {
1834
+ return (key !== undefined && regionPositions.get(key)) || regionsOf(fallback);
1835
+ }
1836
+
1837
+ /** Stops the scroll restore in progress, if any. */
1838
+ function stopRestoring() {
1839
+ restoring?.stop();
1840
+ }
1841
+
1842
+ /**
1843
+ * Puts the scroll back where an entry was left -- the window, and each
1844
+ * scroll region by its place on the page -- and keeps at it while the page
1845
+ * grows into that position.
1846
+ *
1847
+ * A page mounted from a stream is often shorter at commit than it will
1848
+ * be: its Suspense content arrives after. A single scrollTo at commit was
1849
+ * clamped to the short page and never revisited, so back to a long page
1850
+ * landed near its top. The target is kept and tried again each frame
1851
+ * until it is reached, the user takes over the scroll, or
1852
+ * SCROLL_RESTORE_MS has passed. A scroll region inside streamed content
1853
+ * may not be there at all at commit, and is waited for the same way.
1854
+ *
1855
+ * @param {readonly number[] | null} position the window's, or null to leave it
1856
+ * @param {readonly (readonly [number, number] | null)[] | null} [regions] the scroll regions', or null to leave them
1857
+ * @param {ReadonlySet<Element>} [skip] regions a list puts back by its own anchor
1858
+ */
1859
+ function restoreScroll(position, regions = null, skip = new Set()) {
1860
+ stopRestoring();
1861
+ /** @type {{ position: readonly number[] | null, stop: () => void }} */
1862
+ const target = { position, stop: noop };
1863
+
1864
+ // Read from `target` each time: a list that takes the window over
1865
+ // meanwhile (takeScrollAnchor()) leaves the regions still to restore.
1866
+ const reach = () => {
1867
+ const at = target.position;
1868
+ if (at !== null) win.scrollTo(at[0], at[1]);
1869
+ const regionsThere = regions === null || scrollRegionsTo(regions, skip);
1870
+ return (at === null || scrolledTo(at[0], at[1])) && regionsThere;
1871
+ };
1872
+ if (reach()) return;
1873
+
1874
+ const started = Date.now();
1875
+ let frame;
1876
+
1877
+ const stop = () => {
1878
+ cancelFrame(frame);
1879
+ for (const type of USER_SCROLL_EVENTS) win.removeEventListener(type, stop, true);
1880
+ if (restoring === target) restoring = null;
1881
+ };
1882
+ target.stop = stop;
1883
+
1884
+ const retry = () => {
1885
+ if (Date.now() - started > SCROLL_RESTORE_MS) return stop();
1886
+ if (reach()) return stop();
1887
+ frame = nextFrame(retry);
1888
+ };
1889
+
1890
+ // Whatever the user does with the page is theirs. A restore that went
1891
+ // on pulling the page back while they scrolled it would fight them.
1892
+ for (const type of USER_SCROLL_EVENTS) win.addEventListener(type, stop, { capture: true, passive: true });
1893
+ restoring = target;
1894
+ frame = nextFrame(retry);
1895
+ }
1896
+
1897
+ /** Whether the window is at a position, give or take a fractional pixel. */
1898
+ function scrolledTo(x, y) {
1899
+ return Math.abs(win.scrollX - x) < 1 && Math.abs(win.scrollY - y) < 1;
1900
+ }
1901
+
1902
+ /** @param {() => void} fn */
1903
+ function nextFrame(fn) {
1904
+ return typeof win.requestAnimationFrame === 'function'
1905
+ ? { raf: win.requestAnimationFrame(fn) }
1906
+ : { timer: setTimeout(fn, 16) };
1907
+ }
1908
+
1909
+ function cancelFrame(frame) {
1910
+ if (frame?.raf !== undefined) win.cancelAnimationFrame(frame.raf);
1911
+ if (frame?.timer !== undefined) clearTimeout(frame.timer);
1912
+ }
1913
+
1914
+ /**
1915
+ * @param {string} href
1916
+ * @param {VisitOptions} [opts]
1917
+ * @returns {Promise<boolean>} whether a page was mounted
1918
+ */
1919
+ function visit(href, opts = {}) {
1920
+ // Malformed paths are the caller's mistake, said to the caller -- not a
1921
+ // failed request, which would end in a full load.
1922
+ if (opts.only !== undefined) pathsOf(opts.only);
1923
+ if (opts.except !== undefined) pathsOf(opts.except);
1924
+ if (opts.reset !== undefined) pathsOf(opts.reset);
1925
+
1926
+ // A GET's data is its query. Merged here, before `before`, so every
1927
+ // listener and the request itself see the URL the visit goes to.
1928
+ const target =
1929
+ opts.data === undefined ? href : withData(new URL(href, win.location.href), opts.data).href;
1930
+
1931
+ return go(target, opts, { redirects: 0, history: null });
1932
+ }
1933
+
1934
+ /**
1935
+ * Every navigation -- a visit, a redirect it follows, back and forward --
1936
+ * goes through here and handle(), so they cannot drift apart. Back and
1937
+ * forward ran a copy of this that had: it skipped the content-type check,
1938
+ * ignored X-Location and X-Redirect, and reported nothing on failure.
1939
+ *
1940
+ * The context lives here rather than on VisitOptions, so callers cannot
1941
+ * set it and it does not leak into the `before` event's detail.
1942
+ *
1943
+ * @typedef {{
1944
+ * redirects: number,
1945
+ * history: { scroll: unknown, regions?: unknown } | null,
1946
+ * sequence?: number,
1947
+ * }} Context
1948
+ *
1949
+ * `redirects` counts the X-Redirects that led here. `history` is set for
1950
+ * back and forward: the browser has already moved to the entry, so there
1951
+ * is nothing to push and nothing to cancel, and the scroll is restored
1952
+ * rather than reset. `sequence` numbers a background request.
1953
+ *
1954
+ * Resolves with whether a page was mounted -- for a background request,
1955
+ * whether it refreshed the page on screen -- so a caller can tell a poll
1956
+ * that landed from one that failed, was dropped, or was never sent.
1957
+ *
1958
+ * @param {string} href
1959
+ * @param {VisitOptions} opts
1960
+ * @param {Context} ctx
1961
+ * @returns {Promise<boolean>}
1962
+ */
1963
+ async function go(href, opts, ctx) {
1964
+ if (destroyed || revoked) return false;
1965
+ const url = new URL(href, win.location.href);
1966
+
1967
+ if (!fetchable(url)) {
1968
+ // Elsewhere is a full load -- which a background request never does.
1969
+ if (!opts.background) hardNavigate(url.href);
1970
+ return false;
1971
+ }
1972
+
1973
+ // Back and forward have already happened; there is nothing to cancel.
1974
+ if (!ctx.history && !emit('before', { url: url.href, options: opts })) {
1975
+ return false;
1976
+ }
1977
+
1978
+ // A new visit supersedes one in flight. Two navigations racing to mount
1979
+ // would leave the page showing whichever finished last, not whichever
1980
+ // the user asked for last.
1981
+ //
1982
+ // A BACKGROUND request is not a navigation and never takes this slot:
1983
+ // a poll must not cancel the page the user just asked for. It goes the
1984
+ // other way. A refresh of the page being left is worthless, and one
1985
+ // that mounted while the user's visit was pending could finish after
1986
+ // it and put the old page back -- so a navigation cancels background
1987
+ // requests, and none starts while a navigation is pending.
1988
+ if (opts.background && inFlight) return false;
1989
+
1990
+ // The caller's own cancel token: one visit, or one poll, stopped
1991
+ // without touching anything else in flight.
1992
+ if (opts.signal?.aborted) return false;
1993
+
1994
+ const controller = new AbortController();
1995
+ visitUrls.set(controller, url.href);
1996
+ if (opts.background) {
1997
+ background.add(controller);
1998
+ backgrounds.add(controller);
1999
+ if (opts.preserveUrl) merging.add(controller);
2000
+ // Numbered, so an older one answering late cannot mount over a newer
2001
+ // one; see handle().
2002
+ ctx = { ...ctx, sequence: ++requested };
2003
+ requestedAs.set(controller, requested);
2004
+ } else {
2005
+ interrupt();
2006
+ inFlight = controller;
2007
+ abortBackground();
2008
+ }
2009
+
2010
+ const cancel = () => {
2011
+ if (inFlight === controller) interrupt();
2012
+ else if (background.delete(controller)) {
2013
+ controller.abort();
2014
+ finish(controller, true);
2015
+ }
2016
+ };
2017
+ opts.signal?.addEventListener('abort', cancel, { once: true });
2018
+
2019
+ // Whether a progress bar should show for it: never for a background
2020
+ // request, and not for a visit that said `showProgress: false`.
2021
+ emit('start', {
2022
+ url: url.href,
2023
+ ...(opts.background ? { background: true } : {}),
2024
+ showProgress: !opts.background && opts.showProgress !== false,
2025
+ });
2026
+
2027
+ /** Takes an instant visit's loading boundary away, if one is showing. */
2028
+ let settle = null;
2029
+
2030
+ try {
2031
+ // Nothing is awaited unless something is kept: up to the request the
2032
+ // visit runs synchronously, so one cancelled straight after it was
2033
+ // made has already sent its one request, and aborts it.
2034
+ const kept = usesCache(opts, ctx) ? cached(url, controller.signal) : null;
2035
+
2036
+ // A kept copy is about as quick as the boundary would be, and is the
2037
+ // page itself; only a visit that waits on the server shows one.
2038
+ if (kept === null && opts.instant && !opts.background && !ctx.history && !isPartial(opts)) {
2039
+ saveScroll();
2040
+ boundaries++;
2041
+ const shown = options.instant?.(opts.instant === true ? true : String(opts.instant)) ?? null;
2042
+ if (shown === null) {
2043
+ // No boundary went up: the page under the visit is still the one
2044
+ // being left, and is read as ever.
2045
+ boundaries--;
2046
+ } else {
2047
+ settle = () => {
2048
+ boundaries--;
2049
+ shown();
2050
+ };
2051
+ }
2052
+ }
2053
+
2054
+ const response = (kept === null ? null : await kept) ?? (await request(url, opts, controller.signal));
2055
+ const shown = await handle(response, url, opts, ctx, controller.signal);
2056
+ // Merged into the page up -- a partial reload, a list's next page --
2057
+ // the page's own payload is still the one being read.
2058
+ if (shown && !isPartial(opts) && opts.preserveUrl !== true) supersede(controller);
2059
+ return shown;
2060
+ } catch (error) {
2061
+ // A superseded visit reports nothing, whatever its mount threw on the
2062
+ // way out -- the visit that replaced it owns the page now.
2063
+ if (controller.signal.aborted || error instanceof VisitCancelled || error?.name === 'AbortError') {
2064
+ return false;
2065
+ }
2066
+
2067
+ // The response arrived; rendering it failed. Not connectivity, so not
2068
+ // networkError -- an app retrying on network errors would retry a
2069
+ // render that fails the same way every time. Its own cancelable
2070
+ // channel; if nobody handles it, a document load renders the page on
2071
+ // the server instead.
2072
+ if (error instanceof MountFailed) {
2073
+ const unhandled = emit('error', { url: url.href, error: error.cause });
2074
+ // The visit's own `onError` first, waited for: whatever it does --
2075
+ // reports the failure somewhere -- happens before the document goes.
2076
+ // A visit made meanwhile owns the page, and is not loaded over.
2077
+ await runCallback(opts.onError, error.cause);
2078
+ if (unhandled && !opts.background && !controller.signal.aborted) hardNavigate(url.href);
2079
+ return false;
2080
+ }
2081
+
2082
+ // Connectivity, not an HTTP status. "Keep three error channels: 422
2083
+ // -> errors, other statuses -> onHttpException, connectivity ->
2084
+ // onNetworkError." The visit's `onError` hears it too, waited for
2085
+ // before the document load, as it is for a render that failed.
2086
+ const unhandled = emit('networkError', { url: url.href, error });
2087
+ await runCallback(opts.onError, new VisitNetworkError(url.href, error));
2088
+ if (unhandled && !opts.background && !controller.signal.aborted) hardNavigate(url.href);
2089
+ return false;
2090
+ } finally {
2091
+ // The page that arrived has replaced it already; a visit that failed,
2092
+ // was cancelled or was superseded puts back the page it came from.
2093
+ settle?.();
2094
+ opts.signal?.removeEventListener('abort', cancel);
2095
+ if (inFlight === controller) inFlight = null;
2096
+ background.delete(controller);
2097
+ finish(controller, false);
2098
+ }
2099
+ }
2100
+
2101
+ /**
2102
+ * @param {URL} url
2103
+ * @param {VisitOptions} opts
2104
+ * @param {AbortSignal} signal
2105
+ * @returns {Promise<Response>}
2106
+ */
2107
+ async function request(url, opts, signal) {
2108
+ // Cancelled while waiting on a prefetch that came to nothing.
2109
+ if (signal.aborted) throw new VisitCancelled();
2110
+
2111
+ const response = await doFetch(url.href, {
2112
+ method: 'GET',
2113
+ headers: {
2114
+ ...pageHeaders(),
2115
+ ...partialHeaders(opts),
2116
+ ...(opts.background ? { [BACKGROUND_HEADER]: '1' } : {}),
2117
+ ...mergeHeaders(opts),
2118
+ ...(opts.refreshOnce ? {} : onceHeaders()),
2119
+ ...opts.headers,
2120
+ },
2121
+ credentials: 'same-origin',
2122
+ // A navigation never follows a cross-origin redirect silently; the
2123
+ // server answers 409 with a location when it wants a full load.
2124
+ redirect: 'manual',
2125
+ signal,
2126
+ });
2127
+
2128
+ if (signal.aborted) throw new VisitCancelled();
2129
+ return response;
2130
+ }
2131
+
2132
+ /** What every request for a page's payload says, a prefetch's included. */
2133
+ function pageHeaders() {
2134
+ return { RSC: '1', accept: RSC_CONTENT_TYPE, 'X-Version': version };
2135
+ }
2136
+
2137
+ /**
2138
+ * What asks for a partial reload: the paths, and the page they are part
2139
+ * of. Nothing when the page on screen never said which it is -- the
2140
+ * server would render the page whole without X-Page regardless.
2141
+ *
2142
+ * @param {VisitOptions} opts
2143
+ * @returns {Record<string, string>}
2144
+ */
2145
+ function partialHeaders(opts) {
2146
+ if (!isPartial(opts) || view === null) return {};
2147
+
2148
+ /** @type {Record<string, string>} */
2149
+ const headers = { 'X-Page': view };
2150
+ if (opts.only !== undefined) headers['X-Only'] = pathsOf(opts.only).join(',');
2151
+ if (opts.except !== undefined) headers['X-Except'] = pathsOf(opts.except).join(',');
2152
+ return headers;
2153
+ }
2154
+
2155
+ /**
2156
+ * The merged fields this response is to replace rather than add to.
2157
+ *
2158
+ * @param {VisitOptions} opts
2159
+ * @returns {Record<string, string>}
2160
+ */
2161
+ function mergeHeaders(opts) {
2162
+ const paths = opts.reset === undefined ? [] : pathsOf(opts.reset);
2163
+ return paths.length === 0 ? {} : { 'X-Reset': paths.join(',') };
2164
+ }
2165
+
2166
+ /**
2167
+ * The #[Once] keys whose rendered content this client keeps, so the
2168
+ * response can leave those fields out -- with the route token of the page
2169
+ * on screen, which the server checks names its own session before it
2170
+ * believes the list (ADR 0020). Only keys kept under that token's
2171
+ * session: what was kept for another is never claimed, and is dropped
2172
+ * when a page for this one arrives.
2173
+ *
2174
+ * Within the server's limits, so the list is never refused whole for
2175
+ * being long: the most recently used keys, which the page on screen's
2176
+ * are, go first.
2177
+ *
2178
+ * @returns {Record<string, string>}
2179
+ */
2180
+ function onceHeaders() {
2181
+ if (routeToken === null) return {};
2182
+
2183
+ const keys = [];
2184
+ let bytes = 0;
2185
+ for (const key of once.claimable(bindingOf(routeToken))) {
2186
+ if (keys.length === MAX_HELD_KEYS || bytes + key.length + 1 > MAX_HELD_BYTES) break;
2187
+ keys.push(key);
2188
+ bytes += key.length + 1;
2189
+ }
2190
+ /** @type {Record<string, string>} */
2191
+ const headers = keys.length === 0 ? {} : { 'X-Have-Once': keys.join(','), 'X-Once-Binding': routeToken };
2192
+
2193
+ // Once-shared values by their holds (ADR 0065), which the server checks
2194
+ // against the session, user, tenant, panel and build asking now. Kept
2195
+ // under the same binding as the keys above, so none sent for another
2196
+ // session is ever handed back.
2197
+ const holds = [];
2198
+ bytes = 0;
2199
+ for (const { hold } of once.holds(bindingOf(routeToken))) {
2200
+ if (holds.length === MAX_SHARED_HOLDS || bytes + hold.length + 1 > MAX_SHARED_BYTES) break;
2201
+ holds.push(hold);
2202
+ bytes += hold.length + 1;
2203
+ }
2204
+ if (holds.length > 0) headers[SHARED_HOLDS_HEADER] = holds.join(',');
2205
+
2206
+ return headers;
2207
+ }
2208
+
2209
+ /**
2210
+ * The once keys a set of claim headers claimed: X-Have-Once's, and each
2211
+ * once-shared value whose hold went in X-Have-Shared.
2212
+ *
2213
+ * @param {Record<string, string>} claims
2214
+ * @returns {string[]}
2215
+ */
2216
+ function claimedBy(claims) {
2217
+ const keys = claims['X-Have-Once']?.split(',') ?? [];
2218
+ const sent = claims[SHARED_HOLDS_HEADER]?.split(',') ?? [];
2219
+ if (sent.length === 0 || routeToken === null) return keys;
2220
+ for (const { key, hold } of once.holds(bindingOf(routeToken))) {
2221
+ if (sent.includes(hold)) keys.push(key);
2222
+ }
2223
+ return keys;
2224
+ }
2225
+
2226
+ /**
2227
+ * Whether a navigation may be answered from the prefetch cache: a plain
2228
+ * GET visit the user made. Not back and forward, which ask for the entry
2229
+ * they arrive at as it is now; not a redirect being followed; not a
2230
+ * background request; not a visit sending headers of its own, which
2231
+ * may be asking for something a prefetch did not; and not one asked to be
2232
+ * `fresh`.
2233
+ *
2234
+ * @param {VisitOptions} opts
2235
+ * @param {Context} ctx
2236
+ */
2237
+ function usesCache(opts, ctx) {
2238
+ return (
2239
+ !opts.background &&
2240
+ !opts.fresh &&
2241
+ !isPartial(opts) &&
2242
+ opts.reset === undefined &&
2243
+ !opts.preserveUrl &&
2244
+ ctx.history === null &&
2245
+ ctx.redirects === 0 &&
2246
+ (opts.method ?? 'get') === 'get' &&
2247
+ Object.keys(opts.headers ?? {}).length === 0
2248
+ );
2249
+ }
2250
+
2251
+ /**
2252
+ * A prefetched copy of a page, or null to ask the server.
2253
+ *
2254
+ * One still in flight is waited for rather than sent again: the click
2255
+ * that follows a hover usually lands while its prefetch is on the way.
2256
+ * A stale one -- past the first of a `cacheFor` pair -- is used, and a
2257
+ * fresh copy fetched behind it for the next visit.
2258
+ *
2259
+ * @param {URL} url
2260
+ * @param {AbortSignal} signal
2261
+ * @returns {Promise<Response | null> | null} null at once when nothing is kept
2262
+ */
2263
+ function cached(url, signal) {
2264
+ const key = stripHash(url.href);
2265
+
2266
+ // A visit to the page on screen is a reload, and asks the server.
2267
+ if (key === mounted) return null;
2268
+
2269
+ const kept = cache.lookup(key);
2270
+ return kept === null ? null : take(kept, signal);
2271
+ }
2272
+
2273
+ /**
2274
+ * @param {{ entry: import('./prefetch-cache.js').Entry, stale: boolean }} kept
2275
+ * @param {AbortSignal} signal
2276
+ * @returns {Promise<Response | null>}
2277
+ */
2278
+ async function take(kept, signal) {
2279
+ const key = kept.entry.url;
2280
+ const response = await kept.entry.response;
2281
+ // A 401 on the prefetch ended the session while this waited.
2282
+ if (signal.aborted || revoked) throw new VisitCancelled();
2283
+ if (response === null) return null;
2284
+
2285
+ // Rendered for another session than the one now on screen, or asked for
2286
+ // under one that has since ended. Nothing it holds may be shown -- nor
2287
+ // anything else kept under that session.
2288
+ if (foreign(kept.entry.binding) || !underThisSession(kept.entry)) {
2289
+ cache.clear();
2290
+ return null;
2291
+ }
2292
+
2293
+ if (kept.stale) void fetchAhead(key, kept.entry.window, kept.entry.tags);
2294
+
2295
+ // A copy: the kept response is never read, so every visit within its
2296
+ // lifetime -- and a back to the page that linked here -- can take one.
2297
+ // With the key it came with, imported; the copy has no header for it.
2298
+ const copy = response.clone();
2299
+ if (kept.entry.historyKey) importedKeys.set(copy, kept.entry.historyKey);
2300
+ return copy;
2301
+ }
2302
+
2303
+ /**
2304
+ * Whether a binding names a session other than the one the client holds
2305
+ * data for. Unknown on either side is not a mismatch: a page without a
2306
+ * route token says nothing about whose session it is.
2307
+ *
2308
+ * @param {string | null} other
2309
+ */
2310
+ function foreign(other) {
2311
+ return other !== null && binding !== null && other !== binding;
2312
+ }
2313
+
2314
+ /**
2315
+ * Whether a kept page was asked for under the session the tab holds now,
2316
+ * by the CSRF cookie: script can read it, the server rotates it with the
2317
+ * session, and every tab shares it. A route token only says whose page it
2318
+ * is once a page carrying one is mounted -- after a sign-in, the redirect
2319
+ * was answered before any had been -- and a sign-out in another tab sends
2320
+ * this one nothing at all. The cookie has changed in both.
2321
+ *
2322
+ * @param {import('./prefetch-cache.js').Entry} entry
2323
+ */
2324
+ function underThisSession(entry) {
2325
+ return entry.csrf === csrfToken(win.document);
2326
+ }
2327
+
2328
+ /**
2329
+ * Takes the binding a mounted page came with. A different session than
2330
+ * the one the cache was filled under purges it -- a sign-in, a sign-out
2331
+ * or a tenant switch all rotate what the token says. So does a page with
2332
+ * none after one with one: the server mints no token for a visitor with
2333
+ * no session, so that is the session ending, not a page keeping quiet.
2334
+ *
2335
+ * @param {string | null} next
2336
+ */
2337
+ function adoptBinding(next) {
2338
+ // What was rendered from once fields for any other session goes; what
2339
+ // the page just mounted kept, it kept under this one.
2340
+ once.keepOnly(next);
2341
+ if (next !== binding) {
2342
+ if (binding !== null) cache.clear();
2343
+ // Whatever was remembered under the session before is gone with it,
2344
+ // for every entry of the tab -- wiped, not merely left unread -- and
2345
+ // where its lists were read to.
2346
+ store.wipe();
2347
+ purgeResumeStore(next, resumeStore);
2348
+ forgetAnchors();
2349
+ }
2350
+ binding = next;
2351
+ }
2352
+
2353
+ /**
2354
+ * Fetches a page ahead of a visit and keeps it for `cacheFor`.
2355
+ *
2356
+ * Sent with `Purpose: prefetch`, so the server can tell it from a visit
2357
+ * and skip what a visit records; the page runs its whole GET pipeline
2358
+ * either way, authorization included. Only GET: "Loaders and queries run
2359
+ * in a read-only transaction ... because hovering a link runs them. Only
2360
+ * GET links prefetch."
2361
+ *
2362
+ * Background work, never a navigation: no `before`, `start` or `finish`,
2363
+ * and nothing here moves the page -- except a 401, which ends the session
2364
+ * for everything this client holds, as it does for a poll.
2365
+ *
2366
+ * @param {string} href
2367
+ * @param {PrefetchOptions} [opts]
2368
+ * @returns {Promise<void>} settles once the page is kept, or is not
2369
+ */
2370
+ function prefetch(href, opts = {}) {
2371
+ if (destroyed || revoked) return Promise.resolve();
2372
+ if (opts.method !== undefined && String(opts.method).toLowerCase() !== 'get') return Promise.resolve();
2373
+
2374
+ const url = new URL(href, win.location.href);
2375
+ if (!fetchable(url)) return Promise.resolve();
2376
+
2377
+ // The page on screen is not worth a second copy: a visit to it is a
2378
+ // reload, and reloads ask the server.
2379
+ const key = stripHash(url.href);
2380
+ if (key === mounted) return Promise.resolve();
2381
+
2382
+ // Read before anything is sent, so a malformed duration throws at the
2383
+ // caller rather than after a request nobody can use.
2384
+ const window = cacheWindow(opts.cacheFor);
2385
+ const tags = tagsOf(opts.cacheTags);
2386
+
2387
+ let kept = cache.lookup(key);
2388
+ if (kept && !underThisSession(kept.entry)) {
2389
+ // Kept under a session that has since ended: nothing kept under it is
2390
+ // worth keeping, and this page is asked for again under this one.
2391
+ cache.clear();
2392
+ kept = null;
2393
+ }
2394
+ if (kept && !kept.stale) {
2395
+ // Two links to one page may name different tags; a change to either
2396
+ // makes the one copy out of date.
2397
+ kept.entry.tags = [...new Set([...kept.entry.tags, ...tags])];
2398
+ return kept.entry.response.then(noop);
2399
+ }
2400
+
2401
+ return fetchAhead(key, window, tags);
2402
+ }
2403
+
2404
+ /**
2405
+ * @param {string} key the page's URL, without its fragment
2406
+ * @param {{ fresh: number, stale: number }} window
2407
+ * @param {readonly string[]} tags
2408
+ * @returns {Promise<void>}
2409
+ */
2410
+ function fetchAhead(key, window, tags) {
2411
+ /** @type {import('./prefetch-cache.js').Entry} */
2412
+ const entry = {
2413
+ url: key,
2414
+ tags,
2415
+ window,
2416
+ controller: new AbortController(),
2417
+ binding: null,
2418
+ csrf: csrfToken(win.document),
2419
+ arrivedAt: null,
2420
+ response: Promise.resolve(null),
2421
+ };
2422
+
2423
+ // Kept before the request starts, so a click from here on waits for
2424
+ // this request instead of sending its own.
2425
+ cache.add(entry);
2426
+ entry.response = fetchEntry(entry);
2427
+
2428
+ return entry.response.then(noop);
2429
+ }
2430
+
2431
+ /**
2432
+ * @param {import('./prefetch-cache.js').Entry} entry
2433
+ * @returns {Promise<Response | null>}
2434
+ */
2435
+ async function fetchEntry(entry) {
2436
+ const { signal } = entry.controller;
2437
+ emit('prefetching', { url: entry.url });
2438
+
2439
+ // Once keys too: what is kept stays until a fresh copy replaces it, it
2440
+ // is dropped to make room -- which drops this entry with it -- or the
2441
+ // session goes, and the session going clears this cache.
2442
+ const claims = onceHeaders();
2443
+ entry.claimed = claimedBy(claims);
2444
+
2445
+ let response;
2446
+ try {
2447
+ response = await doFetch(entry.url, {
2448
+ method: 'GET',
2449
+ headers: { ...pageHeaders(), ...claims, Purpose: PREFETCH_PURPOSE },
2450
+ credentials: 'same-origin',
2451
+ redirect: 'manual',
2452
+ signal,
2453
+ });
2454
+ } catch {
2455
+ // Offline, or stopped. Nothing is kept, and a click asks again.
2456
+ cache.forget(entry);
2457
+ return null;
2458
+ }
2459
+
2460
+ if (signal.aborted || destroyed) {
2461
+ cache.forget(entry);
2462
+ return null;
2463
+ }
2464
+
2465
+ if (response.status === 401) {
2466
+ revoke(null, signal);
2467
+ return null;
2468
+ }
2469
+
2470
+ // Only a page: a redirect, a version mismatch or an error is the
2471
+ // visit's to handle when it happens, not something to replay.
2472
+ const type = response.headers.get('content-type') ?? '';
2473
+ if (response.status !== 200 || !type.startsWith(RSC_CONTENT_TYPE) || !response.body) {
2474
+ cache.forget(entry);
2475
+ return null;
2476
+ }
2477
+
2478
+ // Rendered for another session, by its token. Or the session cookie
2479
+ // changed while it was on its way, and whose page it is cannot be told.
2480
+ // Either way, nothing kept under the session before is kept now.
2481
+ const theirs = bindingOf(response.headers.get(ROUTE_TOKEN_HEADER));
2482
+ if (foreign(theirs) || !underThisSession(entry)) {
2483
+ cache.clear();
2484
+ return null;
2485
+ }
2486
+
2487
+ // Flushed while it was in flight -- by a cancel or a purge -- in which
2488
+ // case the answer is no longer wanted.
2489
+ if (!cache.arrived(entry, theirs)) return null;
2490
+
2491
+ emit('prefetched', { url: entry.url });
2492
+ return withoutKey(response, entry);
2493
+ }
2494
+
2495
+ /**
2496
+ * A prefetched response as the cache keeps it: its history key imported
2497
+ * onto the entry, as a key that cannot be exported, and the header taken
2498
+ * off. Kept with it, the raw key sat in memory for the entry's lifetime,
2499
+ * in headers anything holding the response could read (ADR 0019).
2500
+ *
2501
+ * @param {Response} response
2502
+ * @param {import('./prefetch-cache.js').Entry} entry
2503
+ * @returns {Response}
2504
+ */
2505
+ function withoutKey(response, entry) {
2506
+ const raw = response.headers.get(HISTORY_KEY_HEADER);
2507
+ if (raw === null) return response;
2508
+ entry.historyKey = historyKeyOf(raw);
2509
+ const headers = new Headers(response.headers);
2510
+ headers.delete(HISTORY_KEY_HEADER);
2511
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
2512
+ }
2513
+
2514
+ /**
2515
+ * What each response means.
2516
+ *
2517
+ * @param {Response} response
2518
+ * @param {URL} url
2519
+ * @param {VisitOptions} opts
2520
+ * @param {Context} ctx
2521
+ * @param {AbortSignal} signal aborted once a later navigation supersedes this one
2522
+ * @returns {Promise<boolean>} whether a page was mounted
2523
+ */
2524
+ async function handle(response, url, opts, ctx, signal) {
2525
+ // A background request -- a poll, a prefetch -- is something the user
2526
+ // did not ask for, so it may never take the page away from them. The
2527
+ // spec says this of a version mismatch ("does not force a reload"); it
2528
+ // holds for every other reason a foreground visit would fall back to a
2529
+ // full load as well, or a poll could yank the user to another URL.
2530
+ const fullLoad = (href) => {
2531
+ if (!opts.background) hardNavigate(href);
2532
+ };
2533
+
2534
+ // Whatever a response says it invalidated is out of date in the
2535
+ // prefetch cache, whatever else the response is -- a redirect after an
2536
+ // action carries the tags as much as the action's own answer does. And
2537
+ // so is any once field kept under one of those tags: no longer claimed.
2538
+ const invalidated = invalidatedTags(response);
2539
+ cache.deleteTagged(invalidated);
2540
+ once.flushTags(invalidated);
2541
+
2542
+ // 409 + X-Location: "Full page load: version mismatch on GET, or
2543
+ // external redirect." For a BACKGROUND request the spec says a mismatch
2544
+ // "does not force a reload but fires a cancelable location event so the
2545
+ // app can show a 'new version' toast" -- interrupting a user who did not
2546
+ // navigate would be its own bug.
2547
+ if (response.status === 409) {
2548
+ const location = response.headers.get('x-location');
2549
+ if (location) {
2550
+ // Against the URL that was requested, as a Location is (RFC 9110),
2551
+ // not the address bar -- a relative target from a page at another
2552
+ // path resolved somewhere else entirely.
2553
+ const target = new URL(location, url);
2554
+
2555
+ // A version reload sends the client back to the page it asked for;
2556
+ // the fragment never reaches the server, so the location cannot
2557
+ // carry it. Put back, as Inertia does, when the location is the
2558
+ // same page -- not for an external redirect somewhere else.
2559
+ if (!target.hash && target.origin === url.origin && target.pathname === url.pathname) {
2560
+ target.hash = url.hash;
2561
+ }
2562
+ if (opts.background) {
2563
+ emit('location', { url: target.href, background: true });
2564
+ return false;
2565
+ }
2566
+ if (emit('location', { url: target.href, background: false })) {
2567
+ hardNavigate(target.href);
2568
+ }
2569
+ return false;
2570
+ }
2571
+
2572
+ // 409 + X-Redirect: "Redirect with a preserved fragment."
2573
+ const redirect = response.headers.get('x-redirect');
2574
+ if (redirect) {
2575
+ // Following one is a navigation, which a background request never
2576
+ // makes.
2577
+ if (opts.background) return false;
2578
+
2579
+ // A redirect that leads back to itself -- or round a cycle -- would
2580
+ // otherwise be followed until something ran out. Past the limit the
2581
+ // document is loaded instead, where the browser's own redirect
2582
+ // handling applies.
2583
+ if (ctx.redirects >= MAX_REDIRECTS) {
2584
+ fullLoad(url.href);
2585
+ return false;
2586
+ }
2587
+
2588
+ // Followed INSIDE this visit, not as a new one. A nested visit with
2589
+ // replace: true ran before this one had pushed anything, so it
2590
+ // overwrote the page being LEFT, and it emitted a second, cancelable
2591
+ // `before` that could strand the visit half done. The visit's own
2592
+ // replace setting stands; back and forward replace the entry they
2593
+ // arrived at rather than pushing a new one after it.
2594
+ const next = new URL(withFragment(redirect, url.hash), url);
2595
+ if (!fetchable(next)) {
2596
+ fullLoad(next.href);
2597
+ return false;
2598
+ }
2599
+
2600
+ const followed = await request(next, opts, signal);
2601
+ return handle(
2602
+ followed,
2603
+ next,
2604
+ ctx.history ? { ...opts, replace: true } : opts,
2605
+ { redirects: ctx.redirects + 1, history: null },
2606
+ signal,
2607
+ );
2608
+ }
2609
+ }
2610
+
2611
+ // A redirect the fetch did not follow (redirect: 'manual' yields an
2612
+ // opaque response). The server is meant to answer 409 instead; a raw
2613
+ // redirect is a full load rather than a guess at where it pointed.
2614
+ if (response.type === 'opaqueredirect') {
2615
+ fullLoad(url.href);
2616
+ return false;
2617
+ }
2618
+
2619
+ if (!response.ok) {
2620
+ // Cancelable, so an app can render its own error page in place. If
2621
+ // nobody does, a full load of the same URL shows the server's.
2622
+ const handled = !emit('httpException', { url: url.href, status: response.status });
2623
+ const failure = new VisitHttpError(url.href, response.status);
2624
+
2625
+ // The session has ended: what is held for it goes first, whatever
2626
+ // the callback does or however long it takes -- a callback that
2627
+ // never settled must not keep one session's pages on screen.
2628
+ if (response.status === 401) {
2629
+ // The page on screen, for a background request and for a visit to
2630
+ // the page itself: reload(), a link to it.
2631
+ const onScreen = opts.background || stripHash(url.href) === mounted;
2632
+ revoke(onScreen ? null : url.href, signal);
2633
+ await runCallback(opts.onError, failure);
2634
+ return false;
2635
+ }
2636
+
2637
+ // Told before anything is loaded in the page's place, and waited for.
2638
+ await runCallback(opts.onError, failure);
2639
+ // A visit made while the callback ran owns the page now.
2640
+ if (!handled && !signal.aborted) fullLoad(url.href);
2641
+ return false;
2642
+ }
2643
+
2644
+ const type = response.headers.get('content-type') ?? '';
2645
+ if (!type.startsWith(RSC_CONTENT_TYPE) || !response.body) {
2646
+ // A 200 that is not a payload is not something to hand the RSC
2647
+ // reader. Load it as a document instead.
2648
+ fullLoad(url.href);
2649
+ return false;
2650
+ }
2651
+
2652
+ // Any page rendered for another session -- signed out in another tab,
2653
+ // say -- means what was prefetched under this one must go, whether or
2654
+ // not this response is mounted.
2655
+ const theirs = bindingOf(response.headers.get(ROUTE_TOKEN_HEADER));
2656
+ if (foreign(theirs)) cache.clear();
2657
+
2658
+ // Only part of a page: the segments a partial reload recomputed, to go
2659
+ // into the page on screen. A partial request the server answered with
2660
+ // the whole page -- it is another page now, or partial reloads are off --
2661
+ // is mounted as the page it is.
2662
+ const partial = response.headers.has(PARTIAL_HEADER);
2663
+ const shows = partial ? view : response.headers.get(PAGE_HEADER);
2664
+
2665
+ if (opts.background) {
2666
+ // Rendered for another session than the page on screen: another tab
2667
+ // signed in as someone else, or out, while this one went on polling or
2668
+ // loading the list. Mounted under this page's token, the other
2669
+ // session's orders went into this one's list. The session has changed
2670
+ // under the page, and is treated as it would be at a 401: nothing more
2671
+ // is mounted, everything held for it goes, and the page is loaded
2672
+ // again under whoever the session is now.
2673
+ if (foreign(theirs)) {
2674
+ revoke(null, signal);
2675
+ return false;
2676
+ }
2677
+
2678
+ // Only the page already showing may be refreshed in the background --
2679
+ // that is a poll. Mounting any other page would replace what the user
2680
+ // is looking at with something they did not ask for, so a background
2681
+ // response for another URL is never mounted. Either way: no history,
2682
+ // no scroll, and the route token stays the one the user's own
2683
+ // navigation produced.
2684
+ //
2685
+ // Compared with the MOUNTED page, not the address bar: after a back
2686
+ // that failed in place, the bar names a page that is not on screen,
2687
+ // and refreshing "it" put that page up under the old one's token.
2688
+ //
2689
+ // One exception: `preserveUrl`, another page of a merged list for the
2690
+ // page on screen. Only a partial payload of it is mounted -- the server
2691
+ // sends one only for the page the request named in X-Page -- and merged
2692
+ // into what is shown (ADR 0020). A whole page means the URL is some
2693
+ // other page now, and is never mounted in the background.
2694
+ const loading = opts.preserveUrl === true && partial;
2695
+ if ((loading || stripHash(url.href) === mounted) && showingCurrentEntry()) {
2696
+ // Overlapping polls answer in any order. One older than what is on
2697
+ // screen is dropped; the older ones still out are stopped, so none
2698
+ // can commit after this one and put an earlier page back.
2699
+ //
2700
+ // Not an action's re-render still mounting, though older by number.
2701
+ // A poll knows nothing of the action: a partial one re-renders a
2702
+ // segment, and stopping the whole page the action sent back left its
2703
+ // layout as it was before the action, its submission settled with
2704
+ // nothing re-rendered. Both commit, in the order they arrive.
2705
+ //
2706
+ // Nor a list's next page: it is not an older copy of what a poll
2707
+ // brought but more of the list, and dropping it left the reader at
2708
+ // the end of the list with nothing loading.
2709
+ const sequence = ctx.sequence ?? 0;
2710
+ if (!loading && sequence < newestMounted) return false;
2711
+ for (const c of [...background]) {
2712
+ if (
2713
+ c.signal === signal ||
2714
+ applying.has(c) ||
2715
+ merging.has(c) ||
2716
+ (requestedAs.get(c) ?? 0) > sequence
2717
+ ) {
2718
+ continue;
2719
+ }
2720
+ background.delete(c);
2721
+ c.abort();
2722
+ finish(c, true);
2723
+ }
2724
+
2725
+ const payload = observe(response.body, { url: url.href, background: true }, signal);
2726
+ try {
2727
+ await mountPage(payload.stream, {
2728
+ url: url.href,
2729
+ routeToken,
2730
+ signal,
2731
+ partial,
2732
+ // A poll, a live refresh, a list's next page: the page the reader
2733
+ // is on, which keeps its state unless asked otherwise.
2734
+ preserveState: preserves(opts.preserveState, { url: url.href, view: shows, partial }, true),
2735
+ });
2736
+ } catch (error) {
2737
+ payload.stop();
2738
+ throw error;
2739
+ }
2740
+ if (signal.aborted) throw new VisitCancelled();
2741
+ newestMounted = Math.max(newestMounted, sequence);
2742
+ view = shows;
2743
+ emit('success', {
2744
+ url: url.href,
2745
+ background: true,
2746
+ partial,
2747
+ liveToken: response.headers.get(LIVE_TOKEN_HEADER),
2748
+ });
2749
+ await runCallback(opts.onSuccess, { url: url.href, view: shows, partial });
2750
+ return true;
2751
+ }
2752
+ return false;
2753
+ }
2754
+
2755
+ // A restore still reaching for a position on the page being left has
2756
+ // nothing more to do there, and must not move the page that replaces it.
2757
+ stopRestoring();
2758
+
2759
+ // Back and forward have already left their entry; its position was
2760
+ // recorded as the user scrolled.
2761
+ if (!ctx.history) saveScroll();
2762
+
2763
+ // Decided before the mount, while the address bar still names the entry
2764
+ // being replaced: only an entry whose own page is on screen keeps its
2765
+ // state. After a back that failed in place, the bar's entry belongs to
2766
+ // a page that never mounted, and keeping its state carried one page's
2767
+ // app state onto another.
2768
+ // A visit that keeps the address bar where it is stays on its entry, so
2769
+ // whatever URL it fetched, it is the same page's for all of this.
2770
+ const samePage = (opts.preserveUrl === true || stripHash(url.href) === mounted) && showingCurrentEntry();
2771
+
2772
+ const page = {
2773
+ url: url.href,
2774
+ routeToken: response.headers.get(ROUTE_TOKEN_HEADER),
2775
+ signal,
2776
+ partial,
2777
+ // Inertia's default: a visit shows a fresh page, its components
2778
+ // mounted again; reload() and a partial reload keep theirs -- the
2779
+ // segments a partial reload re-renders are put back where they were,
2780
+ // state and all (ADR 0015) -- and so does a visit that keeps the
2781
+ // address bar where it is.
2782
+ preserveState: preserves(
2783
+ opts.preserveState,
2784
+ { url: url.href, view: shows, partial },
2785
+ partial || opts.preserveUrl === true,
2786
+ ),
2787
+ };
2788
+
2789
+ // Whether the window and the scroll regions stay where they are, decided
2790
+ // once, now the response is in, as `preserveState` is and told the same.
2791
+ const keepScroll = preserves(opts.preserveScroll, { url: url.href, view: shows, partial }, false);
2792
+
2793
+ // What the page being mounted remembers (useRemember). An entry arrived
2794
+ // at by back or forward brings back its own values; the same page again
2795
+ // under the same session -- a reload, a partial reload -- keeps the
2796
+ // page's; any other page starts with nothing. Bound to the session the
2797
+ // page came with, so a page rendered after a sign-in never reads what
2798
+ // was remembered before it -- and to no session at all when it came
2799
+ // with no route token. A page that does not say whose it is must not
2800
+ // be taken for the last session's: unbound, it reads and keeps nothing.
2801
+ //
2802
+ // Sealed with the history key the page came with, if any (ADR 0019). An
2803
+ // entry kept sealed is opened with it before the page mounts, so the
2804
+ // page's components mount with what it kept; one sealed under another
2805
+ // session's key does not open.
2806
+ const session = theirs;
2807
+ const historyKey = importedKeys.get(response) ?? historyKeyOf(response.headers.get(HISTORY_KEY_HEADER));
2808
+ const reuse = samePage && !ctx.history && session === remembered.session;
2809
+ const arrived = ctx.history ? String(objectState().tinvar?.key) : null;
2810
+ const kept = arrived === null ? {} : await store.open(arrived, session, historyKey);
2811
+ if (signal.aborted) throw new VisitCancelled();
2812
+
2813
+ /** @type {Remembered} */
2814
+ const record = reuse
2815
+ ? remembered
2816
+ : { entry: null, session, values: valuesOf(kept, session), key: historyKey, restoring: false };
2817
+ // A page kept through a reload seals with the key it comes with now;
2818
+ // one that comes with none keeps sealing with the key it had, rather
2819
+ // than writing in the clear what was typed under encryption.
2820
+ if (reuse && historyKey !== null) record.key = historyKey;
2821
+
2822
+ // While a page mounts, what is on screen is neither page: a shorter
2823
+ // page makes the browser clamp the scroll and fire `scroll`, and that
2824
+ // clamped position was recorded against the page being left. Its
2825
+ // position was saved just above; nothing is recorded while any mount is
2826
+ // in progress.
2827
+ //
2828
+ // A count rather than clearing `entry`: clearing it meant a visit that
2829
+ // never committed had to put the old key back, and a superseded visit
2830
+ // whose mount settled late put it back while a NEWER visit was mounting.
2831
+ mounting++;
2832
+ incoming = record;
2833
+
2834
+ // Where the lists on the entry arrived at were read to, for them to
2835
+ // take as they mount; nothing for any other visit, which starts its
2836
+ // lists at the top -- nor for a page of another session than the one
2837
+ // they were read under, which forgets them as it is adopted.
2838
+ restored =
2839
+ ctx.history && theirs === binding
2840
+ ? arrivedAt(anchors.get(arrived ?? '') ?? objectState().tinvar?.anchors)
2841
+ : null;
2842
+
2843
+ const payload = observe(response.body, { url: url.href }, signal);
2844
+ try {
2845
+ await mountPage(payload.stream, page, opts.viewTransition);
2846
+
2847
+ // A mount takes time, and another navigation -- a click, a back press
2848
+ // -- can start meanwhile. Checking only after the fetch let a
2849
+ // superseded visit go on to push its URL over the newer one, and left
2850
+ // the router holding the newer page's route token while showing this
2851
+ // one, so an action revalidated a page that was not on screen. Nothing
2852
+ // below runs for a visit that has been superseded, and the token is
2853
+ // only taken once this page is the one that stays.
2854
+ if (signal.aborted) throw new VisitCancelled();
2855
+ } catch (error) {
2856
+ if (incoming === record) incoming = null;
2857
+ payload.stop();
2858
+ throw error;
2859
+ } finally {
2860
+ mounting--;
2861
+ }
2862
+ routeToken = page.routeToken;
2863
+ adoptBinding(theirs);
2864
+ if (!opts.preserveUrl) mounted = stripHash(url.href);
2865
+ view = shows;
2866
+
2867
+ // The page's own record from now on; see `remembered`.
2868
+ if (incoming === record) incoming = null;
2869
+ remembered = record;
2870
+
2871
+ if (ctx.history) {
2872
+ // "Restore on back/forward."
2873
+ entry = claimEntry();
2874
+ record.entry = entry;
2875
+ storeRemembered(record);
2876
+ const saved = savedScroll(entry, ctx.history.scroll);
2877
+ const regions = savedRegions(entry, ctx.history.regions);
2878
+ if (restored?.window) {
2879
+ // A list on the page took its anchor and puts the reader back by
2880
+ // it, reloading the pages it had; the raw position would land on
2881
+ // whatever the page holds at that offset now. The scroll regions
2882
+ // are still put back.
2883
+ if (regions !== null) restoreScroll(null, regions, restored.containers);
2884
+ } else if (Array.isArray(saved)) {
2885
+ restoreScroll(saved, regions, restored?.containers);
2886
+ } else {
2887
+ // An entry this router never recorded -- pushed by other code. The
2888
+ // previous page's position means nothing on it.
2889
+ resetRegions();
2890
+ if (!scrollToFragment(url.hash)) win.scrollTo(0, 0);
2891
+ }
2892
+ } else if (opts.preserveUrl) {
2893
+ // The address bar and its entry stay as they were: nothing pushed,
2894
+ // nothing replaced. The page took over the entry's record above, or
2895
+ // starts its own on the entry now.
2896
+ if (record.entry === null && entry !== null) {
2897
+ record.entry = entry;
2898
+ storeRemembered(record);
2899
+ }
2900
+ if (!keepScroll) {
2901
+ resetRegions();
2902
+ if (!scrollToFragment(url.hash)) win.scrollTo(0, 0);
2903
+ }
2904
+ if (entry !== null) keepNewest(regionPositions, entry, readRegions());
2905
+ } else {
2906
+ const key = newKey();
2907
+ const scroll = keepScroll ? [win.scrollX, win.scrollY] : [0, 0];
2908
+ const ours = { key, scroll };
2909
+
2910
+ // A visit to the URL already in the address bar -- a link to the page
2911
+ // it is on -- replaces the entry, as a browser's own navigation does.
2912
+ // Pushed, each click added another copy of the page for back to step
2913
+ // through.
2914
+ if (opts.replace || url.href === win.location.href) {
2915
+ // Replacing the entry for the SAME page -- reload(), a filter that
2916
+ // stays on the page -- keeps whatever else the entry holds. It was
2917
+ // overwritten wholesale, so anything an app had stored on the entry
2918
+ // was gone after a reload. A different page starts clean.
2919
+ const kept = samePage ? objectState() : {};
2920
+ win.history.replaceState({ ...kept, tinvar: ours }, '', url.href);
2921
+ // The replaced entry is gone from history; so is any use for its
2922
+ // position. Without this every reload() and poll-driven replace
2923
+ // left one behind.
2924
+ if (entry !== null) {
2925
+ positions.delete(entry);
2926
+ regionPositions.delete(entry);
2927
+ anchors.delete(entry);
2928
+ }
2929
+ } else {
2930
+ win.history.pushState({ tinvar: ours }, '', url.href);
2931
+ }
2932
+ entry = key;
2933
+ // What the page remembered as it mounted goes under its entry now
2934
+ // that there is one; a replaced entry's values go with the entry.
2935
+ if (record.entry !== null && record.entry !== key) store.write(record.entry, record.session, {});
2936
+ record.entry = key;
2937
+ storeRemembered(record);
2938
+ keepNewest(positions, key, scroll);
2939
+
2940
+ // "Scroll: reset on navigation", unless asked to keep it -- the scroll
2941
+ // regions with the window, as Inertia resets them. A fragment in the
2942
+ // URL is a request to go somewhere specific on the new page.
2943
+ if (!keepScroll) {
2944
+ resetRegions();
2945
+ if (!scrollToFragment(url.hash)) win.scrollTo(0, 0);
2946
+ }
2947
+ keepNewest(regionPositions, key, readRegions());
2948
+ }
2949
+
2950
+ emit('success', { url: url.href, partial, liveToken: response.headers.get(LIVE_TOKEN_HEADER) });
2951
+ emit('navigate', { url: url.href, type: ctx.history ? 'history' : 'visit' });
2952
+ // The visit's own `onSuccess`, waited for before its `finish`.
2953
+ await runCallback(opts.onSuccess, { url: url.href, view: shows, partial });
2954
+ return true;
2955
+ }
2956
+
2957
+ /**
2958
+ * Back and forward. The URL has already changed by the time this runs,
2959
+ * so the page is fetched for it and the saved scroll position put back.
2960
+ */
2961
+ function onPopState(/** @type {PopStateEvent} */ event) {
2962
+ const saved = savedScroll(event.state?.tinvar?.key, event.state?.tinvar?.scroll);
2963
+
2964
+ // An in-page anchor makes its own history entry, and the browser fires
2965
+ // popstate moving between it and its neighbours. The document has not
2966
+ // changed, so this is a scroll, not a navigation: refetching remounted
2967
+ // the whole page and threw away its state for a jump to a heading.
2968
+ if (stripHash(win.location.href) === mounted) {
2969
+ interrupt();
2970
+ const regions = savedRegions(event.state?.tinvar?.key, event.state?.tinvar?.regions);
2971
+ entry = claimEntry();
2972
+ // Still the same page: what it remembers goes with it to this entry.
2973
+ remembered.entry = entry;
2974
+ persist();
2975
+ if (Array.isArray(saved)) {
2976
+ restoreScroll(saved, regions);
2977
+ } else {
2978
+ stopRestoring();
2979
+ scrollToFragment(win.location.hash);
2980
+ }
2981
+ emit('navigate', { url: win.location.href, type: 'history' });
2982
+ return;
2983
+ }
2984
+
2985
+ // What the page being left remembered, written now: the debounce could
2986
+ // fire after the next page mounts, when `remembered` is already that
2987
+ // page's, and whatever was typed in its last moments was lost. Only the
2988
+ // store -- the scroll in history.state belongs to the entry arrived at.
2989
+ clearTimeout(persistTimer);
2990
+ storeRemembered(remembered);
2991
+
2992
+ // Until the entry's page is mounted, what is on screen belongs to the
2993
+ // entry that was left, and scrolling it must not be recorded against
2994
+ // the one arrived at.
2995
+ entry = null;
2996
+
2997
+ void go(
2998
+ win.location.href,
2999
+ {},
3000
+ {
3001
+ redirects: 0,
3002
+ history: { scroll: saved, regions: event.state?.tinvar?.regions },
3003
+ },
3004
+ );
3005
+ }
3006
+
3007
+ win.addEventListener('popstate', onPopState);
3008
+ win.addEventListener('scroll', onScroll, { passive: true });
3009
+ // A window with no DOM has no regions to hear.
3010
+ if (typeof win.document?.addEventListener === 'function') {
3011
+ win.document.addEventListener('scroll', onRegionScroll, { capture: true, passive: true });
3012
+ }
3013
+ // A reload or a closed tab ends the page before the debounce can fire.
3014
+ win.addEventListener('pagehide', persist);
3015
+
3016
+ // scrollRestoration is manual, so the browser does not put a reloaded
3017
+ // document back where it was, nor one reached by back from another
3018
+ // document -- and nothing here did either, so both landed at the top.
3019
+ // The page may still be streaming in and is about to be hydrated;
3020
+ // restoreScroll keeps reaching for the position until the page has grown
3021
+ // into it, which covers the first commit. The scroll regions with it.
3022
+ const leftAt = Array.isArray(left) && Number.isFinite(left[0]) && Number.isFinite(left[1]) ? left : null;
3023
+ if (leftAt !== null || leftRegions !== null) restoreScroll(leftAt, leftRegions, restored?.containers);
3024
+
3025
+ /**
3026
+ * Mounts a re-rendered payload of the page on screen -- what an action's
3027
+ * revalidation sends back -- without asking the server again.
3028
+ *
3029
+ * "The response carries a fresh RSC payload for affected mounted routes."
3030
+ * It is mounted as a background refresh is: onto the page on screen only,
3031
+ * with no history and no scroll, and the route token kept. Never while a
3032
+ * visit is in flight -- that payload is of the page being left, and
3033
+ * mounting it could put that page back over the one the user asked for --
3034
+ * and a visit started meanwhile stops it, as it stops a poll. Resolves
3035
+ * once the page has committed, so a caller settling a revalidation after
3036
+ * it (beginRevalidation()) settles it with the new page up.
3037
+ *
3038
+ * Of the visit options a form was given, `viewTransition`,
3039
+ * `preserveState`, `onSuccess` and `onError` apply: the page stays where
3040
+ * it is, and nothing is pushed or replaced.
3041
+ *
3042
+ * `liveToken`, given, is the re-render's live token, handed on in the
3043
+ * `success` event as a reload's X-Live-Token is (ADR 0048); not given,
3044
+ * the event carries none, and the live module keeps what it holds.
3045
+ *
3046
+ * `routeToken` is the token the action was sent with: the payload is the
3047
+ * re-render of the page that token names. Once another page has been
3048
+ * mounted -- the user followed a link while the action ran -- it is no
3049
+ * longer the page on screen, and mounting it would put the page they left
3050
+ * back up under the new page's URL and token. So it is refused.
3051
+ *
3052
+ * @param {string | Uint8Array | ReadableStream<Uint8Array>} payload
3053
+ * @param {VisitOptions & { routeToken?: string | null, liveToken?: string | null }} [opts]
3054
+ * @returns {Promise<boolean>} whether the payload was mounted
3055
+ */
3056
+ async function applyPayload(payload, opts = {}) {
3057
+ if (destroyed || revoked || inFlight) return false;
3058
+ if (opts.routeToken !== undefined && opts.routeToken !== routeToken) return false;
3059
+
3060
+ const controller = new AbortController();
3061
+ const sequence = ++requested;
3062
+ visitUrls.set(controller, mounted);
3063
+ requestedAs.set(controller, sequence);
3064
+ applying.add(controller);
3065
+ background.add(controller);
3066
+ backgrounds.add(controller);
3067
+ emit('start', { url: mounted, background: true, showProgress: false });
3068
+
3069
+ try {
3070
+ // An older background refresh still on its way must not commit over
3071
+ // this one.
3072
+ for (const c of [...background]) {
3073
+ if (c === controller || (requestedAs.get(c) ?? 0) > sequence) continue;
3074
+ background.delete(c);
3075
+ c.abort();
3076
+ finish(c, true);
3077
+ }
3078
+
3079
+ const body =
3080
+ typeof ReadableStream !== 'undefined' && payload instanceof ReadableStream
3081
+ ? payload
3082
+ : /** @type {ReadableStream<Uint8Array>} */ (new Response(/** @type {BodyInit} */ (payload)).body);
3083
+ // The page re-rendered in place, the form that sent the action on
3084
+ // it: its state stays unless the form's options say otherwise.
3085
+ await mountPage(
3086
+ body,
3087
+ {
3088
+ url: mounted,
3089
+ routeToken,
3090
+ signal: controller.signal,
3091
+ partial: false,
3092
+ preserveState: preserves(opts.preserveState, { url: mounted, view, partial: false }, true),
3093
+ },
3094
+ opts.viewTransition,
3095
+ );
3096
+ if (controller.signal.aborted) return false;
3097
+ supersede(controller);
3098
+
3099
+ newestMounted = Math.max(newestMounted, sequence);
3100
+ emit('success', {
3101
+ url: mounted,
3102
+ background: true,
3103
+ ...('liveToken' in opts ? { liveToken: opts.liveToken ?? null } : {}),
3104
+ });
3105
+ // The visit after the action, as a redirect's or a reload's would be.
3106
+ await runCallback(opts.onSuccess, { url: mounted, view, partial: false });
3107
+ return true;
3108
+ } catch (error) {
3109
+ if (controller.signal.aborted || error instanceof VisitCancelled) return false;
3110
+ // A payload that cannot be rendered leaves the page as it was, and
3111
+ // says so on the router's own channel; a background refresh never
3112
+ // loads a document in its place.
3113
+ if (error instanceof MountFailed) {
3114
+ emit('error', { url: mounted, error: error.cause, background: true });
3115
+ await runCallback(opts.onError, error.cause);
3116
+ return false;
3117
+ }
3118
+ throw error;
3119
+ } finally {
3120
+ background.delete(controller);
3121
+ finish(controller, false);
3122
+ }
3123
+ }
3124
+
3125
+ const router = {
3126
+ visit,
3127
+ isEndpoint,
3128
+ get: (href, opts = {}) => visit(href, { ...opts, method: 'get' }),
3129
+ /**
3130
+ * The page on screen, fetched again. Given the `routeToken` an action
3131
+ * was sent with, refused as applyPayload() refuses: once another page
3132
+ * has been mounted since, or while a visit is in flight -- reloading
3133
+ * would cut off the visit the user started while the action ran.
3134
+ */
3135
+ reload: ({ routeToken: sentFrom, ...opts } = {}) => {
3136
+ if (sentFrom !== undefined && (inFlight || sentFrom !== routeToken)) return Promise.resolve(false);
3137
+ return visit(win.location.href, { preserveState: true, ...opts, replace: true, preserveScroll: true });
3138
+ },
3139
+ prefetch,
3140
+ applyPayload,
3141
+
3142
+ /**
3143
+ * Tells the page an action flashed something: the router's `flash`
3144
+ * event, to its listeners and as a native `tinvar:flash` CustomEvent on
3145
+ * the document, as every router event is.
3146
+ *
3147
+ * @param {Record<string, unknown>} flash
3148
+ * @param {Record<string, unknown>} [detail] more to say, as the action's name
3149
+ */
3150
+ flash(flash, detail = {}) {
3151
+ emit('flash', { ...detail, flash });
3152
+ },
3153
+
3154
+ /**
3155
+ * Tells the page about an action call, for the forms layer -- <Form>,
3156
+ * useForm and useHttp: `actionStart` as it is sent, `progress` as its
3157
+ * files upload, and `actionSettle` once its answer has been applied to
3158
+ * the page. To the router's listeners, and as native `tinvar:<name>`
3159
+ * CustomEvents on the document, as every router event is. None is
3160
+ * cancelable: the call is under way, and what stops one is its form's
3161
+ * `cancel()`.
3162
+ *
3163
+ * Only those three: the rest of the router's events say what the router
3164
+ * did, and are not anyone else's to emit.
3165
+ *
3166
+ * @param {'actionStart' | 'progress' | 'actionSettle'} name
3167
+ * @param {Record<string, unknown>} [detail]
3168
+ */
3169
+ announce(name, detail = {}) {
3170
+ if (!ANNOUNCED.has(name)) {
3171
+ throw new TypeError(`[tinvar:client] '${name}' is not an event the forms layer announces.`);
3172
+ }
3173
+ emit(name, detail);
3174
+ },
3175
+
3176
+ /**
3177
+ * Forgets the prefetched copy of a page, so its next visit asks the
3178
+ * server.
3179
+ *
3180
+ * @param {string} href
3181
+ */
3182
+ flush(href) {
3183
+ cache.delete(stripHash(new URL(href, win.location.href).href));
3184
+ },
3185
+
3186
+ /** Forgets every prefetched page. */
3187
+ flushAll() {
3188
+ cache.clear();
3189
+ },
3190
+
3191
+ /**
3192
+ * Ends the session on this client, for a 401 heard by something other
3193
+ * than the router -- an action, a useHttp call, a fragment: the route
3194
+ * token, every prefetched page and everything useRemember() kept, for
3195
+ * every entry of the tab, are dropped, whatever is in flight is
3196
+ * stopped, and nothing more is sent. The caller loads the page, as the
3197
+ * router does on a 401 of its own: "cleared, with a full page load".
3198
+ */
3199
+ revoke() {
3200
+ if (destroyed) return;
3201
+ forgetSession(null);
3202
+ },
3203
+
3204
+ /**
3205
+ * "Logout calls `clearHistory()` to rotate the key" (tinvar-spec.md):
3206
+ * everything remembered, for every entry of the tab, is wiped -- seals
3207
+ * still under way included -- and the history key the router holds is
3208
+ * dropped, so nothing it sealed can be opened by this document again.
3209
+ * Nothing more is remembered on the page it is called on; the next page
3210
+ * remembers under the key it comes with.
3211
+ *
3212
+ * The key itself is the session's (ADR 0019): it rotates when the
3213
+ * server gives the session a new id, which signing out does. This only
3214
+ * makes sure nothing sealed under the old one is left behind meanwhile.
3215
+ */
3216
+ clearHistory() {
3217
+ if (destroyed) return;
3218
+ for (const record of [remembered, incoming]) {
3219
+ if (record === null) continue;
3220
+ record.values.clear();
3221
+ record.session = null;
3222
+ record.key = null;
3223
+ }
3224
+ store.wipe();
3225
+ purgeResumeStore(null, resumeStore);
3226
+ // And what once fields rendered: client-held data of the same session.
3227
+ // Kept in memory alone, never in storage, so there is nothing sealed
3228
+ // to rotate a key for -- but nothing of it outlives the sign-out
3229
+ // either, on this document or a later one (ADR 0020).
3230
+ once.clear();
3231
+ // And where the lists were read to, whose keys name what was read.
3232
+ forgetAnchors();
3233
+ // And what else holds the session's data: the live module.
3234
+ emit('clearHistory');
3235
+ },
3236
+
3237
+ /**
3238
+ * Settles once what the first page's entry remembered has been read
3239
+ * back -- at once unless its history is encrypted. Await it before
3240
+ * hydrating, so what was remembered is there when components mount.
3241
+ */
3242
+ ready,
3243
+
3244
+ /**
3245
+ * Forgets every prefetched page carrying one of these tags -- given by
3246
+ * the `cacheTags` it was prefetched with.
3247
+ *
3248
+ * @param {string | readonly string[]} tags
3249
+ * @returns {number} how many pages were forgotten
3250
+ */
3251
+ flushByCacheTags(tags) {
3252
+ const listed = tagsOf(tags);
3253
+ once.flushTags(listed);
3254
+ return cache.deleteTagged(listed);
3255
+ },
3256
+
3257
+ /**
3258
+ * Forgets what an action's response says it invalidated; see the
3259
+ * module's flushInvalidated(), which does this for every router.
3260
+ *
3261
+ * @param {Response | Headers | { headers?: Headers } | string | null | undefined} source
3262
+ * @returns {string[]} the tags it named
3263
+ */
3264
+ flushInvalidated(source) {
3265
+ const tags = invalidatedTags(source);
3266
+ cache.deleteTagged(tags);
3267
+ once.flushTags(tags);
3268
+ return tags;
3269
+ },
3270
+
3271
+ /**
3272
+ * What a page kept of a #[Once] field, for <OnceSlot> to put back where
3273
+ * the server left the field out -- only for a page of the session it
3274
+ * was kept under. `scope` is the page the slot belongs to, from
3275
+ * rememberScope().
3276
+ *
3277
+ * @param {string} key
3278
+ * @param {Remembered} [scope]
3279
+ * @returns {{ content: unknown } | undefined}
3280
+ */
3281
+ heldOnce(key, scope = rememberScope()) {
3282
+ return once.read(key, scope.session);
3283
+ },
3284
+
3285
+ /**
3286
+ * The once-shared values' holds, as a fragment request claims them
3287
+ * (ADR 0074): X-Have-Shared with the holds kept under the page on
3288
+ * screen's session, which the server checks against the session, user,
3289
+ * tenant, panel and build asking now. No X-Have-Once: a fragment claims
3290
+ * no loader field.
3291
+ *
3292
+ * @returns {Record<string, string>}
3293
+ */
3294
+ sharedClaims() {
3295
+ if (destroyed || revoked) return {};
3296
+ const headers = onceHeaders();
3297
+ const holds = headers[SHARED_HOLDS_HEADER];
3298
+ return holds === undefined ? {} : { [SHARED_HOLDS_HEADER]: holds };
3299
+ },
3300
+
3301
+ /**
3302
+ * Keeps what a page rendered from a #[Once] field, under the session the
3303
+ * page came with, so the next request can list the key in X-Have-Once --
3304
+ * or, for a once-shared value, hand its hold back in X-Have-Shared.
3305
+ *
3306
+ * @param {string} key
3307
+ * @param {unknown} content
3308
+ * @param {number | null} ttl seconds it may be listed for
3309
+ * @param {readonly string[]} tags
3310
+ * @param {Remembered} [scope]
3311
+ * @param {string | null} [hold] a once-shared value's signed hold (ADR 0065)
3312
+ */
3313
+ holdOnce(key, content, ttl, tags, scope = rememberScope(), hold = null) {
3314
+ if (destroyed || revoked) return;
3315
+ // A prefetched page sent without a field kept under a key dropped to
3316
+ // make room could not be filled; it is asked for again instead.
3317
+ cache.deleteClaiming(once.record(key, scope.session, content, ttl, tags, hold));
3318
+ },
3319
+
3320
+ /**
3321
+ * Stops listing once keys -- the given ones, or all -- so the next
3322
+ * response sends those fields fresh. What was kept stays shown until
3323
+ * then.
3324
+ *
3325
+ * @param {string | readonly string[]} [keys]
3326
+ */
3327
+ flushOnce(keys) {
3328
+ once.flush(keys === undefined ? undefined : tagsOf(keys));
3329
+ },
3330
+
3331
+ /**
3332
+ * Moves the address bar to another URL of the page on screen, without a
3333
+ * request: what <InfiniteScroll> does as the reader scrolls from one
3334
+ * page of a list into the next. The entry is replaced, not pushed, and
3335
+ * keeps its state -- its scroll position and what was remembered on it
3336
+ * -- since it is still the same page.
3337
+ *
3338
+ * Refused for another origin, and while the page on screen is not the
3339
+ * entry's (a back or forward is pending).
3340
+ *
3341
+ * @param {string} href
3342
+ * @returns {boolean} whether the address bar moved
3343
+ */
3344
+ replaceUrl(href) {
3345
+ if (destroyed) return false;
3346
+ const target = new URL(href, win.location.href);
3347
+ if (!fetchable(target) || !showingCurrentEntry()) return false;
3348
+ win.history.replaceState(win.history.state, '', target.href);
3349
+ mounted = stripHash(target.href);
3350
+ return true;
3351
+ },
3352
+
3353
+ /**
3354
+ * Records where a merged list on the page on screen is read to, on the
3355
+ * page's history entry: what <InfiniteScroll> calls as the reader
3356
+ * scrolls. Persisted with the scroll position. Not while a page is
3357
+ * being mounted or a back or forward is pending, when the list on
3358
+ * screen belongs to no entry.
3359
+ *
3360
+ * @param {string} id the list's dot path
3361
+ * @param {ScrollAnchor} anchor
3362
+ */
3363
+ saveScrollAnchor(id, anchor) {
3364
+ if (destroyed || entry === null || mounting > 0 || !showingCurrentEntry() || !isAnchor(anchor)) return;
3365
+ // Nothing after the session has ended on this page -- a 401, or
3366
+ // clearHistory(), after which the page remembers nothing more.
3367
+ if (revoked || (remembered.session === null && binding !== null)) return;
3368
+ keepNewest(anchors, entry, { ...anchors.get(entry), [id]: anchor });
3369
+ schedulePersist();
3370
+ },
3371
+
3372
+ /**
3373
+ * Where a merged list was read to on the entry a back, a forward or a
3374
+ * reload arrived at, for the list to put the reader back by -- once;
3375
+ * undefined after any other visit. One in the window stops the raw
3376
+ * position being restored, which the list does instead; one in a
3377
+ * scroll container -- given as `container` -- stops that container's,
3378
+ * when it is a scroll region.
3379
+ *
3380
+ * @param {string} id the list's dot path
3381
+ * @param {Element | null} [container] the element the list scrolls in, null for the window
3382
+ * @returns {ScrollAnchor | undefined}
3383
+ */
3384
+ takeScrollAnchor(id, container = null) {
3385
+ if (destroyed || restored === null || restored.taken.has(id)) return undefined;
3386
+ const anchor = restored.anchors[id];
3387
+ if (anchor === undefined) return undefined;
3388
+ restored.taken.add(id);
3389
+ if (anchor.window) {
3390
+ restored.window = true;
3391
+ // The window's part of the restore alone: the scroll regions are
3392
+ // still put back.
3393
+ if (restoring !== null) restoring.position = null;
3394
+ } else if (container !== null && typeof container === 'object') {
3395
+ restored.containers.add(container);
3396
+ }
3397
+ return anchor;
3398
+ },
3399
+
3400
+ /**
3401
+ * The page a component mounting now belongs to, for what it remembers:
3402
+ * the page being mounted while a visit mounts one, otherwise the page on
3403
+ * screen. useRemember() takes it once, as it mounts.
3404
+ */
3405
+ rememberScope,
3406
+
3407
+ /**
3408
+ * The value remembered under a key for a page, or undefined -- also when
3409
+ * it was set under another session, which forgets it.
3410
+ */
3411
+ remembered: rememberedValue,
3412
+
3413
+ /**
3414
+ * Remembers a value under a key for a page, on its history entry,
3415
+ * bound to the session in force as it is set. Without a session nothing
3416
+ * is remembered.
3417
+ */
3418
+ remember: rememberValue,
3419
+
3420
+ /**
3421
+ * Cancels what is in flight: the visit (`sync`), background requests
3422
+ * such as polls (`async`) and prefetches (`prefetch`). All three unless
3423
+ * told otherwise. Prefetched pages that have arrived are kept.
3424
+ *
3425
+ * @param {{ sync?: boolean, async?: boolean, prefetch?: boolean }} [which]
3426
+ */
3427
+ cancelAll({ sync = true, async = true, prefetch: prefetches = true } = {}) {
3428
+ if (sync) interrupt();
3429
+ if (async) abortBackground();
3430
+ if (prefetches) cache.abortInFlight();
3431
+ },
3432
+
3433
+ /**
3434
+ * Subscribes to a router event. Returns an unsubscribe function.
3435
+ *
3436
+ * @param {string} name
3437
+ * @param {(event: CustomEvent) => void} fn
3438
+ */
3439
+ on(name, fn) {
3440
+ if (!listeners.has(name)) listeners.set(name, new Set());
3441
+ listeners.get(name).add(fn);
3442
+ return () => listeners.get(name)?.delete(fn);
3443
+ },
3444
+
3445
+ /**
3446
+ * The route tokens to send with an action, so it can re-render the page
3447
+ * that is mounted. The server only re-renders routes the action itself
3448
+ * invalidated, so sending the token is not asking for anything.
3449
+ *
3450
+ * @returns {string[]}
3451
+ */
3452
+ routeTokens() {
3453
+ return routeToken === null ? [] : [routeToken];
3454
+ },
3455
+
3456
+ /**
3457
+ * The view of the page on screen, as the server named it -- in the page
3458
+ * metadata, or a payload's X-Page -- or null where it did not: what a
3459
+ * page with no route name files its reports under (ADR 0129).
3460
+ *
3461
+ * @returns {string | null}
3462
+ */
3463
+ currentView() {
3464
+ return view;
3465
+ },
3466
+
3467
+ /** Removes the router's listeners. */
3468
+ destroy() {
3469
+ destroyed = true;
3470
+ live.delete(router);
3471
+ stopReload();
3472
+ inFlight?.abort();
3473
+ showing?.abort();
3474
+ abortBackground();
3475
+ cache.clear();
3476
+ once.clear();
3477
+ win.removeEventListener('popstate', onPopState);
3478
+ win.removeEventListener('pagehide', persist);
3479
+ win.removeEventListener('scroll', onScroll);
3480
+ win.document?.removeEventListener?.('scroll', onRegionScroll, { capture: true });
3481
+ clearTimeout(persistTimer);
3482
+ stopRestoring();
3483
+ listeners.clear();
3484
+ },
3485
+ };
3486
+
3487
+ live.add(router);
3488
+ return router;
3489
+ }
3490
+
3491
+ /** Keeps the fragment of the page being left, as "X-Redirect" preserves it. */
3492
+ function withFragment(target, hash) {
3493
+ if (!hash) return target;
3494
+
3495
+ const url = new URL(target, 'http://placeholder');
3496
+ if (url.hash) return target;
3497
+
3498
+ return target + hash;
3499
+ }
3500
+
3501
+ let keys = 0;
3502
+
3503
+ /** A key for a history entry, unique within this tab's history. */
3504
+ function newKey() {
3505
+ return `${Date.now().toString(36)}.${(keys++).toString(36)}`;
3506
+ }
3507
+
3508
+ function stripHash(href) {
3509
+ const url = new URL(href);
3510
+ url.hash = '';
3511
+ return url.href;
3512
+ }
3513
+
3514
+ /**
3515
+ * Whether a value has a scroll anchor's shape. History state is the page's
3516
+ * to write as much as the router's, so nothing read back from it is taken
3517
+ * on trust.
3518
+ *
3519
+ * @param {unknown} value
3520
+ * @returns {value is ScrollAnchor}
3521
+ */
3522
+ function isAnchor(value) {
3523
+ if (value === null || typeof value !== 'object') return false;
3524
+ const anchor = /** @type {Record<string, unknown>} */ (value);
3525
+ const whole = (/** @type {unknown} */ n) => Number.isInteger(n);
3526
+ return (
3527
+ whole(anchor.page) &&
3528
+ (anchor.key === null || typeof anchor.key === 'string') &&
3529
+ whole(anchor.index) &&
3530
+ Number(anchor.index) >= 0 &&
3531
+ Number.isFinite(anchor.top) &&
3532
+ whole(anchor.first) &&
3533
+ whole(anchor.last) &&
3534
+ typeof anchor.window === 'boolean'
3535
+ );
3536
+ }
3537
+
3538
+ /**
3539
+ * Whether this document was loaded by a reload or by back or forward: a
3540
+ * return to an entry the user has seen, rather than a fresh navigation.
3541
+ *
3542
+ * @param {Window} win
3543
+ */
3544
+ function isReturn(win) {
3545
+ const performance = win.performance;
3546
+ const [timing] =
3547
+ typeof performance?.getEntriesByType === 'function' ? performance.getEntriesByType('navigation') : [];
3548
+ if (timing && 'type' in timing) return timing.type === 'reload' || timing.type === 'back_forward';
3549
+
3550
+ // The older interface, where Navigation Timing 2 is missing: 1 is a
3551
+ // reload, 2 is back or forward.
3552
+ const legacy = /** @type {{ navigation?: { type?: number } } | undefined} */ (performance)?.navigation
3553
+ ?.type;
3554
+ return legacy === 1 || legacy === 2;
3555
+ }