@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,248 @@
1
+ 'use client';
2
+
3
+ // useHttp: an action or a query, called from a component without moving the
4
+ // page.
5
+ //
6
+ // tinvar-spec.md, "Forms and mutations": "`useHttp` calls queries and
7
+ // actions without navigating (search-as-you-type). Keep three error
8
+ // channels: 422 -> `errors`, other statuses -> `onHttpException`,
9
+ // connectivity -> `onNetworkError`."
10
+ //
11
+ // The answer is the caller's to use: a query's result, an action's value.
12
+ // Nothing is mounted and no redirect is followed -- an action's redirect is
13
+ // handed back as `redirect` -- but what an action changed still reaches the
14
+ // router: prefetched pages are dropped, and its flash is announced, since
15
+ // neither moves the page -- and so is the call itself, its `actionStart`
16
+ // and `actionSettle`, as a form's is.
17
+ //
18
+ // Each call replaces the one in flight. Typing "sh", "sho", "shop" sends
19
+ // three searches; only the answer to "shop" lands, and the other two are
20
+ // cancelled rather than left to arrive late and overwrite it.
21
+
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { actionName, actionUrl } from './action.js';
24
+ import { firstMessages, useFormSession, useOptionalRouter } from './form.js';
25
+ import { revalidateDeferred } from './deferred-revalidation.js';
26
+ import {
27
+ announceAction,
28
+ announceFlash,
29
+ assignLocation,
30
+ endSession,
31
+ flushAfterAction,
32
+ } from './router-hooks.js';
33
+ import { call, resultOf } from './submit.js';
34
+ import { invalidatedTags } from './router.js';
35
+ import { send } from './transport.js';
36
+
37
+ export const QUERY_PREFIX = '/_query/';
38
+ const EMPTY = Object.freeze({});
39
+
40
+ /**
41
+ * A reference to a PHP #[Query], for useHttp: `query('products.search')`.
42
+ * Server components call queries over the render's socket instead.
43
+ *
44
+ * @param {string} name
45
+ */
46
+ export function query(name) {
47
+ if (typeof name !== 'string' || name === '') throw new TypeError('[tinvar] A query is its name.');
48
+ return Object.freeze({ query: name, url: QUERY_PREFIX + encodeURIComponent(name) });
49
+ }
50
+
51
+ /**
52
+ * What an action call's `actionSettle` says of its answer, as a form's
53
+ * submission says it: a redirect is a success, and a status other than 422
54
+ * is `http`, with the status.
55
+ *
56
+ * @param {import('./transport.js').Outcome} outcome
57
+ * @returns {{ type: string, status?: number }}
58
+ */
59
+ function settledAs(outcome) {
60
+ if (outcome.type === 'redirect' || outcome.type === 'location') return { type: 'success' };
61
+ if (outcome.type === 'http') return { type: 'http', status: outcome.status };
62
+ return { type: outcome.type };
63
+ }
64
+
65
+ /**
66
+ * @typedef {import('./action.js').ActionTarget | { readonly query: string }} HttpTarget
67
+ *
68
+ * @typedef {{
69
+ * onSuccess?: (value: unknown) => void,
70
+ * onError?: (errors: Record<string, string[]>) => void,
71
+ * onHttpException?: (failure: import('./submit.js').HttpFailure) => void,
72
+ * onNetworkError?: (error: unknown) => void,
73
+ * onCancel?: () => void,
74
+ * onFinish?: () => void,
75
+ * headers?: Record<string, string>,
76
+ * }} HttpCallbacks
77
+ */
78
+
79
+ /**
80
+ * @param {HttpTarget} target
81
+ * @param {HttpCallbacks} [defaults] callbacks every call runs, before its own
82
+ */
83
+ export function useHttp(target, defaults = {}) {
84
+ const router = useOptionalRouter();
85
+ const session = useFormSession();
86
+ const [result, setResult] = useState(/** @type {unknown} */ (undefined));
87
+ const [errors, setErrors] = useState(/** @type {Record<string, string>} */ (EMPTY));
88
+ const [processing, setProcessing] = useState(false);
89
+ const [wasSuccessful, setWasSuccessful] = useState(false);
90
+ const inFlight = useRef(/** @type {AbortController | null} */ (null));
91
+
92
+ const latest = useRef({ target, defaults, router, session });
93
+ latest.current = { target, defaults, router, session };
94
+
95
+ useEffect(() => () => inFlight.current?.abort(), []);
96
+
97
+ const cancel = useCallback(() => inFlight.current?.abort(), []);
98
+
99
+ const run = useCallback(
100
+ /**
101
+ * @param {Record<string, unknown>} [args]
102
+ * @param {HttpCallbacks} [own]
103
+ */
104
+ async (args = {}, own = {}) => {
105
+ const { target: to, defaults: base, router: r, session: s } = latest.current;
106
+ const each = (/** @type {keyof HttpCallbacks} */ name, /** @type {unknown[]} */ ...values) => {
107
+ call(/** @type {any} */ (base[name]), ...values);
108
+ call(/** @type {any} */ (own[name]), ...values);
109
+ };
110
+
111
+ inFlight.current?.abort();
112
+ const controller = new AbortController();
113
+ inFlight.current = controller;
114
+ const current = () => inFlight.current === controller;
115
+ setProcessing(true);
116
+
117
+ const isQuery = typeof to === 'object' && to !== null && 'query' in to;
118
+ // An action call is announced through the router, as a form's is: its
119
+ // `actionStart`, its upload's `progress`, and its `actionSettle`. A
120
+ // query changes nothing, and is not.
121
+ const action = isQuery ? null : actionName(/** @type {any} */ (to));
122
+ if (action !== null) announceAction(r, 'actionStart', { action });
123
+ const outcome = await send({
124
+ url: isQuery ? QUERY_PREFIX + encodeURIComponent(to.query) : actionUrl(/** @type {any} */ (to)),
125
+ // A query takes its arguments under `args`, as the endpoint reads
126
+ // them; an action takes its input as the body.
127
+ data: isQuery ? { args } : args,
128
+ headers: { ...base.headers, ...own.headers },
129
+ signal: controller.signal,
130
+ onUploadProgress:
131
+ action === null ? undefined : (progress) => announceAction(r, 'progress', { action, progress }),
132
+ });
133
+
134
+ try {
135
+ if (!current()) {
136
+ // Replaced by a later call, whose answer is the one that counts.
137
+ if (outcome.type === 'cancelled') each('onCancel');
138
+ return { type: 'cancelled' };
139
+ }
140
+ inFlight.current = null;
141
+ setProcessing(false);
142
+
143
+ switch (outcome.type) {
144
+ case 'success': {
145
+ let value;
146
+ if (isQuery) {
147
+ value = outcome.body?.value ?? null;
148
+ } else {
149
+ const answered = resultOf(outcome.body);
150
+ value = answered.value;
151
+ flushAfterAction(r, outcome.headers);
152
+ // The page stays, and so do its fragments: one that read a tag
153
+ // the action invalidated is fetched again, as after a form's.
154
+ if (invalidatedTags(outcome.headers).length > 0) revalidateDeferred();
155
+ if (Object.keys(answered.flash).length > 0) {
156
+ s?.setFlash(answered.flash);
157
+ announceFlash(r, answered.flash, actionName(/** @type {any} */ (to)));
158
+ }
159
+ }
160
+ setResult(value);
161
+ setErrors(EMPTY);
162
+ setWasSuccessful(true);
163
+ each('onSuccess', value);
164
+ return { type: 'success', value };
165
+ }
166
+
167
+ case 'redirect':
168
+ case 'location':
169
+ // Not followed: that would be navigating. The caller decides.
170
+ if (!isQuery) flushAfterAction(r, outcome.headers);
171
+ setErrors(EMPTY);
172
+ setWasSuccessful(true);
173
+ each('onSuccess', { redirect: outcome.location });
174
+ return { type: 'success', value: undefined, redirect: outcome.location };
175
+
176
+ case 'validation':
177
+ setErrors(firstMessages(outcome.errors));
178
+ setWasSuccessful(false);
179
+ each('onError', outcome.errors);
180
+ return { type: 'validation', errors: outcome.errors };
181
+
182
+ case 'http': {
183
+ const failure = {
184
+ status: outcome.status,
185
+ code: outcome.code,
186
+ reference: outcome.reference,
187
+ retryAfter: outcome.retryAfter,
188
+ body: outcome.body,
189
+ };
190
+ setWasSuccessful(false);
191
+ each('onHttpException', failure);
192
+ // The one status that moves the page: the session is over, and
193
+ // a call that must not navigate still may not leave the page up
194
+ // under it -- nor what it remembered in the tab's storage. See
195
+ // endSession().
196
+ if (outcome.status === 401) endSession(r, s?.hardNavigate ?? assignLocation);
197
+ return { type: 'http', failure };
198
+ }
199
+
200
+ case 'network':
201
+ setWasSuccessful(false);
202
+ each('onNetworkError', outcome.error);
203
+ return { type: 'network', error: outcome.error };
204
+
205
+ case 'expired': {
206
+ // Refused twice for its token. A form answers that by loading
207
+ // the page again; a call that must not move the page reports
208
+ // it, and the caller decides.
209
+ const failure = {
210
+ status: 419,
211
+ code: 'page_expired',
212
+ reference: null,
213
+ retryAfter: null,
214
+ body: null,
215
+ };
216
+ setWasSuccessful(false);
217
+ each('onHttpException', failure);
218
+ return { type: 'http', failure };
219
+ }
220
+
221
+ default:
222
+ each('onCancel');
223
+ return { type: 'cancelled' };
224
+ }
225
+ } finally {
226
+ // How the call came back, whatever this hook made of it: a newer
227
+ // call superseding it does not change what the action did.
228
+ if (action !== null) announceAction(r, 'actionSettle', { action, ...settledAs(outcome) });
229
+ each('onFinish');
230
+ }
231
+ },
232
+ [],
233
+ );
234
+
235
+ return useMemo(
236
+ () => ({
237
+ call: run,
238
+ result,
239
+ errors,
240
+ hasErrors: Object.keys(errors).length > 0,
241
+ processing,
242
+ wasSuccessful,
243
+ cancel,
244
+ clearErrors: () => setErrors(EMPTY),
245
+ }),
246
+ [run, result, errors, processing, wasSuccessful, cancel],
247
+ );
248
+ }
@@ -0,0 +1,41 @@
1
+ import type { InboxClient, InboxEntry, NotificationChannel, NotificationPreference } from './inbox.js';
2
+
3
+ export interface UseInboxOptions<T extends string = string> {
4
+ /** Entries a page holds: 1 to 50, 20 by default. */
5
+ limit?: number;
6
+ /** Called for each entry that arrives while mounted: a toast, a sound. */
7
+ onEntry?: (entry: InboxEntry<T>) => void;
8
+ /** Listen on the inbox's personal channel; true by default. */
9
+ live?: boolean;
10
+ }
11
+
12
+ export interface UseInbox<T extends string = string> {
13
+ entries: InboxEntry<T>[];
14
+ unread: number;
15
+ loading: boolean;
16
+ error: unknown;
17
+ hasMore: boolean;
18
+ loadMore(): Promise<void>;
19
+ markRead(ids: string[]): Promise<void>;
20
+ markAllRead(): Promise<void>;
21
+ refresh(): Promise<void>;
22
+ }
23
+
24
+ /** Reads the user's inbox while the component is mounted, live. */
25
+ export declare function useInbox<T extends string>(
26
+ client: InboxClient<T>,
27
+ options?: UseInboxOptions<T>,
28
+ ): UseInbox<T>;
29
+
30
+ export interface UseNotificationPreferences<T extends string = string> {
31
+ types: NotificationPreference<T>[];
32
+ loading: boolean;
33
+ error: unknown;
34
+ /** Saves one switch; resolves to whether the shop saved it. */
35
+ choose(type: T, channel: NotificationChannel, enabled: boolean): Promise<boolean>;
36
+ }
37
+
38
+ /** Which channels the user gets for each kind of notification, and a way to change one. */
39
+ export declare function useNotificationPreferences<T extends string>(
40
+ client: InboxClient<T>,
41
+ ): UseNotificationPreferences<T>;
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ // useInbox and useNotificationPreferences: a component reading the user's
4
+ // inbox while it is mounted (ADR 0155).
5
+ //
6
+ // const { entries, unread, markRead } = useInbox(inbox.customer);
7
+ //
8
+ // Reads the first page when it mounts, and listens on the inbox's personal
9
+ // channel: an entry that arrives goes on top and is counted, ids another
10
+ // tab marked read are shown read, and a gap in the stream -- messages the
11
+ // hub kept none of -- reads the first page again. Marking read updates the
12
+ // count at once and takes the server's answer as the truth.
13
+ //
14
+ // Hand it a client that stays the same between renders -- the `inbox` codegen
15
+ // exports, or one made outside the component: a new one each render reads
16
+ // the feed again each render.
17
+
18
+ import { useCallback, useEffect, useRef, useState } from 'react';
19
+
20
+ /**
21
+ * @typedef {import('./inbox.js').inboxClient} InboxClientFactory
22
+ * @typedef {ReturnType<InboxClientFactory>} InboxClient
23
+ * @typedef {{ id: string, type: string, title: string, body: string, url: string | null, createdAt: string, readAt: string | null }} Entry
24
+ */
25
+
26
+ /**
27
+ * @param {InboxClient} client
28
+ * @param {{ limit?: number, onEntry?: (entry: Entry) => void, live?: boolean }} [options]
29
+ */
30
+ export function useInbox(client, options = {}) {
31
+ const limit = options.limit ?? 20;
32
+ const live = options.live ?? true;
33
+ const [entries, setEntries] = useState(/** @type {Entry[]} */ ([]));
34
+ const [unread, setUnread] = useState(0);
35
+ const [next, setNext] = useState(/** @type {string | null} */ (null));
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState(/** @type {unknown} */ (null));
38
+ const onEntry = useRef(options.onEntry);
39
+ onEntry.current = options.onEntry;
40
+ const shown = useRef(entries);
41
+ shown.current = entries;
42
+
43
+ const refresh = useCallback(async () => {
44
+ setLoading(true);
45
+ try {
46
+ const page = await client.page({ limit });
47
+ setEntries(page.entries);
48
+ setUnread(page.unread);
49
+ setNext(page.next);
50
+ setError(null);
51
+ } catch (e) {
52
+ setError(e);
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ }, [client, limit]);
57
+
58
+ useEffect(() => {
59
+ void refresh();
60
+ if (!live) return undefined;
61
+
62
+ return client.listen(
63
+ (/** @type {any} */ update) => {
64
+ if (update === null || typeof update !== 'object') return;
65
+ if (typeof update.unread === 'number') setUnread(update.unread);
66
+ const entry = update.entry;
67
+ if (entry && typeof entry.id === 'string') {
68
+ setEntries((held) => (held.some((e) => e.id === entry.id) ? held : [entry, ...held]));
69
+ onEntry.current?.(entry);
70
+ }
71
+ const now = new Date().toISOString();
72
+ if (update.allRead === true) {
73
+ setEntries((held) => held.map((e) => (e.readAt === null ? { ...e, readAt: now } : e)));
74
+ } else if (Array.isArray(update.read) && update.read.length > 0) {
75
+ const read = new Set(update.read);
76
+ setEntries((held) =>
77
+ held.map((e) => (e.readAt === null && read.has(e.id) ? { ...e, readAt: now } : e)),
78
+ );
79
+ }
80
+ },
81
+ { onGap: () => void refresh() },
82
+ );
83
+ }, [client, live, refresh]);
84
+
85
+ const loadMore = useCallback(async () => {
86
+ if (next === null) return;
87
+ try {
88
+ const page = await client.page({ before: next, limit });
89
+ setEntries((held) => [...held, ...page.entries.filter((e) => !held.some((h) => h.id === e.id))]);
90
+ setUnread(page.unread);
91
+ setNext(page.next);
92
+ } catch (e) {
93
+ setError(e);
94
+ }
95
+ }, [client, next, limit]);
96
+
97
+ const markRead = useCallback(
98
+ async (/** @type {string[]} */ ids) => {
99
+ if (ids.length === 0) return;
100
+ const now = new Date().toISOString();
101
+ const marking = new Set(ids);
102
+ // Counted down at once by those shown unread, as markAllRead() counts
103
+ // to zero; the server's count replaces it when it answers.
104
+ const counted = shown.current.filter((e) => e.readAt === null && marking.has(e.id)).length;
105
+ if (counted > 0) setUnread((was) => Math.max(0, was - counted));
106
+ setEntries((held) =>
107
+ held.map((e) => (e.readAt === null && marking.has(e.id) ? { ...e, readAt: now } : e)),
108
+ );
109
+ try {
110
+ setUnread((await client.markRead(ids)).unread);
111
+ } catch (e) {
112
+ setError(e);
113
+ void refresh();
114
+ }
115
+ },
116
+ [client, refresh],
117
+ );
118
+
119
+ const markAllRead = useCallback(async () => {
120
+ const now = new Date().toISOString();
121
+ setEntries((held) => held.map((e) => (e.readAt === null ? { ...e, readAt: now } : e)));
122
+ setUnread(0);
123
+ try {
124
+ setUnread((await client.markAllRead()).unread);
125
+ } catch (e) {
126
+ setError(e);
127
+ void refresh();
128
+ }
129
+ }, [client, refresh]);
130
+
131
+ return {
132
+ entries,
133
+ unread,
134
+ loading,
135
+ error,
136
+ hasMore: next !== null,
137
+ loadMore,
138
+ markRead,
139
+ markAllRead,
140
+ refresh,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Which channels the user gets for each kind of notification, and a way to
146
+ * change one: written at once, the server's answer taken as the truth.
147
+ *
148
+ * @param {InboxClient} client
149
+ */
150
+ export function useNotificationPreferences(client) {
151
+ const [types, setTypes] = useState(/** @type {any[]} */ ([]));
152
+ const [loading, setLoading] = useState(true);
153
+ const [error, setError] = useState(/** @type {unknown} */ (null));
154
+
155
+ useEffect(() => {
156
+ let current = true;
157
+ client
158
+ .preferences()
159
+ .then((answer) => {
160
+ if (current) setTypes(answer.types);
161
+ })
162
+ .catch((e) => {
163
+ if (current) setError(e);
164
+ })
165
+ .finally(() => {
166
+ if (current) setLoading(false);
167
+ });
168
+ return () => {
169
+ current = false;
170
+ };
171
+ }, [client]);
172
+
173
+ const choose = useCallback(
174
+ async (/** @type {string} */ type, /** @type {string} */ channel, /** @type {boolean} */ enabled) => {
175
+ setTypes((held) =>
176
+ held.map((t) =>
177
+ t.type !== type
178
+ ? t
179
+ : {
180
+ ...t,
181
+ enabled: enabled
182
+ ? [...new Set([...t.enabled, channel])]
183
+ : t.enabled.filter((/** @type {string} */ c) => c !== channel),
184
+ },
185
+ ),
186
+ );
187
+ try {
188
+ setTypes((await client.choose([{ type, channel, enabled }])).types);
189
+ setError(null);
190
+ return true;
191
+ } catch (e) {
192
+ setError(e);
193
+ setTypes((await client.preferences().catch(() => ({ types: [] }))).types);
194
+ return false;
195
+ }
196
+ },
197
+ [client],
198
+ );
199
+
200
+ return { types, loading, error, choose };
201
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client component state kept on the history entry, so back and forward
3
+ * bring it back -- debounced, because browsers rate-limit replaceState.
4
+ * Bound to the session it was set under: read back only under that session,
5
+ * and not remembered at all without one.
6
+ */
7
+ export declare function useRemember<T>(
8
+ initial: T | (() => T),
9
+ key: string,
10
+ ): [T, (next: T | ((current: T) => T)) => void];
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ // useRemember: client component state that back and forward bring back.
4
+ //
5
+ // tinvar-spec.md, "Client router and components": "`useRemember(initial,
6
+ // key)` for client component state across back/forward, debounced because
7
+ // browsers rate-limit `replaceState`."
8
+ //
9
+ // The value lives in the router's record for the page the component belongs
10
+ // to, and the router writes it onto that page's history entry -- debounced
11
+ // with the scroll position, and at once before a visit leaves the entry.
12
+ // What is remembered is client-held data: each value is bound to the session
13
+ // it was set under, read back only under that session, and not remembered
14
+ // at all where there is none (see the router's rememberValue()).
15
+
16
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
17
+ import { useOptionalRouter } from './link.js';
18
+
19
+ const subscribeToNothing = () => () => {};
20
+ const onClient = () => true;
21
+ const onServer = () => false;
22
+
23
+ /**
24
+ * @internal Whether this render may show what was remembered: false on the
25
+ * server and while hydrating, true for every render after, and from the
26
+ * first for a component that mounts on the client.
27
+ *
28
+ * What is remembered lives in the tab's storage, which the server never
29
+ * sees. A document loaded again -- a reload, a back that missed the
30
+ * browser's cache -- is rendered by the server with each initial value, and
31
+ * a hydration that rendered the remembered one instead did not match it:
32
+ * React threw the server's HTML away and rendered the page again. React
33
+ * renders with the server's snapshot while hydrating, and again with this
34
+ * one once it has, so the remembered value shows straight after.
35
+ */
36
+ export function useHydrated() {
37
+ return useSyncExternalStore(subscribeToNothing, onClient, onServer);
38
+ }
39
+
40
+ /**
41
+ * @internal Whether a component kept across a navigation -- in a layout both
42
+ * pages share -- may carry what it holds from the page it was on to the
43
+ * next. Within one session only: what it shows was set under that session.
44
+ * And never from a page whose history is encrypted to one that is not --
45
+ * an `#[EncryptHistory(false)]` page in the same session: that page has no
46
+ * key, so what was typed under encryption would be written to the tab's
47
+ * storage in the clear (ADR 0019). useRemember() and useForm() alike start
48
+ * again from what they were given instead.
49
+ *
50
+ * @param {import('./router.js').RememberScope} from
51
+ * @param {import('./router.js').RememberScope} to
52
+ */
53
+ export function carries(from, to) {
54
+ const sealed = (/** @type {{ key?: unknown }} */ scope) => scope.key !== null && scope.key !== undefined;
55
+ return (
56
+ /** @type {{ session?: unknown }} */ (from).session ===
57
+ /** @type {{ session?: unknown }} */ (to).session &&
58
+ (!sealed(from) || sealed(to))
59
+ );
60
+ }
61
+
62
+ /**
63
+ * @template T
64
+ * @param {T | (() => T)} initial used when nothing was remembered
65
+ * @param {string} key unique among the useRemember() calls on one page
66
+ * @returns {[T, (next: T | ((current: T) => T)) => void]}
67
+ */
68
+ export function useRemember(initial, key) {
69
+ const router = useOptionalRouter();
70
+ const canRemember = typeof router?.rememberScope === 'function';
71
+
72
+ // The page this component mounted on: while a visit is mounting a page,
73
+ // that page, so a component on it never reads -- or writes -- the entry
74
+ // being left.
75
+ const scope = useRef(/** @type {import('./router.js').RememberScope | null} */ (null));
76
+ if (scope.current === null && canRemember) scope.current = router.rememberScope();
77
+
78
+ const initialRef = useRef(initial);
79
+ initialRef.current = initial;
80
+ const fresh = () => {
81
+ const start = initialRef.current;
82
+ return typeof start === 'function' ? /** @type {() => T} */ (start)() : start;
83
+ };
84
+
85
+ const [value, setValue] = useState(() => {
86
+ const kept = scope.current === null ? undefined : router.remembered(key, scope.current);
87
+ return kept !== undefined ? /** @type {T} */ (kept) : fresh();
88
+ });
89
+ // The initial value until hydration is done; see useHydrated().
90
+ const hydrated = useHydrated();
91
+
92
+ const latest = useRef(value);
93
+ latest.current = value;
94
+
95
+ useEffect(() => {
96
+ if (canRemember && scope.current !== null) router.remember(key, value, scope.current);
97
+ }, [canRemember, router, key, value]);
98
+
99
+ // A component kept across a navigation -- in a layout both pages share --
100
+ // belongs to the new page from then on. Back or forward to an entry that
101
+ // remembered a value brings that value back; a visit carries the current
102
+ // value onto the new entry -- within one session, and never from an
103
+ // encrypted page to one that is not (carries()). Otherwise nothing the
104
+ // component holds goes with it: it starts again from `initial`, and the
105
+ // old value is never remembered on the new page.
106
+ useEffect(() => {
107
+ if (!canRemember) return undefined;
108
+ return router.on('navigate', (event) => {
109
+ const next = router.rememberScope();
110
+ const previous = scope.current;
111
+ if (next === previous) return;
112
+ scope.current = next;
113
+
114
+ const kept =
115
+ /** @type {{ type?: string }} */ (event.detail).type === 'history'
116
+ ? router.remembered(key, next)
117
+ : undefined;
118
+
119
+ if (previous === null || !carries(previous, next)) {
120
+ setValue(kept !== undefined ? /** @type {T} */ (kept) : fresh());
121
+ return;
122
+ }
123
+
124
+ if (kept !== undefined) setValue(/** @type {T} */ (kept));
125
+ else router.remember(key, latest.current, next);
126
+ });
127
+ }, [canRemember, router, key]);
128
+
129
+ const set = useCallback((/** @type {T | ((current: T) => T)} */ next) => setValue(next), []);
130
+
131
+ return [hydrated ? value : fresh(), set];
132
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface WhenMountedProps {
4
+ /** Rendered on the server and while the page hydrates, in place of the children. */
5
+ fallback?: ReactNode;
6
+ /** Rendered in the browser alone: code that needs `window` as it renders. */
7
+ children?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * A client-only boundary: the fallback on the server and while hydrating,
12
+ * the children once mounted -- from the first render for a boundary that
13
+ * mounts after hydration.
14
+ */
15
+ export declare function WhenMounted(props: WhenMountedProps): ReactNode;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ // <WhenMounted>: a client-only boundary.
4
+ //
5
+ // tinvar-spec.md, "Client router and components": "`<WhenMounted
6
+ // fallback>` (Inertia v3.8.0+) as a client-only boundary for code that
7
+ // needs `window`." What is inside renders in the browser alone: a map, a
8
+ // chart reading the viewport, anything that reads `localStorage` as it
9
+ // renders. The server renders the fallback, and so does the browser while
10
+ // it hydrates -- the HTML the server sent must be what hydration finds, or
11
+ // React throws it away and renders the page again. The children follow at
12
+ // once.
13
+ //
14
+ // Read through useSyncExternalStore, as useHydrated() is, not a state set in
15
+ // an effect: a boundary that mounts on the client after hydration -- on the
16
+ // next page of a client visit, in a dialog opened later -- renders its
17
+ // children from its first render, with no flash of the fallback and no
18
+ // second render to get there.
19
+
20
+ import { useHydrated } from './use-remember.js';
21
+
22
+ /**
23
+ * @param {{ fallback?: unknown, children?: unknown }} props
24
+ */
25
+ export function WhenMounted({ fallback = null, children = null }) {
26
+ return useHydrated() ? children : fallback;
27
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.