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