@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.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- 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
|
+
}
|