@tinvar/react 0.0.0-stage → 0.4.1
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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The prefetch cache: pages fetched ahead of a click, kept for a while.
|
|
2
|
+
//
|
|
3
|
+
// tinvar-spec.md, "Caching and invalidation": "`<Link prefetch>` on hover
|
|
4
|
+
// (75 ms default), `"click"` (mousedown), `"mount"`, or an array of them.
|
|
5
|
+
// `cacheFor` takes a TTL or a stale-while-revalidate pair such as
|
|
6
|
+
// `['30s', '1m']`."
|
|
7
|
+
//
|
|
8
|
+
// What is kept is the response itself, not its bytes: a click that lands
|
|
9
|
+
// while the prefetch is still streaming waits for its headers and then reads
|
|
10
|
+
// the rest as it arrives, rather than waiting for the whole body or asking
|
|
11
|
+
// the server a second time.
|
|
12
|
+
//
|
|
13
|
+
// The cache is client-held data, so it belongs to one session ("Client-held
|
|
14
|
+
// data ... is bound to (user, tenant)"). Each entry records the binding of
|
|
15
|
+
// the response it holds and the CSRF cookie it was asked for under, and the
|
|
16
|
+
// router purges everything when either stops matching. This module only
|
|
17
|
+
// keeps the entries; when to purge is the router's.
|
|
18
|
+
|
|
19
|
+
/** How long a prefetched page is used for when `cacheFor` is not given. */
|
|
20
|
+
export const DEFAULT_CACHE_FOR = 30_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many pages are kept at once. A long catalog with `prefetch="mount"` on
|
|
24
|
+
* every card would otherwise hold every page it links to, each a whole RSC
|
|
25
|
+
* payload, for as long as the tab is open.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_ENTRIES = 50;
|
|
28
|
+
|
|
29
|
+
const UNITS = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A duration in milliseconds: a number of milliseconds, or a string such as
|
|
33
|
+
* `'500ms'`, `'30s'`, `'1m'`, `'2h'` or `'1d'`.
|
|
34
|
+
*
|
|
35
|
+
* @param {number | string} value
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
export function parseDuration(value) {
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
41
|
+
} else if (typeof value === 'string') {
|
|
42
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value.trim());
|
|
43
|
+
if (match) return Number(match[1]) * UNITS[/** @type {keyof typeof UNITS} */ (match[2])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Thrown rather than read as zero: a typo that silently turned caching off
|
|
47
|
+
// would look like a cache that never works.
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
`[tinvar:client] Cannot read ${JSON.stringify(value)} as a duration; write '30s' or 30000.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* When a prefetched page stops being fresh and when it stops being usable,
|
|
55
|
+
* in milliseconds after it arrived.
|
|
56
|
+
*
|
|
57
|
+
* A single TTL is fresh for that long and then gone. A pair is
|
|
58
|
+
* stale-while-revalidate: fresh until the first, then still used -- while a
|
|
59
|
+
* new copy is fetched behind it -- until the second, which is the whole
|
|
60
|
+
* lifetime, not a further period after the first.
|
|
61
|
+
*
|
|
62
|
+
* @param {number | string | readonly [number | string, number | string]} [cacheFor]
|
|
63
|
+
* @returns {{ fresh: number, stale: number }}
|
|
64
|
+
*/
|
|
65
|
+
export function cacheWindow(cacheFor = DEFAULT_CACHE_FOR) {
|
|
66
|
+
if (Array.isArray(cacheFor)) {
|
|
67
|
+
const [fresh, stale] = cacheFor.map(parseDuration);
|
|
68
|
+
if (fresh === undefined || stale === undefined || cacheFor.length !== 2) {
|
|
69
|
+
throw new TypeError("[tinvar:client] A cacheFor pair is [fresh, stale], as ['30s', '1m'].");
|
|
70
|
+
}
|
|
71
|
+
if (stale < fresh) {
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
'[tinvar:client] In a cacheFor pair the second duration is the whole lifetime, so it cannot be the shorter.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return { fresh, stale };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ttl = parseDuration(/** @type {number | string} */ (cacheFor));
|
|
80
|
+
return { fresh: ttl, stale: ttl };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {{
|
|
85
|
+
* url: string,
|
|
86
|
+
* tags: string[],
|
|
87
|
+
* window: { fresh: number, stale: number },
|
|
88
|
+
* response: Promise<Response | null>,
|
|
89
|
+
* controller: AbortController,
|
|
90
|
+
* binding: string | null,
|
|
91
|
+
* csrf: string | null,
|
|
92
|
+
* arrivedAt: number | null,
|
|
93
|
+
* claimed?: readonly string[],
|
|
94
|
+
* historyKey?: import('./remember-store.js').HistoryKey | null,
|
|
95
|
+
* }} Entry
|
|
96
|
+
*
|
|
97
|
+
* `response` resolves to the stored response once its headers are in, or to
|
|
98
|
+
* null when what came back is not worth keeping; the entry is gone by then.
|
|
99
|
+
* `arrivedAt` is null while it is in flight. `binding` is the session the
|
|
100
|
+
* response was rendered for, once known. `csrf` is the CSRF cookie the page
|
|
101
|
+
* held when it was asked for, which rotates with the session. `claimed` is
|
|
102
|
+
* the #[Once] keys it was asked for with, whose fields it may have been
|
|
103
|
+
* sent without. `historyKey` is the history key the page came with, imported
|
|
104
|
+
* -- never the raw key, whose header is taken off the response kept.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
export class PrefetchCache {
|
|
108
|
+
/** @type {Map<string, Entry>} */
|
|
109
|
+
#entries = new Map();
|
|
110
|
+
#max;
|
|
111
|
+
#now;
|
|
112
|
+
|
|
113
|
+
/** @param {{ max?: number, now?: () => number }} [options] */
|
|
114
|
+
constructor(options = {}) {
|
|
115
|
+
this.#max = options.max ?? MAX_ENTRIES;
|
|
116
|
+
this.#now = options.now ?? Date.now;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get size() {
|
|
120
|
+
return this.#entries.size;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The entry for a URL, in flight or arrived, if it can still be used, and
|
|
125
|
+
* whether it is past fresh. An expired entry is dropped on the way.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} url
|
|
128
|
+
* @returns {{ entry: Entry, stale: boolean } | null}
|
|
129
|
+
*/
|
|
130
|
+
lookup(url) {
|
|
131
|
+
const entry = this.#entries.get(url);
|
|
132
|
+
if (!entry) return null;
|
|
133
|
+
if (entry.arrivedAt === null) return { entry, stale: false };
|
|
134
|
+
|
|
135
|
+
const age = this.#now() - entry.arrivedAt;
|
|
136
|
+
if (age >= entry.window.stale) {
|
|
137
|
+
this.#drop(url, entry);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { entry, stale: age >= entry.window.fresh };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Starts keeping an entry, in flight. It replaces any other for the URL.
|
|
146
|
+
*
|
|
147
|
+
* @param {Entry} entry
|
|
148
|
+
*/
|
|
149
|
+
add(entry) {
|
|
150
|
+
const previous = this.#entries.get(entry.url);
|
|
151
|
+
if (previous && previous !== entry) this.#drop(entry.url, previous);
|
|
152
|
+
|
|
153
|
+
// Oldest first, as a Map iterates: what was prefetched longest ago is
|
|
154
|
+
// the least likely to be clicked next.
|
|
155
|
+
while (this.#entries.size >= this.#max) {
|
|
156
|
+
const [url, oldest] = this.#entries.entries().next().value ?? [];
|
|
157
|
+
if (url === undefined || oldest === undefined) break;
|
|
158
|
+
this.#drop(url, oldest);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.#entries.set(entry.url, entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Marks an entry as arrived, if it is still the one kept for its URL.
|
|
166
|
+
*
|
|
167
|
+
* @param {Entry} entry
|
|
168
|
+
* @param {string | null} binding
|
|
169
|
+
* @returns {boolean} whether it was still kept
|
|
170
|
+
*/
|
|
171
|
+
arrived(entry, binding) {
|
|
172
|
+
if (this.#entries.get(entry.url) !== entry) return false;
|
|
173
|
+
entry.arrivedAt = this.#now();
|
|
174
|
+
entry.binding = binding;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Forgets an entry, if it is still the one kept for its URL. A request
|
|
180
|
+
* still in flight for it is left to finish: whoever is waiting on it --
|
|
181
|
+
* a click that landed mid-prefetch -- still gets its answer.
|
|
182
|
+
*
|
|
183
|
+
* @param {Entry} entry
|
|
184
|
+
*/
|
|
185
|
+
forget(entry) {
|
|
186
|
+
if (this.#entries.get(entry.url) === entry) this.#entries.delete(entry.url);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string} url */
|
|
190
|
+
delete(url) {
|
|
191
|
+
const entry = this.#entries.get(url);
|
|
192
|
+
if (entry) this.#drop(url, entry);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Forgets every entry, and stops those still in flight. */
|
|
196
|
+
clear() {
|
|
197
|
+
for (const [url, entry] of [...this.#entries]) this.#drop(url, entry);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Forgets every entry carrying any of these tags -- one still in flight
|
|
202
|
+
* too, since it may have been answered from before the change that
|
|
203
|
+
* invalidated it.
|
|
204
|
+
*
|
|
205
|
+
* @param {Iterable<string>} tags
|
|
206
|
+
* @returns {number} how many were forgotten
|
|
207
|
+
*/
|
|
208
|
+
deleteTagged(tags) {
|
|
209
|
+
const wanted = new Set(tags);
|
|
210
|
+
if (wanted.size === 0) return 0;
|
|
211
|
+
|
|
212
|
+
let forgotten = 0;
|
|
213
|
+
for (const [url, entry] of [...this.#entries]) {
|
|
214
|
+
if (!entry.tags.some((tag) => wanted.has(tag))) continue;
|
|
215
|
+
this.#drop(url, entry);
|
|
216
|
+
forgotten++;
|
|
217
|
+
}
|
|
218
|
+
return forgotten;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Forgets every entry asked for claiming any of these once keys: the
|
|
223
|
+
* browser no longer keeps what they stand for, so a page sent without
|
|
224
|
+
* those fields has nothing to fill them with.
|
|
225
|
+
*
|
|
226
|
+
* @param {readonly string[]} keys
|
|
227
|
+
*/
|
|
228
|
+
deleteClaiming(keys) {
|
|
229
|
+
if (keys.length === 0) return;
|
|
230
|
+
for (const [url, entry] of [...this.#entries]) {
|
|
231
|
+
if (entry.claimed?.some((key) => keys.includes(key))) this.#drop(url, entry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stops every request still in flight, and forgets those entries. Arrived
|
|
237
|
+
* entries are kept.
|
|
238
|
+
*/
|
|
239
|
+
abortInFlight() {
|
|
240
|
+
for (const [url, entry] of [...this.#entries]) {
|
|
241
|
+
if (entry.arrivedAt === null) this.#drop(url, entry);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} url
|
|
247
|
+
* @param {Entry} entry
|
|
248
|
+
*/
|
|
249
|
+
#drop(url, entry) {
|
|
250
|
+
this.#entries.delete(url);
|
|
251
|
+
// An arrived response may still be read by a visit that took a copy of
|
|
252
|
+
// it; aborting would cut that visit's stream. Only a request still
|
|
253
|
+
// waiting on its headers is stopped.
|
|
254
|
+
if (entry.arrivedAt === null) entry.controller.abort();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Router } from './router';
|
|
2
|
+
|
|
3
|
+
/** How long a visit runs before the bar appears, unless told otherwise, in ms. */
|
|
4
|
+
export declare const PROGRESS_DELAY_MS: number;
|
|
5
|
+
/** How often the bar creeps forward while the server works, in ms. */
|
|
6
|
+
export declare const TRICKLE_MS: number;
|
|
7
|
+
/** How long a finished bar stays full before it goes, in ms. */
|
|
8
|
+
export declare const FINISH_MS: number;
|
|
9
|
+
|
|
10
|
+
export interface ProgressState {
|
|
11
|
+
/** Whether to draw the bar: a visit has run past the delay, and nothing has hidden it. */
|
|
12
|
+
visible: boolean;
|
|
13
|
+
/** How far along, from 0 to 1. Creeps toward 0.9; 1 once the page has committed. */
|
|
14
|
+
value: number;
|
|
15
|
+
/** The visit is done and the bar is at 1, on its way out. */
|
|
16
|
+
finishing: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProgressStore {
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
snapshot(): ProgressState;
|
|
22
|
+
/** Hides the bar until a matching reveal(); calls nest. */
|
|
23
|
+
hide(): void;
|
|
24
|
+
/** Undoes one hide(). */
|
|
25
|
+
reveal(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The progress state for a router: one per router, with the delay the first caller gives. */
|
|
29
|
+
export declare function progressFor(router: Router, options?: { delay?: number }): ProgressStore;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The progress bar's state for the router above, for the app to draw in its
|
|
33
|
+
* own style, and hide()/reveal(). The bar finishes when the page commits --
|
|
34
|
+
* its first meaningful flush -- not when the stream ends.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useProgress(options?: {
|
|
37
|
+
delay?: number;
|
|
38
|
+
}): ProgressState & { hide: () => void; reveal: () => void };
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The progress bar's state, for an app to draw in its own style.
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "Client router and components": "Progress bar with a delay
|
|
6
|
+
// before showing, ref-counted `hide()`/`reveal()`, and per-visit
|
|
7
|
+
// `showProgress`. With streaming, finish at the first meaningful flush."
|
|
8
|
+
//
|
|
9
|
+
// Only the state lives here -- whether a bar is showing and how far along it
|
|
10
|
+
// is -- because how it looks is the design system's. A visit's `finish`
|
|
11
|
+
// comes once its page has COMMITTED, which for a streamed page is its shell
|
|
12
|
+
// -- the first flush with the page in it -- while Suspense boundaries go on
|
|
13
|
+
// streaming into it. That is where the bar finishes: a bar that waited for
|
|
14
|
+
// the stream's end would sit at the top of a page the reader is already
|
|
15
|
+
// reading.
|
|
16
|
+
//
|
|
17
|
+
// A visit that finishes inside the delay shows nothing at all, so the
|
|
18
|
+
// hundredth fast click is as quiet as the first.
|
|
19
|
+
|
|
20
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
21
|
+
import { useOptionalRouter } from './link.js';
|
|
22
|
+
|
|
23
|
+
/** How long a visit runs before the bar appears, unless told otherwise. */
|
|
24
|
+
export const PROGRESS_DELAY_MS = 250;
|
|
25
|
+
|
|
26
|
+
/** How often the bar creeps forward while the server is working. */
|
|
27
|
+
export const TRICKLE_MS = 200;
|
|
28
|
+
|
|
29
|
+
/** How long a finished bar stays full before it goes, for the app to fade. */
|
|
30
|
+
export const FINISH_MS = 160;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{ visible: boolean, value: number, finishing: boolean }} ProgressState
|
|
34
|
+
*
|
|
35
|
+
* `visible` is whether to draw the bar; `value` how far along, from 0 to 1;
|
|
36
|
+
* `finishing` that the visit is done and the bar is at 1 on its way out.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @type {ProgressState} */
|
|
40
|
+
const IDLE = Object.freeze({ visible: false, value: 0, finishing: false });
|
|
41
|
+
|
|
42
|
+
/** @type {WeakMap<object, ProgressStore>} */
|
|
43
|
+
const stores = new WeakMap();
|
|
44
|
+
|
|
45
|
+
class ProgressStore {
|
|
46
|
+
/** @type {Set<() => void>} */
|
|
47
|
+
#listeners = new Set();
|
|
48
|
+
/** @type {ProgressState} */
|
|
49
|
+
#state = IDLE;
|
|
50
|
+
#delay;
|
|
51
|
+
/** Whether the visit in progress has passed its delay. */
|
|
52
|
+
#shown = false;
|
|
53
|
+
#value = 0;
|
|
54
|
+
#finishing = false;
|
|
55
|
+
/** How many hide() calls are waiting for their reveal(). */
|
|
56
|
+
#hidden = 0;
|
|
57
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
58
|
+
#timer = null;
|
|
59
|
+
/** An interrupted visit's finish, held until it is known whether a visit follows it. */
|
|
60
|
+
#interrupted = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {import('./router.js').Router} router
|
|
64
|
+
* @param {number} delay
|
|
65
|
+
*/
|
|
66
|
+
constructor(router, delay) {
|
|
67
|
+
this.#delay = delay;
|
|
68
|
+
|
|
69
|
+
router.on('start', (event) => {
|
|
70
|
+
const detail = /** @type {{ background?: boolean, showProgress?: boolean }} */ (event.detail);
|
|
71
|
+
if (detail.background || detail.showProgress === false) return;
|
|
72
|
+
|
|
73
|
+
// The visit that superseded the one the bar is for: it carries on as
|
|
74
|
+
// it was -- its delay still counting, or its value still creeping.
|
|
75
|
+
if (this.#interrupted) {
|
|
76
|
+
this.#interrupted = false;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.#begin();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
router.on('finish', (event) => {
|
|
83
|
+
const detail = /** @type {{ background?: boolean, interrupted?: boolean }} */ (event.detail);
|
|
84
|
+
if (detail.background) return;
|
|
85
|
+
|
|
86
|
+
// A visit superseded by the next finishes, interrupted, just before
|
|
87
|
+
// the next one starts. Finished at once, the bar filled and the next
|
|
88
|
+
// visit started it over from the beginning: back to 10% in the middle
|
|
89
|
+
// of what the user sees as one wait. So it is held for the rest of
|
|
90
|
+
// this task -- the next start comes in it, or none does, and then it
|
|
91
|
+
// finishes, as after cancelAll().
|
|
92
|
+
if (detail.interrupted) {
|
|
93
|
+
this.#interrupted = true;
|
|
94
|
+
void Promise.resolve().then(() => {
|
|
95
|
+
if (!this.#interrupted) return;
|
|
96
|
+
this.#interrupted = false;
|
|
97
|
+
this.#done();
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.#interrupted = false;
|
|
103
|
+
this.#done();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {() => void} listener */
|
|
108
|
+
subscribe = (listener) => {
|
|
109
|
+
this.#listeners.add(listener);
|
|
110
|
+
return () => this.#listeners.delete(listener);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
snapshot = () => this.#state;
|
|
114
|
+
|
|
115
|
+
/** Hides the bar until a matching reveal(); calls nest. */
|
|
116
|
+
hide = () => {
|
|
117
|
+
this.#hidden++;
|
|
118
|
+
this.#publish();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Undoes one hide(). */
|
|
122
|
+
reveal = () => {
|
|
123
|
+
this.#hidden = Math.max(0, this.#hidden - 1);
|
|
124
|
+
this.#publish();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
#begin() {
|
|
128
|
+
this.#clear();
|
|
129
|
+
|
|
130
|
+
// A visit that supersedes one already showing carries on from where the
|
|
131
|
+
// bar is, rather than blinking out and starting again; one that follows
|
|
132
|
+
// a bar on its way out starts it over.
|
|
133
|
+
if (this.#shown) {
|
|
134
|
+
if (this.#finishing) this.#value = 0.1;
|
|
135
|
+
this.#finishing = false;
|
|
136
|
+
this.#trickle();
|
|
137
|
+
this.#publish();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.#value = 0;
|
|
142
|
+
this.#timer = setTimeout(() => {
|
|
143
|
+
this.#shown = true;
|
|
144
|
+
this.#value = 0.1;
|
|
145
|
+
this.#publish();
|
|
146
|
+
this.#trickle();
|
|
147
|
+
}, this.#delay);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Creeps toward, never to, the end: nothing here knows how long is left. */
|
|
151
|
+
#trickle() {
|
|
152
|
+
this.#timer = setTimeout(() => {
|
|
153
|
+
this.#value = Math.min(0.9, this.#value + (0.9 - this.#value) * 0.15);
|
|
154
|
+
this.#publish();
|
|
155
|
+
this.#trickle();
|
|
156
|
+
}, TRICKLE_MS);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#done() {
|
|
160
|
+
this.#clear();
|
|
161
|
+
|
|
162
|
+
// Finished inside the delay: nothing was shown, and nothing is.
|
|
163
|
+
if (!this.#shown) {
|
|
164
|
+
this.#value = 0;
|
|
165
|
+
this.#publish();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.#value = 1;
|
|
170
|
+
this.#finishing = true;
|
|
171
|
+
this.#publish();
|
|
172
|
+
this.#timer = setTimeout(() => {
|
|
173
|
+
this.#timer = null;
|
|
174
|
+
this.#shown = false;
|
|
175
|
+
this.#finishing = false;
|
|
176
|
+
this.#value = 0;
|
|
177
|
+
this.#publish();
|
|
178
|
+
}, FINISH_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#clear() {
|
|
182
|
+
if (this.#timer !== null) clearTimeout(this.#timer);
|
|
183
|
+
this.#timer = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#publish() {
|
|
187
|
+
const visible = this.#shown && this.#hidden === 0;
|
|
188
|
+
const next = visible ? { visible, value: this.#value, finishing: this.#finishing } : IDLE;
|
|
189
|
+
const current = this.#state;
|
|
190
|
+
if (
|
|
191
|
+
current.visible === next.visible &&
|
|
192
|
+
current.value === next.value &&
|
|
193
|
+
current.finishing === next.finishing
|
|
194
|
+
) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.#state = next;
|
|
198
|
+
for (const listener of this.#listeners) listener();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The progress state for a router: one per router, made on first use, with
|
|
204
|
+
* the delay the first caller gives.
|
|
205
|
+
*
|
|
206
|
+
* @param {import('./router.js').Router} router
|
|
207
|
+
* @param {{ delay?: number }} [options]
|
|
208
|
+
*/
|
|
209
|
+
export function progressFor(router, options = {}) {
|
|
210
|
+
let store = stores.get(router);
|
|
211
|
+
if (store === undefined) {
|
|
212
|
+
store = new ProgressStore(router, options.delay ?? PROGRESS_DELAY_MS);
|
|
213
|
+
stores.set(router, store);
|
|
214
|
+
}
|
|
215
|
+
return store;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nothing = () => () => {};
|
|
219
|
+
const idle = () => IDLE;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The progress bar's state for the router above, and `hide()` / `reveal()`
|
|
223
|
+
* to keep it off screen while something else says the page is busy. Idle
|
|
224
|
+
* without a router, as in a server render.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ delay?: number }} [options] how long a visit runs before the
|
|
227
|
+
* bar shows; read once per router
|
|
228
|
+
* @returns {ProgressState & { hide: () => void, reveal: () => void }}
|
|
229
|
+
*/
|
|
230
|
+
export function useProgress(options = {}) {
|
|
231
|
+
const router = useOptionalRouter();
|
|
232
|
+
const store = router === null ? null : progressFor(router, options);
|
|
233
|
+
const state = useSyncExternalStore(store?.subscribe ?? nothing, store?.snapshot ?? idle, idle);
|
|
234
|
+
|
|
235
|
+
return useMemo(
|
|
236
|
+
() => ({
|
|
237
|
+
...state,
|
|
238
|
+
hide: store?.hide ?? (() => {}),
|
|
239
|
+
reveal: store?.reveal ?? (() => {}),
|
|
240
|
+
}),
|
|
241
|
+
[state, store],
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Where remembered state is kept: per tab, per history entry, per session. */
|
|
2
|
+
|
|
3
|
+
/** The sessionStorage key the map is kept under. */
|
|
4
|
+
export declare const REMEMBER_STORAGE_KEY: 'tinvar:remember';
|
|
5
|
+
/** How many history entries' values are kept; the oldest go first. */
|
|
6
|
+
export declare const MAX_REMEMBERED_ENTRIES: number;
|
|
7
|
+
/** Puts a value in a map by history entry as its newest, dropping the oldest past MAX_REMEMBERED_ENTRIES. */
|
|
8
|
+
export declare function keepNewest<V>(map: Map<string, V>, entry: string, value: V): void;
|
|
9
|
+
|
|
10
|
+
/** A page's history key, imported; null when it could not be. */
|
|
11
|
+
export type HistoryKey = Promise<CryptoKey | null>;
|
|
12
|
+
|
|
13
|
+
export interface RememberStore {
|
|
14
|
+
/**
|
|
15
|
+
* An entry's values kept in the clear, if the map is bound to this
|
|
16
|
+
* session; reading under another wipes it. A sealed entry reads as empty
|
|
17
|
+
* here: see open().
|
|
18
|
+
*/
|
|
19
|
+
read(entry: string, binding: string | null): Record<string, unknown>;
|
|
20
|
+
/** Whether an entry is kept sealed, under this session. */
|
|
21
|
+
isSealed(entry: string, binding: string | null): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* An entry's values, opened with the page's history key when they were
|
|
24
|
+
* sealed. One that does not open under it -- another session's key -- is
|
|
25
|
+
* dropped.
|
|
26
|
+
*/
|
|
27
|
+
open(entry: string, binding: string | null, key: HistoryKey | null): Promise<Record<string, unknown>>;
|
|
28
|
+
/**
|
|
29
|
+
* Replaces an entry's values under this session -- sealed with the key
|
|
30
|
+
* when one is given, and then never in the clear -- and settles once
|
|
31
|
+
* written. A map bound to another session is replaced whole.
|
|
32
|
+
*/
|
|
33
|
+
write(
|
|
34
|
+
entry: string,
|
|
35
|
+
binding: string | null,
|
|
36
|
+
values: Record<string, unknown>,
|
|
37
|
+
key?: HistoryKey | null,
|
|
38
|
+
): Promise<void>;
|
|
39
|
+
/** Forgets everything, and every seal still under way. */
|
|
40
|
+
wipe(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The tab's sessionStorage, or null where it cannot be used. */
|
|
44
|
+
export declare function tabStorage(win: Window | undefined): Storage | null;
|
|
45
|
+
|
|
46
|
+
/** Imports a history key as the server sends it: AES-GCM, not extractable. */
|
|
47
|
+
export declare function importHistoryKey(encoded: string, crypto?: Crypto): HistoryKey;
|
|
48
|
+
|
|
49
|
+
/** A store over a Storage, or over memory when there is none. */
|
|
50
|
+
export declare function createRememberStore(storage: Storage | null, crypto?: Crypto): RememberStore;
|