@tinvar/react 0.0.0-stage → 0.4.0
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,41 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type MergeMode = 'append' | 'prepend' | 'page';
|
|
4
|
+
|
|
5
|
+
export interface Chunk {
|
|
6
|
+
order: number;
|
|
7
|
+
seq: number;
|
|
8
|
+
items: readonly unknown[];
|
|
9
|
+
keys: readonly (string | null)[];
|
|
10
|
+
/** A cursor page's: the cursor it was read at, null for the first (ADR 0120). */
|
|
11
|
+
cursor?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export declare function mergeChunks(
|
|
15
|
+
chunks: readonly Chunk[],
|
|
16
|
+
incoming: {
|
|
17
|
+
items: readonly unknown[];
|
|
18
|
+
keys: readonly (string | null)[];
|
|
19
|
+
order: number;
|
|
20
|
+
seq: number;
|
|
21
|
+
cursor?: string | null;
|
|
22
|
+
},
|
|
23
|
+
mode: MergeMode,
|
|
24
|
+
): Chunk[];
|
|
25
|
+
|
|
26
|
+
export declare function arrange(
|
|
27
|
+
chunks: readonly Chunk[],
|
|
28
|
+
reverse: boolean,
|
|
29
|
+
): { order: number; cursor?: string | null; entries: { key: string; node: unknown }[] }[];
|
|
30
|
+
|
|
31
|
+
/** Where a merged list's items go; rendered by <Merged>, not by hand. */
|
|
32
|
+
export declare function MergeSlot(props: {
|
|
33
|
+
id: string;
|
|
34
|
+
mode?: MergeMode;
|
|
35
|
+
order?: number;
|
|
36
|
+
/** A cursor page's cursor, which the <InfiniteScroll> around it places it by in place of `order`. */
|
|
37
|
+
cursor?: string | null;
|
|
38
|
+
reset?: boolean;
|
|
39
|
+
items?: readonly ReactNode[];
|
|
40
|
+
keys?: readonly (string | null)[];
|
|
41
|
+
}): ReactNode;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Where a merged list's items go: those every response so far has rendered,
|
|
4
|
+
// together (ADR 0020).
|
|
5
|
+
//
|
|
6
|
+
// The server renders a #[Merge] field or a Scroll page through <Merged>,
|
|
7
|
+
// which draws only the items this response carries and hands them here. A
|
|
8
|
+
// partial reload puts the page segment back at the position it had, so
|
|
9
|
+
// React keeps this component and its state, and the new items are added to
|
|
10
|
+
// those it holds: after them or before them for a #[Merge] field, and by
|
|
11
|
+
// page number for a Scroll page, which goes where its page belongs whatever
|
|
12
|
+
// order the pages arrive in. Each response's items are a chunk; a page
|
|
13
|
+
// fetched again replaces its own chunk.
|
|
14
|
+
//
|
|
15
|
+
// Replaced instead, with only the new items:
|
|
16
|
+
// - when the response says so -- X-Reset named the field;
|
|
17
|
+
// - on a whole page, even the same page again, which the page root counts
|
|
18
|
+
// (`generation`): a navigation, a document, an action's re-render.
|
|
19
|
+
//
|
|
20
|
+
// Items match on their keys -- the matchOn property, or failing one the
|
|
21
|
+
// React key the server rendered each with, unless that is its index (see
|
|
22
|
+
// <Merged>) -- so an item that arrives again replaces the one shown, where
|
|
23
|
+
// it stands, rather than appearing twice. For a #[Merge] field the old copy
|
|
24
|
+
// is let go as the new one takes its place, so what is held is bounded by
|
|
25
|
+
// what is shown however often the list is polled. Unkeyed items are never
|
|
26
|
+
// matched.
|
|
27
|
+
//
|
|
28
|
+
// A cursor page has no number to go by (ADR 0120). The <InfiniteScroll>
|
|
29
|
+
// around it places it by its cursors and says where -- `place()` -- and
|
|
30
|
+
// when the list starts again -- `epoch` -- since a page read at a cursor no
|
|
31
|
+
// page shown leads to cannot be placed among them at all.
|
|
32
|
+
|
|
33
|
+
import { createElement, Fragment, useContext, useState } from 'react';
|
|
34
|
+
import { InfiniteScrollContext } from './infinite-scroll.js';
|
|
35
|
+
import { SegmentContext } from './segment.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One response's items.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* order: number,
|
|
42
|
+
* seq: number,
|
|
43
|
+
* items: readonly unknown[],
|
|
44
|
+
* keys: readonly (string | null)[],
|
|
45
|
+
* cursor?: string | null,
|
|
46
|
+
* }} Chunk
|
|
47
|
+
*
|
|
48
|
+
* `order` is where the chunk sits: the page number for a Scroll, and for a
|
|
49
|
+
* #[Merge] field one past the last chunk (append) or one before the first
|
|
50
|
+
* (prepend). `seq` counts arrivals, so the latest copy of a matched item
|
|
51
|
+
* wins.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {'append' | 'prepend' | 'page'} Mode
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The chunks after one more arrives.
|
|
58
|
+
*
|
|
59
|
+
* @param {readonly Chunk[]} chunks in order
|
|
60
|
+
* @param {{ items: readonly unknown[], keys: readonly (string | null)[], order: number, seq: number, cursor?: string | null }} incoming
|
|
61
|
+
* @param {Mode} mode
|
|
62
|
+
* @returns {Chunk[]}
|
|
63
|
+
*/
|
|
64
|
+
export function mergeChunks(chunks, incoming, mode) {
|
|
65
|
+
if (chunks.length === 0) return [{ ...incoming, order: mode === 'page' ? incoming.order : 0 }];
|
|
66
|
+
|
|
67
|
+
if (mode === 'append' || mode === 'prepend') return mergeArrived(chunks, incoming, mode);
|
|
68
|
+
|
|
69
|
+
// A page again replaces itself, where it sits; a new one goes where its
|
|
70
|
+
// number puts it.
|
|
71
|
+
const others = chunks.filter((chunk) => chunk.order !== incoming.order);
|
|
72
|
+
return [...others, incoming].sort((a, b) => a.order - b.order);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A #[Merge] field's chunks after one more arrives: each matched item's
|
|
77
|
+
* latest copy put where the item already stands, only the new ones in the
|
|
78
|
+
* new chunk, and a chunk left with nothing dropped.
|
|
79
|
+
*
|
|
80
|
+
* Kept as arrived, a poll of the same twenty messages added twenty more
|
|
81
|
+
* held items every time -- three hundred polls, three hundred chunks and six
|
|
82
|
+
* thousand items, for twenty shown. Held once each, they are bounded by the
|
|
83
|
+
* items shown. Unkeyed items are never matched, so each arrival is new.
|
|
84
|
+
*
|
|
85
|
+
* @param {readonly Chunk[]} chunks in order
|
|
86
|
+
* @param {{ items: readonly unknown[], keys: readonly (string | null)[], order: number, seq: number }} incoming
|
|
87
|
+
* @param {'append' | 'prepend'} mode
|
|
88
|
+
* @returns {Chunk[]}
|
|
89
|
+
*/
|
|
90
|
+
function mergeArrived(chunks, incoming, mode) {
|
|
91
|
+
/** @type {Map<string, [number, number]>} where each key stands: chunk, item */
|
|
92
|
+
const standing = new Map();
|
|
93
|
+
chunks.forEach((chunk, c) =>
|
|
94
|
+
chunk.keys.forEach((key, i) => {
|
|
95
|
+
if (key !== null && key !== undefined) standing.set(key, [c, i]);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** @type {Chunk[]} */
|
|
100
|
+
const kept = [...chunks];
|
|
101
|
+
const copied = new Set();
|
|
102
|
+
const items = [];
|
|
103
|
+
const keys = [];
|
|
104
|
+
/** @type {Map<string, number>} */
|
|
105
|
+
const own = new Map();
|
|
106
|
+
incoming.items.forEach((node, i) => {
|
|
107
|
+
const key = incoming.keys[i] ?? null;
|
|
108
|
+
const stands = key === null ? undefined : standing.get(key);
|
|
109
|
+
if (stands !== undefined) {
|
|
110
|
+
const [c, at] = stands;
|
|
111
|
+
if (!copied.has(c)) {
|
|
112
|
+
const chunk = /** @type {Chunk} */ (kept[c]);
|
|
113
|
+
kept[c] = { ...chunk, items: [...chunk.items] };
|
|
114
|
+
copied.add(c);
|
|
115
|
+
}
|
|
116
|
+
/** @type {unknown[]} */ (/** @type {Chunk} */ (kept[c]).items)[at] = node;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const mine = key === null ? undefined : own.get(key);
|
|
120
|
+
if (mine !== undefined) {
|
|
121
|
+
items[mine] = node;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key !== null) own.set(key, items.length);
|
|
125
|
+
items.push(node);
|
|
126
|
+
keys.push(key);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const fresh = { ...incoming, items, keys };
|
|
130
|
+
const placed =
|
|
131
|
+
mode === 'append'
|
|
132
|
+
? [...kept, { ...fresh, order: /** @type {Chunk} */ (kept.at(-1)).order + 1 }]
|
|
133
|
+
: [{ ...fresh, order: /** @type {Chunk} */ (kept[0]).order - 1 }, ...kept];
|
|
134
|
+
return placed.filter((chunk) => chunk.items.length > 0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The items to render, in order, each once: a matched item at the place it
|
|
139
|
+
* first appears, with its latest copy.
|
|
140
|
+
*
|
|
141
|
+
* @param {readonly Chunk[]} chunks
|
|
142
|
+
* @param {boolean} reverse newest last, for a chat: the whole list turned round
|
|
143
|
+
* @returns {{ order: number, cursor?: string | null, entries: { key: string, node: unknown }[] }[]} by chunk, in display order
|
|
144
|
+
*/
|
|
145
|
+
export function arrange(chunks, reverse) {
|
|
146
|
+
/** @type {Map<string, { node: unknown, seq: number }>} */
|
|
147
|
+
const latest = new Map();
|
|
148
|
+
for (const chunk of chunks) {
|
|
149
|
+
chunk.items.forEach((node, i) => {
|
|
150
|
+
const key = chunk.keys[i];
|
|
151
|
+
if (key === null || key === undefined) return;
|
|
152
|
+
const seen = latest.get(key);
|
|
153
|
+
if (seen === undefined || seen.seq <= chunk.seq) latest.set(key, { node, seq: chunk.seq });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const shown = new Set();
|
|
158
|
+
const ordered = reverse ? [...chunks].reverse() : chunks;
|
|
159
|
+
return ordered.map((chunk) => {
|
|
160
|
+
const entries = [];
|
|
161
|
+
const indices = chunk.items.map((_, i) => i);
|
|
162
|
+
if (reverse) indices.reverse();
|
|
163
|
+
for (const i of indices) {
|
|
164
|
+
const key = chunk.keys[i];
|
|
165
|
+
if (key === null || key === undefined) {
|
|
166
|
+
// Positional, by the chunk's place, so it keeps its identity while
|
|
167
|
+
// its chunk stays.
|
|
168
|
+
entries.push({ key: `~${chunk.order}:${i}`, node: chunk.items[i] });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (shown.has(key)) continue;
|
|
172
|
+
shown.add(key);
|
|
173
|
+
entries.push({ key: `k:${key}`, node: /** @type {{ node: unknown }} */ (latest.get(key)).node });
|
|
174
|
+
}
|
|
175
|
+
return { order: chunk.order, cursor: chunk.cursor, entries };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {{
|
|
181
|
+
* id: string,
|
|
182
|
+
* mode?: Mode,
|
|
183
|
+
* order?: number,
|
|
184
|
+
* cursor?: string | null,
|
|
185
|
+
* reset?: boolean,
|
|
186
|
+
* items?: readonly unknown[],
|
|
187
|
+
* keys?: readonly (string | null)[],
|
|
188
|
+
* }} props `cursor`, for a cursor page: the cursor it was read at, which the
|
|
189
|
+
* <InfiniteScroll> around it places it by in place of `order`
|
|
190
|
+
*/
|
|
191
|
+
export function MergeSlot({ id, mode = 'append', order = 0, cursor, reset = false, items = [], keys = [] }) {
|
|
192
|
+
const generation = useContext(SegmentContext)?.generation ?? 0;
|
|
193
|
+
const scroll = useContext(InfiniteScrollContext);
|
|
194
|
+
const own = scroll !== null && scroll.id === id;
|
|
195
|
+
const reverse = own && scroll.reverse;
|
|
196
|
+
|
|
197
|
+
// Where a cursor page goes, and how often its list has started again:
|
|
198
|
+
// the <InfiniteScroll>'s, worked out as it rendered, just before this.
|
|
199
|
+
const paged = cursor !== undefined && own && typeof scroll.place === 'function';
|
|
200
|
+
const at = paged ? (scroll.place(cursor) ?? 0) : order;
|
|
201
|
+
const epoch = paged ? scroll.epoch : 0;
|
|
202
|
+
|
|
203
|
+
const [state, setState] = useState(() => ({
|
|
204
|
+
items,
|
|
205
|
+
generation,
|
|
206
|
+
epoch,
|
|
207
|
+
seq: 1,
|
|
208
|
+
chunks: mergeChunks([], { items, keys, order: at, seq: 1, cursor }, mode),
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
// A new response's items: merged during render, the way React derives
|
|
212
|
+
// state from a prop that changed, so the list never commits without them.
|
|
213
|
+
let current = state;
|
|
214
|
+
if (state.items !== items || state.generation !== generation || state.epoch !== epoch) {
|
|
215
|
+
const seq = state.seq + 1;
|
|
216
|
+
const incoming = { items, keys, order: at, seq, cursor };
|
|
217
|
+
const fresh = reset || state.generation !== generation || state.epoch !== epoch;
|
|
218
|
+
current = {
|
|
219
|
+
items,
|
|
220
|
+
generation,
|
|
221
|
+
epoch,
|
|
222
|
+
seq,
|
|
223
|
+
chunks: fresh ? mergeChunks([], incoming, mode) : mergeChunks(state.chunks, incoming, mode),
|
|
224
|
+
};
|
|
225
|
+
setState(current);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const chunk of arrange(current.chunks, reverse)) {
|
|
230
|
+
// Where each page starts, for <InfiniteScroll> to tell which page the
|
|
231
|
+
// reader is in. A <template>: allowed in a <tbody>, a <ul> or a <table>
|
|
232
|
+
// alike, rendered as nothing, and in nobody's accessibility tree. With
|
|
233
|
+
// the keys of the page's items, in order, so the item the reader was at
|
|
234
|
+
// is found again by its key after the pages are loaded afresh -- an
|
|
235
|
+
// order placed meanwhile moves every later one down a place.
|
|
236
|
+
if (mode === 'page') {
|
|
237
|
+
out.push(
|
|
238
|
+
createElement('template', {
|
|
239
|
+
key: `m:${chunk.order}`,
|
|
240
|
+
'data-tinvar-merge': id,
|
|
241
|
+
'data-page': String(chunk.order),
|
|
242
|
+
// The cursor a cursor page was read at, for the address bar; ''
|
|
243
|
+
// for the first page, which is read at none.
|
|
244
|
+
...(cursor === undefined ? {} : { 'data-cursor': chunk.cursor ?? '' }),
|
|
245
|
+
'data-keys': JSON.stringify(chunk.entries.map((entry) => entry.key)),
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
for (const entry of chunk.entries) out.push(createElement(Fragment, { key: entry.key }, entry.node));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return out;
|
|
253
|
+
}
|
package/src/merge.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** The key a #[Merge] field is sent under, holding its dot path. */
|
|
4
|
+
export declare const MERGE_KEY: '$merge';
|
|
5
|
+
/** The key a Scroll page is sent under, holding its dot path. */
|
|
6
|
+
export declare const SCROLL_KEY: '$scroll';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the encoder sends for a #[Merge] field: this response's items, and
|
|
10
|
+
* how the browser puts them with those it shows. Rendered through <Merged>.
|
|
11
|
+
* A field merged into a path -- `#[Merge(append: 'data')]` -- is its object
|
|
12
|
+
* with this under that property.
|
|
13
|
+
*/
|
|
14
|
+
export interface MergeField<T> {
|
|
15
|
+
/** The list's dot path, `'page.messages'` or `'page.feed.data'`: what X-Reset names it by. */
|
|
16
|
+
$merge: string;
|
|
17
|
+
mode: 'append' | 'prepend';
|
|
18
|
+
/** X-Reset named it: replace what is shown instead of adding to it. */
|
|
19
|
+
reset: boolean;
|
|
20
|
+
matchOn: string | null;
|
|
21
|
+
items: T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the encoder sends for a Scroll: one page's items, and where they sit
|
|
26
|
+
* among the pages. Rendered through <InfiniteScroll> and <Merged>.
|
|
27
|
+
*/
|
|
28
|
+
export interface ScrollField<T> {
|
|
29
|
+
/** The field's dot path, `'page.orders'`: what X-Reset and X-Only name it by. */
|
|
30
|
+
$scroll: string;
|
|
31
|
+
reset: boolean;
|
|
32
|
+
page: number;
|
|
33
|
+
previous: number | null;
|
|
34
|
+
next: number | null;
|
|
35
|
+
total: number | null;
|
|
36
|
+
perPage: number;
|
|
37
|
+
/** The query parameter the page number is read from and written to. */
|
|
38
|
+
pageName: string;
|
|
39
|
+
matchOn: string | null;
|
|
40
|
+
items: T[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the encoder sends for a CursorScroll: one page of a list read by
|
|
45
|
+
* cursor, and the cursors of the pages either side -- sealed and bound to
|
|
46
|
+
* the reader by the server, opaque here (ADR 0120). A page has no number:
|
|
47
|
+
* the browser places it by its cursors. Rendered as a ScrollField is.
|
|
48
|
+
*/
|
|
49
|
+
export interface CursorScrollField<T> {
|
|
50
|
+
/** The field's dot path, `'page.orders'`: what X-Reset and X-Only name it by. */
|
|
51
|
+
$scroll: string;
|
|
52
|
+
reset: boolean;
|
|
53
|
+
/** The cursor this page was read at; null for the first page. */
|
|
54
|
+
cursor: string | null;
|
|
55
|
+
previous: string | null;
|
|
56
|
+
next: string | null;
|
|
57
|
+
total: null;
|
|
58
|
+
perPage: number;
|
|
59
|
+
/** The query parameter the cursor is read from and written to. */
|
|
60
|
+
pageName: string;
|
|
61
|
+
matchOn: string | null;
|
|
62
|
+
items: T[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export declare function isScrollField(
|
|
66
|
+
field: unknown,
|
|
67
|
+
): field is ScrollField<unknown> | CursorScrollField<unknown>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Renders this response's items of a merged field -- each keyed, by its own
|
|
71
|
+
* React key or the field's matchOn -- for the browser to put with those it
|
|
72
|
+
* shows. A server component.
|
|
73
|
+
*/
|
|
74
|
+
export declare function Merged<T>(props: {
|
|
75
|
+
field: MergeField<T> | ScrollField<T> | CursorScrollField<T>;
|
|
76
|
+
children: (item: T, index: number) => ReactNode;
|
|
77
|
+
}): ReactNode;
|
|
78
|
+
|
|
79
|
+
export interface InfiniteScrollProps<T> {
|
|
80
|
+
data: ScrollField<T> | CursorScrollField<T>;
|
|
81
|
+
/**
|
|
82
|
+
* The items, as a function of one; or elements holding a <Merged> for the
|
|
83
|
+
* same field -- inside a table's body, say.
|
|
84
|
+
*/
|
|
85
|
+
children: ((item: T, index: number) => ReactNode) | ReactNode;
|
|
86
|
+
/** Pixels short of an edge at which the next page starts loading. 0 unless given. */
|
|
87
|
+
buffer?: number;
|
|
88
|
+
/** Load only when asked -- useInfiniteScroll().loadNext() -- never on scroll. */
|
|
89
|
+
manual?: boolean;
|
|
90
|
+
/** Load on scroll this many times, then only when asked. */
|
|
91
|
+
manualAfter?: number;
|
|
92
|
+
/**
|
|
93
|
+
* For a chat: the list turned round, newest at the bottom, starting
|
|
94
|
+
* scrolled there; scrolling up loads the next (older) page.
|
|
95
|
+
*/
|
|
96
|
+
reverse?: boolean;
|
|
97
|
+
/** Leave the address bar alone as the reader scrolls from page to page. */
|
|
98
|
+
preserveUrl?: boolean;
|
|
99
|
+
/** Shown before the items (after them, reversed): a client component using useInfiniteScroll(). */
|
|
100
|
+
previous?: ReactNode;
|
|
101
|
+
/** Shown after the items (before them, reversed). */
|
|
102
|
+
next?: ReactNode;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A Scroll that loads its other pages as the reader nears either end:
|
|
107
|
+
* background partial reloads of its segment, merged in. Keeps the address
|
|
108
|
+
* bar on the page being read, and works in any scroll container. A server
|
|
109
|
+
* component.
|
|
110
|
+
*/
|
|
111
|
+
export declare function InfiniteScroll<T>(props: InfiniteScrollProps<T>): ReactNode;
|
package/src/merge.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// <Merged> and <InfiniteScroll>: a merged list, as server components.
|
|
2
|
+
//
|
|
3
|
+
// ADR 0020. Under RSC the browser holds rendered items, not the list they
|
|
4
|
+
// were drawn from, so a #[Merge] field or a Scroll page is merged where its
|
|
5
|
+
// items are: <Merged> renders only the items this response carries, each
|
|
6
|
+
// keyed, and hands them to <MergeSlot>, the client component that holds
|
|
7
|
+
// what earlier responses rendered. <InfiniteScroll> wraps a Scroll with the
|
|
8
|
+
// client component that loads its other pages -- given the page's meta
|
|
9
|
+
// alone, so the items cross once, as rendered elements, and never again as
|
|
10
|
+
// props.
|
|
11
|
+
//
|
|
12
|
+
// No Node and no DOM: this runs in the RSC environment, where <MergeSlot>
|
|
13
|
+
// and <InfiniteScrollRoot> are client references.
|
|
14
|
+
|
|
15
|
+
import { createElement } from 'react';
|
|
16
|
+
import { InfiniteScrollRoot } from './infinite-scroll.js';
|
|
17
|
+
import { MergeSlot } from './merge-slot.js';
|
|
18
|
+
|
|
19
|
+
/** The key a #[Merge] field is sent under, holding its dot path. */
|
|
20
|
+
export const MERGE_KEY = '$merge';
|
|
21
|
+
|
|
22
|
+
/** The key a Scroll page is sent under, holding its dot path. */
|
|
23
|
+
export const SCROLL_KEY = '$scroll';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} field
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isScrollField(field) {
|
|
30
|
+
return (
|
|
31
|
+
field !== null &&
|
|
32
|
+
typeof field === 'object' &&
|
|
33
|
+
typeof (/** @type {any} */ (field)[SCROLL_KEY]) === 'string'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a Scroll field is a CursorScroll's: read by cursor, its page
|
|
39
|
+
* named by the cursor it was read at rather than by a number (ADR 0120).
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} field
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isCursorScroll(field) {
|
|
45
|
+
return isScrollField(field) && 'cursor' in /** @type {object} */ (field);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {unknown} field
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function isMergeField(field) {
|
|
53
|
+
return (
|
|
54
|
+
field !== null && typeof field === 'object' && typeof (/** @type {any} */ (field)[MERGE_KEY]) === 'string'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The keys a response's items are matched by, across every response: each
|
|
60
|
+
* item's matchOn property where the field names one, else the React key it
|
|
61
|
+
* was rendered with. Null for neither, and then it is never matched -- kept
|
|
62
|
+
* by its place on its own page or arrival, so never across them.
|
|
63
|
+
*
|
|
64
|
+
* matchOn first: it is what the field says its items are told apart by,
|
|
65
|
+
* where a React key is only how one component drew them. And a list keyed
|
|
66
|
+
* by its index -- every key the item's place, 0 to n-1 -- is keyed by
|
|
67
|
+
* nothing: page 2's 0 is not page 1's 0, and matched, page 2's first item
|
|
68
|
+
* replaced page 1's.
|
|
69
|
+
*
|
|
70
|
+
* @param {readonly unknown[]} nodes
|
|
71
|
+
* @param {readonly unknown[]} items
|
|
72
|
+
* @param {string | null} matchOn
|
|
73
|
+
* @returns {(string | null)[]}
|
|
74
|
+
*/
|
|
75
|
+
function keysOf(nodes, items, matchOn) {
|
|
76
|
+
const own = nodes.map((node) => {
|
|
77
|
+
const key = /** @type {{ key?: unknown } | null} */ (node)?.key;
|
|
78
|
+
return typeof key === 'string' || typeof key === 'number' ? String(key) : null;
|
|
79
|
+
});
|
|
80
|
+
const byIndex = own.length > 0 && own.every((key, index) => key === String(index));
|
|
81
|
+
|
|
82
|
+
return items.map((item, index) => {
|
|
83
|
+
if (matchOn !== null && item !== null && typeof item === 'object') {
|
|
84
|
+
const value = /** @type {Record<string, unknown>} */ (item)[matchOn];
|
|
85
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
86
|
+
}
|
|
87
|
+
return byIndex ? null : (own[index] ?? null);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Renders the items a merged field carries in this response, for the
|
|
93
|
+
* browser to put with those it shows.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ field: any, children: (item: any, index: number) => unknown }} props
|
|
96
|
+
*/
|
|
97
|
+
export function Merged({ field, children }) {
|
|
98
|
+
const scroll = isScrollField(field);
|
|
99
|
+
if (!scroll && !isMergeField(field)) {
|
|
100
|
+
throw new TypeError('[tinvar] <Merged> renders a #[Merge] field or a Scroll; this is neither.');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const items = /** @type {unknown[]} */ (field.items).map((item, index) => children(item, index));
|
|
104
|
+
const keys = keysOf(items, field.items, field.matchOn ?? null);
|
|
105
|
+
|
|
106
|
+
return createElement(MergeSlot, {
|
|
107
|
+
id: scroll ? field[SCROLL_KEY] : field[MERGE_KEY],
|
|
108
|
+
mode: scroll ? 'page' : field.mode,
|
|
109
|
+
// A cursor page has no number: <InfiniteScroll> places it by its cursor.
|
|
110
|
+
order: scroll && !isCursorScroll(field) ? field.page : 0,
|
|
111
|
+
...(isCursorScroll(field) ? { cursor: field.cursor } : {}),
|
|
112
|
+
reset: field.reset === true,
|
|
113
|
+
items,
|
|
114
|
+
keys,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A Scroll that loads its other pages as the reader nears either end of
|
|
120
|
+
* what is shown. Given a function as children it renders the items itself;
|
|
121
|
+
* given elements, they hold a <Merged> for the same field somewhere inside
|
|
122
|
+
* -- a table's body, say -- and the edges are around them all.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ data: any, children: unknown, [option: string]: unknown }} props
|
|
125
|
+
*/
|
|
126
|
+
export function InfiniteScroll({ data, children, ...options }) {
|
|
127
|
+
if (!isScrollField(data)) {
|
|
128
|
+
throw new TypeError('[tinvar] <InfiniteScroll> loads a Scroll field; this is not one.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const field = {
|
|
132
|
+
id: data[SCROLL_KEY],
|
|
133
|
+
page: isCursorScroll(data) ? 0 : data.page,
|
|
134
|
+
...(isCursorScroll(data) ? { cursor: data.cursor } : {}),
|
|
135
|
+
previous: data.previous,
|
|
136
|
+
next: data.next,
|
|
137
|
+
total: data.total,
|
|
138
|
+
perPage: data.perPage,
|
|
139
|
+
pageName: data.pageName,
|
|
140
|
+
reset: data.reset === true,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const content =
|
|
144
|
+
typeof children === 'function'
|
|
145
|
+
? createElement(Merged, { field: data, children: /** @type {any} */ (children) })
|
|
146
|
+
: children;
|
|
147
|
+
|
|
148
|
+
return createElement(InfiniteScrollRoot, { ...options, field }, content);
|
|
149
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Where a once field's rendered content goes; rendered by <Once>, not by hand. */
|
|
4
|
+
export declare function OnceSlot(props: {
|
|
5
|
+
id: string;
|
|
6
|
+
held?: boolean;
|
|
7
|
+
ttl?: number | null;
|
|
8
|
+
tags?: readonly string[];
|
|
9
|
+
/** False for content shown but not kept: a rescued failure. */
|
|
10
|
+
keep?: boolean;
|
|
11
|
+
/** A once-shared value's signed hold, kept with it to claim it by (ADR 0065). */
|
|
12
|
+
hold?: string | null;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
}): ReactNode;
|
package/src/once-slot.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Where a #[Once] field's rendered content goes, and where the browser keeps
|
|
4
|
+
// it from (ADR 0020).
|
|
5
|
+
//
|
|
6
|
+
// The server renders <Once> as this slot: with the content drawn from the
|
|
7
|
+
// field the first time, which the slot hands the router to keep; or -- once
|
|
8
|
+
// a request has listed the key in X-Have-Once -- empty and marked held,
|
|
9
|
+
// when the slot shows what was kept. Server components are rendered away by
|
|
10
|
+
// the time a payload arrives, so the browser has nothing to redraw a field
|
|
11
|
+
// from; what it has is what was drawn, and this is where it is put back.
|
|
12
|
+
//
|
|
13
|
+
// Kept for the page the slot belongs to, taken as it renders: a page being
|
|
14
|
+
// mounted files what it draws under the session its own response came with,
|
|
15
|
+
// never the one on screen it is about to replace.
|
|
16
|
+
|
|
17
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
18
|
+
import { useOptionalRouter } from './link.js';
|
|
19
|
+
|
|
20
|
+
/** Keys already reported missing, so a broken page says so once, not per render. */
|
|
21
|
+
const reported = new Set();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* id: string,
|
|
26
|
+
* held?: boolean,
|
|
27
|
+
* ttl?: number | null,
|
|
28
|
+
* tags?: readonly string[],
|
|
29
|
+
* keep?: boolean,
|
|
30
|
+
* hold?: string | null,
|
|
31
|
+
* children?: unknown,
|
|
32
|
+
* }} props
|
|
33
|
+
*
|
|
34
|
+
* `keep` false shows the content without keeping it: a rescued failure,
|
|
35
|
+
* which the next visit must ask for again rather than claim. `hold` is a
|
|
36
|
+
* once-shared value's signed hold, kept with it and claimed by (ADR 0065).
|
|
37
|
+
*/
|
|
38
|
+
export function OnceSlot({ id, held = false, ttl = null, tags = [], keep = true, hold = null, children }) {
|
|
39
|
+
const router = /** @type {import('./router.js').Router | null} */ (useOptionalRouter());
|
|
40
|
+
const scope = router?.rememberScope();
|
|
41
|
+
|
|
42
|
+
// What this slot last showed, by key. The browser keeps only so many
|
|
43
|
+
// fields, and drops the least recently used: one on screen, rendered
|
|
44
|
+
// again after its entry went, still shows what it showed.
|
|
45
|
+
const shown = useRef(/** @type {{ id: string, content: unknown } | null} */ (null));
|
|
46
|
+
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
// On commit, never during render: a render React throws away must not
|
|
49
|
+
// replace what the next response may be answered from.
|
|
50
|
+
if (!held && keep && router) router.holdOnce(id, children, ttl, tags, scope, hold);
|
|
51
|
+
}, [router, scope, held, keep, id, children, ttl, tags, hold]);
|
|
52
|
+
|
|
53
|
+
const kept = held ? router?.heldOnce(id, scope) : undefined;
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
if (!held) shown.current = { id, content: children ?? null };
|
|
56
|
+
else if (kept !== undefined) shown.current = { id, content: kept.content };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The server render that makes the HTML, and every response the browser
|
|
60
|
+
// did not claim the key for: the content is right here.
|
|
61
|
+
if (!held) return children ?? null;
|
|
62
|
+
|
|
63
|
+
if (kept !== undefined) return kept.content;
|
|
64
|
+
if (shown.current?.id === id) return shown.current.content;
|
|
65
|
+
|
|
66
|
+
// The server left it out because this client said it had it. Nothing
|
|
67
|
+
// keeps it any more -- a defect, since an entry is only dropped with the
|
|
68
|
+
// session, or to make room along with every prefetched page that claimed
|
|
69
|
+
// it -- so the slot is empty, and says why.
|
|
70
|
+
if (!reported.has(id)) {
|
|
71
|
+
reported.add(id);
|
|
72
|
+
console.error(`[tinvar:client] Nothing is kept for the once field '${id}' the server left out.`);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** How many rendered fields are kept, at most, before the least recently used go. */
|
|
2
|
+
export declare const MAX_KEPT: number;
|
|
3
|
+
|
|
4
|
+
/** What the browser keeps of #[Once] fields, by key, bound to a session. */
|
|
5
|
+
export interface OnceStore {
|
|
6
|
+
/** Most recently used first. */
|
|
7
|
+
claimable(binding: string | null): string[];
|
|
8
|
+
/** The once-shared values to claim in X-Have-Shared, each by its signed hold (ADR 0065). */
|
|
9
|
+
holds(binding: string | null): { key: string; hold: string }[];
|
|
10
|
+
read(key: string, binding: string | null): { content: unknown } | undefined;
|
|
11
|
+
/** Returns the keys dropped to make room. */
|
|
12
|
+
record(
|
|
13
|
+
key: string,
|
|
14
|
+
binding: string | null,
|
|
15
|
+
content: unknown,
|
|
16
|
+
ttl: number | null,
|
|
17
|
+
tags: readonly string[],
|
|
18
|
+
hold?: string | null,
|
|
19
|
+
): string[];
|
|
20
|
+
flushTags(tags: readonly string[]): number;
|
|
21
|
+
flush(keys?: readonly string[]): void;
|
|
22
|
+
keepOnly(binding: string | null): void;
|
|
23
|
+
clear(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export declare function createOnceStore(options?: { now?: () => number; max?: number }): OnceStore;
|