@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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
@@ -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 };
@@ -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;