@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
package/src/use-http.js
ADDED
|
@@ -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>;
|
package/src/use-inbox.js
ADDED
|
@@ -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