@tinvar/react 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// What the browser keeps of #[Once] fields: the subtree rendered from each,
|
|
2
|
+
// by its once key (ADR 0020).
|
|
3
|
+
//
|
|
4
|
+
// The server sends a once field's rendered content once; after that a
|
|
5
|
+
// request lists the key in X-Have-Once and gets a marker in its place,
|
|
6
|
+
// which <OnceSlot> fills from here. Under RSC there is no field value on
|
|
7
|
+
// the client to keep -- server components read their data on the server --
|
|
8
|
+
// so what is kept is what was drawn from it: React elements, decoded from
|
|
9
|
+
// a payload. They cannot be serialized, so they live in memory only, and a
|
|
10
|
+
// document load starts with none.
|
|
11
|
+
//
|
|
12
|
+
// Client-held data, bound to the session it was rendered for: every entry
|
|
13
|
+
// carries the binding of the page it arrived with, is only ever read for a
|
|
14
|
+
// page of the same binding, and goes when that session does.
|
|
15
|
+
//
|
|
16
|
+
// Claiming and holding are kept apart. An entry stops being claimed -- its
|
|
17
|
+
// key left out of X-Have-Once -- when its time is up or a tag it carries is
|
|
18
|
+
// invalidated, so the next response sends it fresh. But its content stays
|
|
19
|
+
// until that fresh copy replaces it: a response already on its way, or a
|
|
20
|
+
// prefetched one, may have been answered while the key was still claimed,
|
|
21
|
+
// and has only this to fill its hole with. It shows what it would have
|
|
22
|
+
// shown had it arrived a moment sooner, and nothing is ever left empty.
|
|
23
|
+
//
|
|
24
|
+
// A key names content rendered for one subject -- a default key, the page
|
|
25
|
+
// it was rendered for -- so there is one entry for each page visited, not
|
|
26
|
+
// one for each field. Most recently used first: that is the order they are
|
|
27
|
+
// claimed in, so the request's list, which the server reads only so far,
|
|
28
|
+
// always starts with the page on screen's; and only so many are kept, the
|
|
29
|
+
// least recently used dropped first. The router hears which, and drops any
|
|
30
|
+
// prefetched page that claimed one, which nothing could fill any more.
|
|
31
|
+
//
|
|
32
|
+
// A once-shared value (ADR 0065) is kept here too, under its `$shared.key`,
|
|
33
|
+
// with the signed hold the server sent it with. It is claimed by that hold,
|
|
34
|
+
// in X-Have-Shared, never by its key in X-Have-Once: the server reads a
|
|
35
|
+
// shared key as held only on a hold it can verify for this session, user,
|
|
36
|
+
// tenant, panel and build.
|
|
37
|
+
|
|
38
|
+
/** How many rendered fields are kept, at most, before the least recently used go. */
|
|
39
|
+
export const MAX_KEPT = 256;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{
|
|
43
|
+
* binding: string,
|
|
44
|
+
* content: unknown,
|
|
45
|
+
* expiresAt: number | null,
|
|
46
|
+
* tags: readonly string[],
|
|
47
|
+
* claimable: boolean,
|
|
48
|
+
* hold: string | null,
|
|
49
|
+
* }} Held
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ now?: () => number, max?: number }} [options]
|
|
54
|
+
*/
|
|
55
|
+
export function createOnceStore({ now = () => Date.now(), max = MAX_KEPT } = {}) {
|
|
56
|
+
/**
|
|
57
|
+
* Least recently used first, as a Map iterates once each use moves its
|
|
58
|
+
* entry to the end.
|
|
59
|
+
*
|
|
60
|
+
* @type {Map<string, Held>}
|
|
61
|
+
*/
|
|
62
|
+
const entries = new Map();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every entry that may be claimed under a binding: kept under it, still
|
|
66
|
+
* within its time, and not invalidated since -- most recently used first.
|
|
67
|
+
*
|
|
68
|
+
* @param {string | null} binding
|
|
69
|
+
* @returns {[string, Held][]}
|
|
70
|
+
*/
|
|
71
|
+
function claimed(binding) {
|
|
72
|
+
if (binding === null) return [];
|
|
73
|
+
const at = now();
|
|
74
|
+
return [...entries].reverse().filter(([, held]) => {
|
|
75
|
+
if (held.binding !== binding || !held.claimable) return false;
|
|
76
|
+
return held.expiresAt === null || at < held.expiresAt;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
/**
|
|
82
|
+
* The keys to list in X-Have-Once for a request made under a binding:
|
|
83
|
+
* held under it, still within their time, and not invalidated since --
|
|
84
|
+
* most recently used first. Not a once-shared value's, which its hold
|
|
85
|
+
* claims.
|
|
86
|
+
*
|
|
87
|
+
* @param {string | null} binding
|
|
88
|
+
* @returns {string[]}
|
|
89
|
+
*/
|
|
90
|
+
claimable(binding) {
|
|
91
|
+
return claimed(binding)
|
|
92
|
+
.filter(([, held]) => held.hold === null)
|
|
93
|
+
.map(([key]) => key);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The once-shared values to claim in X-Have-Shared for a request made
|
|
98
|
+
* under a binding, as claimable() picks them: each key, with the hold
|
|
99
|
+
* the server sent it with.
|
|
100
|
+
*
|
|
101
|
+
* @param {string | null} binding
|
|
102
|
+
* @returns {{ key: string, hold: string }[]}
|
|
103
|
+
*/
|
|
104
|
+
holds(binding) {
|
|
105
|
+
/** @type {{ key: string, hold: string }[]} */
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const [key, held] of claimed(binding)) {
|
|
108
|
+
if (held.hold !== null) out.push({ key, hold: held.hold });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What was kept under a key for a page of this binding -- whether or not
|
|
115
|
+
* it may still be claimed -- or undefined. Reading it is using it.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} key
|
|
118
|
+
* @param {string | null} binding
|
|
119
|
+
* @returns {{ content: unknown } | undefined}
|
|
120
|
+
*/
|
|
121
|
+
read(key, binding) {
|
|
122
|
+
const held = entries.get(key);
|
|
123
|
+
if (held === undefined || binding === null || held.binding !== binding) return undefined;
|
|
124
|
+
entries.delete(key);
|
|
125
|
+
entries.set(key, held);
|
|
126
|
+
return { content: held.content };
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keeps what a page rendered from a once field, for its binding, and
|
|
131
|
+
* drops the least recently used beyond what is kept.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} key
|
|
134
|
+
* @param {string | null} binding
|
|
135
|
+
* @param {unknown} content
|
|
136
|
+
* @param {number | null} ttl seconds it may be claimed for; null for as long as the session lasts
|
|
137
|
+
* @param {readonly string[]} tags
|
|
138
|
+
* @param {string | null} [hold] a once-shared value's signed hold, which claims it
|
|
139
|
+
* @returns {string[]} the keys dropped to make room
|
|
140
|
+
*/
|
|
141
|
+
record(key, binding, content, ttl, tags, hold = null) {
|
|
142
|
+
if (binding === null) return [];
|
|
143
|
+
const seconds = typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0 ? ttl : null;
|
|
144
|
+
entries.delete(key);
|
|
145
|
+
entries.set(key, {
|
|
146
|
+
binding,
|
|
147
|
+
content,
|
|
148
|
+
expiresAt: seconds === null ? null : now() + seconds * 1000,
|
|
149
|
+
tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === 'string') : [],
|
|
150
|
+
claimable: true,
|
|
151
|
+
hold: typeof hold === 'string' && hold !== '' ? hold : null,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const dropped = [];
|
|
155
|
+
for (const oldest of entries.keys()) {
|
|
156
|
+
if (entries.size <= max) break;
|
|
157
|
+
entries.delete(oldest);
|
|
158
|
+
dropped.push(oldest);
|
|
159
|
+
}
|
|
160
|
+
return dropped;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Stops claiming every entry carrying one of these tags -- an action
|
|
165
|
+
* invalidated them -- so the next response sends each fresh.
|
|
166
|
+
*
|
|
167
|
+
* @param {readonly string[]} tags
|
|
168
|
+
* @returns {number} how many stopped
|
|
169
|
+
*/
|
|
170
|
+
flushTags(tags) {
|
|
171
|
+
if (tags.length === 0) return 0;
|
|
172
|
+
let flushed = 0;
|
|
173
|
+
for (const held of entries.values()) {
|
|
174
|
+
if (held.claimable && held.tags.some((tag) => tags.includes(tag))) {
|
|
175
|
+
held.claimable = false;
|
|
176
|
+
flushed++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return flushed;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stops claiming the given keys, or every key.
|
|
184
|
+
*
|
|
185
|
+
* @param {readonly string[]} [keys]
|
|
186
|
+
*/
|
|
187
|
+
flush(keys) {
|
|
188
|
+
for (const [key, held] of entries) {
|
|
189
|
+
if (keys === undefined || keys.includes(key)) held.claimable = false;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drops everything kept under any other binding: a page for another
|
|
195
|
+
* session has been mounted, and nothing rendered for the last one may
|
|
196
|
+
* be shown to this one.
|
|
197
|
+
*
|
|
198
|
+
* @param {string | null} binding
|
|
199
|
+
*/
|
|
200
|
+
keepOnly(binding) {
|
|
201
|
+
for (const [key, held] of entries) {
|
|
202
|
+
if (held.binding !== binding) entries.delete(key);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/** Drops everything: the session ended. */
|
|
207
|
+
clear() {
|
|
208
|
+
entries.clear();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/once.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** The key a once field is sent under, holding its once key. */
|
|
4
|
+
export declare const ONCE_KEY: '$once';
|
|
5
|
+
|
|
6
|
+
/** A #[Once] field the server sent whole: its value, and how long the browser may claim it. */
|
|
7
|
+
export interface OnceValue<T> {
|
|
8
|
+
$once: string;
|
|
9
|
+
/** Seconds the browser may list it in X-Have-Once; null for as long as the session lasts. */
|
|
10
|
+
ttl: number | null;
|
|
11
|
+
/** Cache tags whose invalidation makes the browser stop listing it. */
|
|
12
|
+
tags: string[];
|
|
13
|
+
value: T;
|
|
14
|
+
/**
|
|
15
|
+
* False when the value is a #[Rescue] failure, or a once-shared value no
|
|
16
|
+
* hold could be minted for: shown, never kept, so the next visit asks again.
|
|
17
|
+
*/
|
|
18
|
+
keep?: false;
|
|
19
|
+
/** A once-shared value's signed hold, which the browser claims it by (ADR 0065). */
|
|
20
|
+
hold?: string;
|
|
21
|
+
held?: undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A #[Once] field the browser said it holds: its key alone. */
|
|
25
|
+
export interface OnceHeld {
|
|
26
|
+
$once: string;
|
|
27
|
+
held: true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the encoder sends for a #[Once] field: its value, or -- in a response
|
|
32
|
+
* to a client that holds what it renders -- its key alone. Rendered through
|
|
33
|
+
* <Once>, which draws the value or puts back what the browser kept.
|
|
34
|
+
*/
|
|
35
|
+
export type OnceField<T> = OnceValue<T> | OnceHeld;
|
|
36
|
+
|
|
37
|
+
export declare function isHeld<T>(field: OnceField<T>): field is OnceHeld;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders a once field from its value, or -- when the browser holds what it
|
|
41
|
+
* renders -- leaves it to the browser, rendering nothing and calling none of
|
|
42
|
+
* the queries the children would. A server component.
|
|
43
|
+
*/
|
|
44
|
+
export declare function Once<T>(props: { field: OnceField<T>; children: (value: T) => ReactNode }): ReactNode;
|
package/src/once.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// <Once>: renders a #[Once] field, or -- when the browser already holds what
|
|
2
|
+
// it renders -- nothing, which the browser fills from what it kept.
|
|
3
|
+
//
|
|
4
|
+
// A server component. The field crosses as `{ $once: key, value, ttl, tags }`
|
|
5
|
+
// the first time and `{ $once: key, held: true }` once a request has listed
|
|
6
|
+
// the key in X-Have-Once (ADR 0020). Given the value, this renders the
|
|
7
|
+
// children from it inside an <OnceSlot>, which keeps the result in the
|
|
8
|
+
// browser; given the marker, it renders the slot alone, and skips the
|
|
9
|
+
// subtree and every query in it. No Node and no DOM: it runs in the RSC
|
|
10
|
+
// environment, where <OnceSlot> is a client reference.
|
|
11
|
+
|
|
12
|
+
import { createElement } from 'react';
|
|
13
|
+
import { OnceSlot } from './once-slot.js';
|
|
14
|
+
|
|
15
|
+
/** The key a once field is sent under, holding its once key. */
|
|
16
|
+
export const ONCE_KEY = '$once';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a once field was left out because the browser holds it.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} field
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isHeld(field) {
|
|
25
|
+
return isOnceField(field) && /** @type {{ held?: unknown }} */ (field).held === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} field
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isOnceField(field) {
|
|
33
|
+
return (
|
|
34
|
+
field !== null &&
|
|
35
|
+
typeof field === 'object' &&
|
|
36
|
+
typeof (/** @type {Record<string, unknown>} */ (field)[ONCE_KEY]) === 'string'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @template T
|
|
42
|
+
* @param {{ field: import('./once.d.ts').OnceField<T>, children: (value: T) => unknown }} props
|
|
43
|
+
*/
|
|
44
|
+
export function Once({ field, children }) {
|
|
45
|
+
if (!isOnceField(field)) {
|
|
46
|
+
throw new TypeError('[tinvar] <Once> renders a #[Once] field; this is not one.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (field.held === true) return createElement(OnceSlot, { id: field.$once, held: true });
|
|
50
|
+
|
|
51
|
+
// `keep: false` is a #[Rescue] failure: shown this once, never kept, so
|
|
52
|
+
// the next visit asks for the field again rather than claim the failure.
|
|
53
|
+
// A once-shared value comes with the hold it is claimed by (ADR 0065).
|
|
54
|
+
return createElement(
|
|
55
|
+
OnceSlot,
|
|
56
|
+
{
|
|
57
|
+
id: field.$once,
|
|
58
|
+
ttl: field.ttl ?? null,
|
|
59
|
+
tags: field.tags ?? [],
|
|
60
|
+
keep: field.keep !== false,
|
|
61
|
+
hold: typeof field.hold === 'string' ? field.hold : null,
|
|
62
|
+
},
|
|
63
|
+
children(/** @type {T} */ (field.value)),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The state an optimistic patch applies to: the server's values, handed to
|
|
5
|
+
* this boundary by the server component that renders it. A revalidated
|
|
6
|
+
* payload renders it again with new ones. See ADR 0016.
|
|
7
|
+
*/
|
|
8
|
+
export declare function OptimisticState<S extends Record<string, unknown>>(props: {
|
|
9
|
+
value: S;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}): ReactNode;
|
|
12
|
+
|
|
13
|
+
/** The nearest `<OptimisticState>`'s values, with the pending optimistic patches applied. */
|
|
14
|
+
export declare function useOptimisticState<S extends Record<string, unknown> = Record<string, unknown>>(): S;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Optimistic updates: client state that shows an action's effect before the
|
|
4
|
+
// server has re-rendered it.
|
|
5
|
+
//
|
|
6
|
+
// tinvar-spec.md, "Forms and mutations": "`moveUnit.optimistic(state =>
|
|
7
|
+
// patch)`. Snapshot only the keys the patch touches, roll them back on
|
|
8
|
+
// failure, keep validation errors on 422, and do not let a server response
|
|
9
|
+
// overwrite a key until the last optimistic request touching it has
|
|
10
|
+
// settled. The 'server response' is the revalidated RSC payload."
|
|
11
|
+
//
|
|
12
|
+
// Which state, in a page made of server components, is ADR 0016: the values
|
|
13
|
+
// an <OptimisticState> boundary was given by the server component that
|
|
14
|
+
// rendered it. Those are the server's values -- a revalidated payload
|
|
15
|
+
// renders the boundary again with new ones -- and the client components
|
|
16
|
+
// below read them, patched, through useOptimisticState(). A server
|
|
17
|
+
// component cannot show an optimistic value: it is not rendered here.
|
|
18
|
+
//
|
|
19
|
+
// Per key, while any request touching it is pending, the key is HELD: the
|
|
20
|
+
// value it had when the first such request began is frozen, and what shows
|
|
21
|
+
// is every pending patch replayed over it, in the order the requests began.
|
|
22
|
+
// So patches are functions of the state they are given, and must be pure.
|
|
23
|
+
//
|
|
24
|
+
// success the patch is folded into the frozen value, so a request still
|
|
25
|
+
// pending on the key keeps showing on top of it;
|
|
26
|
+
// failure the patch is dropped, and the rest replayed without it -- a
|
|
27
|
+
// 422's field errors stay on the form, which owns them;
|
|
28
|
+
// the last request on a key settles
|
|
29
|
+
// the key is released, and shows the server's value again --
|
|
30
|
+
// by now the revalidated one, which includes the change. An
|
|
31
|
+
// action that does not revalidate the page leaves the server's
|
|
32
|
+
// value as it was, and so does the page.
|
|
33
|
+
|
|
34
|
+
import { createContext, createElement, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
35
|
+
|
|
36
|
+
const OptimisticContext = createContext(/** @type {Store | null} */ (null));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {(state: Record<string, any>) => Record<string, unknown>} Patch
|
|
40
|
+
*
|
|
41
|
+
* @typedef {{
|
|
42
|
+
* state: Record<string, unknown>,
|
|
43
|
+
* begin: (patch: Patch) => number,
|
|
44
|
+
* settle: (ticket: number, succeeded: boolean) => void,
|
|
45
|
+
* }} Store
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The state an optimistic patch applies to.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ value: Record<string, unknown>, children?: unknown }} props
|
|
52
|
+
* `value`: the server's values, from the server component that renders
|
|
53
|
+
* this; a revalidated payload brings new ones
|
|
54
|
+
*/
|
|
55
|
+
export function OptimisticState({ value, children }) {
|
|
56
|
+
const base = useRef(value);
|
|
57
|
+
base.current = value;
|
|
58
|
+
|
|
59
|
+
/** The frozen value of each held key. */
|
|
60
|
+
const held = useRef(/** @type {Map<string, unknown>} */ (new Map()));
|
|
61
|
+
/** Requests still pending, in the order they began. */
|
|
62
|
+
const pending = useRef(/** @type {{ ticket: number, patch: Patch, keys: string[] }[]} */ ([]));
|
|
63
|
+
const tickets = useRef(0);
|
|
64
|
+
const [version, setVersion] = useState(0);
|
|
65
|
+
|
|
66
|
+
const view = useCallback(() => {
|
|
67
|
+
/** @type {Record<string, unknown>} */
|
|
68
|
+
const frozen = { ...base.current };
|
|
69
|
+
for (const [key, held_] of held.current) frozen[key] = held_;
|
|
70
|
+
return frozen;
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const displayed = useCallback(() => {
|
|
74
|
+
let state = view();
|
|
75
|
+
for (const { patch, keys } of pending.current) state = apply(state, patch, keys);
|
|
76
|
+
return state;
|
|
77
|
+
}, [view]);
|
|
78
|
+
|
|
79
|
+
const begin = useCallback(
|
|
80
|
+
(/** @type {Patch} */ patch) => {
|
|
81
|
+
const now = displayed();
|
|
82
|
+
const keys = Object.keys(patch(now) ?? {});
|
|
83
|
+
|
|
84
|
+
// Snapshot only the keys the patch touches, as they stand before any
|
|
85
|
+
// pending request touched them.
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
if (!held.current.has(key)) held.current.set(key, base.current[key]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ticket = ++tickets.current;
|
|
91
|
+
pending.current = [...pending.current, { ticket, patch, keys }];
|
|
92
|
+
setVersion((v) => v + 1);
|
|
93
|
+
return ticket;
|
|
94
|
+
},
|
|
95
|
+
[displayed],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const settle = useCallback(
|
|
99
|
+
(/** @type {number} */ ticket, /** @type {boolean} */ succeeded) => {
|
|
100
|
+
const request = pending.current.find((p) => p.ticket === ticket);
|
|
101
|
+
if (!request) return;
|
|
102
|
+
pending.current = pending.current.filter((p) => p !== request);
|
|
103
|
+
|
|
104
|
+
const stillTouched = new Set(pending.current.flatMap((p) => p.keys));
|
|
105
|
+
const folded = succeeded ? apply(view(), request.patch, request.keys) : null;
|
|
106
|
+
|
|
107
|
+
for (const key of request.keys) {
|
|
108
|
+
if (!stillTouched.has(key)) held.current.delete(key);
|
|
109
|
+
else if (folded !== null) held.current.set(key, folded[key]);
|
|
110
|
+
}
|
|
111
|
+
setVersion((v) => v + 1);
|
|
112
|
+
},
|
|
113
|
+
[view],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Recomputed when the server's values change and when a request begins
|
|
117
|
+
// or settles; `version` stands for the second.
|
|
118
|
+
const state = useMemo(() => displayed(), [value, version, displayed]);
|
|
119
|
+
const store = useMemo(() => ({ state, begin, settle }), [state, begin, settle]);
|
|
120
|
+
|
|
121
|
+
return createElement(OptimisticContext.Provider, { value: store }, children);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The state of the nearest <OptimisticState>: the server's values, with the
|
|
126
|
+
* pending optimistic patches applied.
|
|
127
|
+
*
|
|
128
|
+
* @returns {Record<string, unknown>}
|
|
129
|
+
*/
|
|
130
|
+
export function useOptimisticState() {
|
|
131
|
+
const store = useContext(OptimisticContext);
|
|
132
|
+
if (store === null) {
|
|
133
|
+
throw new Error('[tinvar:client] useOptimisticState() needs an <OptimisticState> above it.');
|
|
134
|
+
}
|
|
135
|
+
return store.state;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @internal The store a submission patches, or null when there is none. */
|
|
139
|
+
export function useOptimisticStore() {
|
|
140
|
+
return useContext(OptimisticContext);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One patch over a state, keeping only the keys it touched when it began: a
|
|
145
|
+
* patch that answers differently on replay cannot reach a key it was not
|
|
146
|
+
* snapshotted for.
|
|
147
|
+
*
|
|
148
|
+
* @param {Record<string, unknown>} state
|
|
149
|
+
* @param {Patch} patch
|
|
150
|
+
* @param {string[]} keys
|
|
151
|
+
*/
|
|
152
|
+
function apply(state, patch, keys) {
|
|
153
|
+
const out = patch(state) ?? {};
|
|
154
|
+
const next = { ...state };
|
|
155
|
+
for (const key of keys) {
|
|
156
|
+
if (Object.hasOwn(out, key)) next[key] = out[key];
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** What an #[Optional] field a request did not name is sent as. See ADR 0045. */
|
|
2
|
+
export interface Unrequested {
|
|
3
|
+
/** The dot path a partial reload names the field by, to ask for it: `page.history`. */
|
|
4
|
+
readonly $optional: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export declare const OPTIONAL_KEY: '$optional';
|
|
8
|
+
|
|
9
|
+
/** Whether an #[Optional] field was left for later; narrows `T | Unrequested` to either side. */
|
|
10
|
+
export declare function isUnrequested(value: unknown): value is Unrequested;
|
package/src/optional.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What an #[Optional] field nobody asked for arrives as.
|
|
2
|
+
//
|
|
3
|
+
// tinvar-spec.md, "Data categories": an #[Optional] field is "never on full
|
|
4
|
+
// renders, only when named in `X-Only`". The encoder sends one a request
|
|
5
|
+
// did not name as `{"$optional": "<dot path>"}`, and codegen types it
|
|
6
|
+
// `T | Unrequested` (ADR 0045), so a component says what it shows until the
|
|
7
|
+
// field is asked for. The path is the one to ask with: a partial reload
|
|
8
|
+
// naming it -- `router.reload({ only: [field.$optional] })` -- re-renders
|
|
9
|
+
// the field's segment with the field resolved.
|
|
10
|
+
//
|
|
11
|
+
// No React and no DOM: a page's server component reads its own data, so
|
|
12
|
+
// this has to import cleanly in either environment.
|
|
13
|
+
|
|
14
|
+
/** Mirrors Encoder::OPTIONAL_KEY. */
|
|
15
|
+
export const OPTIONAL_KEY = '$optional';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether an #[Optional] field was left for later rather than sent.
|
|
19
|
+
*
|
|
20
|
+
* Exactly one key, as isRescued() reads its marker: no PHP property is named
|
|
21
|
+
* with a `$`, so no Data object encodes to this, but a map field's own
|
|
22
|
+
* value could carry the key among others.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isUnrequested(value) {
|
|
28
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
29
|
+
const keys = Object.keys(value);
|
|
30
|
+
return keys.length === 1 && keys[0] === OPTIONAL_KEY && typeof value[OPTIONAL_KEY] === 'string';
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { PartialRoute } from './compose-route';
|
|
3
|
+
import type { Page } from './router';
|
|
4
|
+
|
|
5
|
+
export interface PageRootOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Turns a payload's RSC stream into what it holds, with the app's own
|
|
8
|
+
* client bundle: a page's elements, or for a partial reload the
|
|
9
|
+
* `{ segments }` composeRoute() rendered.
|
|
10
|
+
*/
|
|
11
|
+
decode: (
|
|
12
|
+
stream: ReadableStream<Uint8Array>,
|
|
13
|
+
) => ReactNode | PartialRoute | Promise<ReactNode | PartialRoute>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A payload decoded and ready to go on screen. */
|
|
17
|
+
export interface Prepared {
|
|
18
|
+
/**
|
|
19
|
+
* Shows the page: fresh, its page segment's components mounted again,
|
|
20
|
+
* when the visit does not preserve state. Resolves once React has
|
|
21
|
+
* committed it, or once the visit is aborted, in which case it never
|
|
22
|
+
* commits.
|
|
23
|
+
*/
|
|
24
|
+
commit: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PageRoot {
|
|
28
|
+
/**
|
|
29
|
+
* The root to hydrate the document with; shows the page the router last
|
|
30
|
+
* committed, and hands each <Segment> what partial reloads put in it.
|
|
31
|
+
*/
|
|
32
|
+
PageRoot: (props: { initial: ReactNode }) => ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* The `prepare` to give createRouter(): decodes the payload and hands back
|
|
35
|
+
* its commit, or null when the visit was aborted meanwhile. The router
|
|
36
|
+
* holds a view transition around the commit alone.
|
|
37
|
+
*/
|
|
38
|
+
prepare: (stream: ReadableStream<Uint8Array>, page: Page) => Promise<Prepared | null>;
|
|
39
|
+
/**
|
|
40
|
+
* prepare() and its commit in one step, for a router given a `mount`.
|
|
41
|
+
* Resolves once React has committed the page -- or merged a partial
|
|
42
|
+
* reload's segments into it -- or once its visit is aborted, in which case
|
|
43
|
+
* it never commits.
|
|
44
|
+
*/
|
|
45
|
+
mount: (stream: ReadableStream<Uint8Array>, page: Page) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* The `instant` to give createRouter() (ADR 0092): shows the loading
|
|
48
|
+
* boundary the layout keyed `under` gave -- true for the layout directly
|
|
49
|
+
* above the page -- in place of what it holds, and hands back the function
|
|
50
|
+
* that takes it away. Null when no layout on screen has one by that key.
|
|
51
|
+
*/
|
|
52
|
+
instant: (under: true | string) => (() => void) | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Creates an app's page root and the router mount that changes it. */
|
|
56
|
+
export declare function createPageRoot(options: PageRootOptions): PageRoot;
|