@tinvar/react 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/inbox.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// A signed-in user's in-app inbox, by the #[Inbox] names codegen writes
|
|
2
|
+
// (ADR 0155).
|
|
3
|
+
//
|
|
4
|
+
// `inbox.customer.page()` POSTs to /_inbox/customer for the newest entries
|
|
5
|
+
// and the unread count; `markRead(ids)` and `markAllRead()` to
|
|
6
|
+
// /_inbox/customer/read; `preferences()` and `choose(...)` to
|
|
7
|
+
// /_inbox/customer/preferences. Each carries the session's CSRF token,
|
|
8
|
+
// read from the cookie as the request is sent, to a path on this origin
|
|
9
|
+
// and nowhere else. Whose inbox is never sent: the server reads the
|
|
10
|
+
// signed-in user, in the tenant the inbox's own declarations resolve.
|
|
11
|
+
//
|
|
12
|
+
// `listen(onUpdate)` hears the feed change as it happens -- an entry
|
|
13
|
+
// arriving, another tab marking some read -- on the inbox's personal
|
|
14
|
+
// channel, `inbox.{name}`, through the channel runtime: its token is bound
|
|
15
|
+
// to the session, the user and their auth epoch, and let in again on
|
|
16
|
+
// every trade. Without a hub nothing is heard, and a page polls nothing:
|
|
17
|
+
// the next page() is the truth.
|
|
18
|
+
//
|
|
19
|
+
// What the server answers is checked before anything draws it (review of
|
|
20
|
+
// 2026-11-03): a page, a count, the kinds of notification and every update
|
|
21
|
+
// the channel brings are held to their shapes, and an answer that is not
|
|
22
|
+
// one is an InboxError, an update that is not one unheard. An entry's link
|
|
23
|
+
// is kept only where it is a path on this site (sitePath()), so neither the
|
|
24
|
+
// bell nor an app's onOpen is handed somewhere else to go.
|
|
25
|
+
//
|
|
26
|
+
// No React: the hook is in use-inbox.js, the component in inbox-view.js.
|
|
27
|
+
|
|
28
|
+
import { CSRF_HEADER, csrfToken } from './csrf.js';
|
|
29
|
+
import { listen as listenChannel } from './channel.js';
|
|
30
|
+
|
|
31
|
+
export const INBOX_PATH = '/_inbox/';
|
|
32
|
+
|
|
33
|
+
/** An inbox name, as #[Inbox] declares them. */
|
|
34
|
+
const NAME_SHAPE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* $url where it is a path on this site, else null: one `/`, not `//` or
|
|
38
|
+
* `/\\` -- which a browser reads as another host -- and no control
|
|
39
|
+
* character, which a browser strips first (`/\t/host` is `//host`).
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} url
|
|
42
|
+
* @returns {string | null}
|
|
43
|
+
*/
|
|
44
|
+
export function sitePath(url) {
|
|
45
|
+
if (typeof url !== 'string' || !url.startsWith('/') || url.length > 2048) return null;
|
|
46
|
+
if (url[1] === '/' || url.includes('\\') || /[\u0000-\u001f\u007f]/.test(url)) return null;
|
|
47
|
+
return url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} v */
|
|
51
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
52
|
+
/** @param {unknown} v */
|
|
53
|
+
const isCount = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0;
|
|
54
|
+
/** @param {unknown} v */
|
|
55
|
+
const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An entry as the server writes one, its link kept only on this site; null
|
|
59
|
+
* for anything else.
|
|
60
|
+
*
|
|
61
|
+
* @param {unknown} value
|
|
62
|
+
*/
|
|
63
|
+
export function readEntry(value) {
|
|
64
|
+
if (!isMap(value)) return null;
|
|
65
|
+
const e = /** @type {Record<string, unknown>} */ (value);
|
|
66
|
+
if (
|
|
67
|
+
typeof e.id !== 'string' ||
|
|
68
|
+
e.id === '' ||
|
|
69
|
+
typeof e.type !== 'string' ||
|
|
70
|
+
typeof e.title !== 'string' ||
|
|
71
|
+
typeof e.body !== 'string' ||
|
|
72
|
+
(e.url !== null && typeof e.url !== 'string') ||
|
|
73
|
+
typeof e.createdAt !== 'string' ||
|
|
74
|
+
(e.readAt !== null && typeof e.readAt !== 'string')
|
|
75
|
+
) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id: e.id,
|
|
80
|
+
type: e.type,
|
|
81
|
+
title: e.title,
|
|
82
|
+
body: e.body,
|
|
83
|
+
url: sitePath(e.url),
|
|
84
|
+
createdAt: e.createdAt,
|
|
85
|
+
readAt: e.readAt,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {unknown} value */
|
|
90
|
+
function readPage(value) {
|
|
91
|
+
if (!isMap(value)) return null;
|
|
92
|
+
const p = /** @type {Record<string, unknown>} */ (value);
|
|
93
|
+
if (!Array.isArray(p.entries) || !isCount(p.unread) || (p.next !== null && typeof p.next !== 'string')) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const entries = p.entries.map(readEntry);
|
|
97
|
+
if (entries.some((e) => e === null)) return null;
|
|
98
|
+
return {
|
|
99
|
+
entries: /** @type {NonNullable<ReturnType<typeof readEntry>>[]} */ (entries),
|
|
100
|
+
unread: p.unread,
|
|
101
|
+
next: p.next,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {unknown} value */
|
|
106
|
+
function readCount(value) {
|
|
107
|
+
if (!isMap(value)) return null;
|
|
108
|
+
const c = /** @type {Record<string, unknown>} */ (value);
|
|
109
|
+
return isCount(c.unread) ? { unread: c.unread } : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {unknown} value */
|
|
113
|
+
function readPreferences(value) {
|
|
114
|
+
if (!isMap(value)) return null;
|
|
115
|
+
const p = /** @type {Record<string, unknown>} */ (value);
|
|
116
|
+
if (!Array.isArray(p.types)) return null;
|
|
117
|
+
const types = p.types.map((/** @type {unknown} */ t) => {
|
|
118
|
+
if (!isMap(t)) return null;
|
|
119
|
+
const k = /** @type {Record<string, unknown>} */ (t);
|
|
120
|
+
return typeof k.type === 'string' &&
|
|
121
|
+
typeof k.label === 'string' &&
|
|
122
|
+
typeof k.description === 'string' &&
|
|
123
|
+
isStrings(k.channels) &&
|
|
124
|
+
isStrings(k.enabled)
|
|
125
|
+
? { type: k.type, label: k.label, description: k.description, channels: k.channels, enabled: k.enabled }
|
|
126
|
+
: null;
|
|
127
|
+
});
|
|
128
|
+
return types.some((t) => t === null) ? null : { types };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An update as the inbox's channel says one; null for anything else.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} value
|
|
135
|
+
*/
|
|
136
|
+
export function readUpdate(value) {
|
|
137
|
+
if (!isMap(value)) return null;
|
|
138
|
+
const u = /** @type {Record<string, unknown>} */ (value);
|
|
139
|
+
const read = u.read ?? [];
|
|
140
|
+
if (!isCount(u.unread) || !isStrings(read) || (u.allRead !== undefined && typeof u.allRead !== 'boolean')) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const entry = u.entry === null || u.entry === undefined ? null : readEntry(u.entry);
|
|
144
|
+
if (entry === null && u.entry !== null && u.entry !== undefined) return null;
|
|
145
|
+
return { unread: u.unread, entry, read, allRead: u.allRead === true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A call the server refused, or that never got an answer (status 0). */
|
|
149
|
+
export class InboxError extends Error {
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} name
|
|
152
|
+
* @param {number} status
|
|
153
|
+
* @param {unknown} [body]
|
|
154
|
+
*/
|
|
155
|
+
constructor(name, status, body) {
|
|
156
|
+
const error =
|
|
157
|
+
body !== null &&
|
|
158
|
+
typeof body === 'object' &&
|
|
159
|
+
'error' in body &&
|
|
160
|
+
body.error !== null &&
|
|
161
|
+
typeof body.error === 'object'
|
|
162
|
+
? /** @type {{ message?: unknown }} */ (body.error)
|
|
163
|
+
: {};
|
|
164
|
+
super(
|
|
165
|
+
typeof error.message === 'string'
|
|
166
|
+
? error.message
|
|
167
|
+
: `[tinvar] The inbox '${name}' did not answer (${status}).`,
|
|
168
|
+
);
|
|
169
|
+
this.name = 'InboxError';
|
|
170
|
+
this.inbox = name;
|
|
171
|
+
this.status = status;
|
|
172
|
+
this.body = body;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The client for one inbox.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @param {{ fetch?: typeof fetch, document?: { cookie?: string } | null, listen?: typeof listenChannel }} [options]
|
|
181
|
+
*/
|
|
182
|
+
export function inboxClient(name, options = {}) {
|
|
183
|
+
if (!NAME_SHAPE.test(name)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`[tinvar] '${name}' is not an inbox name: a lowercase identifier, as #[Inbox] declares it.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const channel = `inbox.${name}`;
|
|
189
|
+
const listenImpl = options.listen ?? listenChannel;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {string} path
|
|
193
|
+
* @param {unknown} body
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* @template T
|
|
197
|
+
* @param {string} path
|
|
198
|
+
* @param {unknown} body
|
|
199
|
+
* @param {(value: unknown) => T | null} read
|
|
200
|
+
* @param {string} what
|
|
201
|
+
* @returns {Promise<T>}
|
|
202
|
+
*/
|
|
203
|
+
const call = async (path, body, read, what) => {
|
|
204
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
205
|
+
if (!fetchImpl) throw new InboxError(name, 0);
|
|
206
|
+
const token = csrfToken(options.document ?? globalThis.document);
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
response = await fetchImpl(INBOX_PATH + name + path, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'content-type': 'application/json',
|
|
213
|
+
accept: 'application/json',
|
|
214
|
+
...(token === null ? {} : { [CSRF_HEADER]: token }),
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify(body),
|
|
217
|
+
credentials: 'same-origin',
|
|
218
|
+
mode: 'same-origin',
|
|
219
|
+
redirect: 'error',
|
|
220
|
+
cache: 'no-store',
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
throw new InboxError(name, 0);
|
|
224
|
+
}
|
|
225
|
+
let parsed = null;
|
|
226
|
+
try {
|
|
227
|
+
parsed = await response.json();
|
|
228
|
+
} catch {
|
|
229
|
+
parsed = null;
|
|
230
|
+
}
|
|
231
|
+
if (!response.ok) throw new InboxError(name, response.status, parsed);
|
|
232
|
+
const held = read(parsed);
|
|
233
|
+
if (held === null) {
|
|
234
|
+
throw new InboxError(name, response.status, {
|
|
235
|
+
error: { message: `[tinvar] The inbox '${name}' answered with something that is not ${what}.` },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return held;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
channel,
|
|
244
|
+
/** @param {{ before?: string | null, limit?: number }} [page] */
|
|
245
|
+
page: (page = {}) =>
|
|
246
|
+
call(
|
|
247
|
+
'',
|
|
248
|
+
{
|
|
249
|
+
...(page.before ? { before: page.before } : {}),
|
|
250
|
+
...(page.limit === undefined ? {} : { limit: page.limit }),
|
|
251
|
+
},
|
|
252
|
+
readPage,
|
|
253
|
+
'a page of entries',
|
|
254
|
+
),
|
|
255
|
+
/** @param {string[]} ids */
|
|
256
|
+
markRead: (ids) => call('/read', { ids }, readCount, 'an unread count'),
|
|
257
|
+
markAllRead: () => call('/read', { all: true }, readCount, 'an unread count'),
|
|
258
|
+
preferences: () => call('/preferences', {}, readPreferences, 'the kinds of notification'),
|
|
259
|
+
/** @param {{ type: string, channel: string, enabled: boolean }[]} choices */
|
|
260
|
+
choose: (choices) =>
|
|
261
|
+
call('/preferences', { choose: choices }, readPreferences, 'the kinds of notification'),
|
|
262
|
+
/**
|
|
263
|
+
* @param {(update: unknown) => void} onUpdate
|
|
264
|
+
* @param {{ onError?: (error: unknown) => void, onGap?: () => void }} [listenOptions]
|
|
265
|
+
*/
|
|
266
|
+
listen: (onUpdate, listenOptions = {}) =>
|
|
267
|
+
listenImpl(
|
|
268
|
+
channel,
|
|
269
|
+
{},
|
|
270
|
+
(/** @type {unknown} */ raw) => {
|
|
271
|
+
const update = readUpdate(raw);
|
|
272
|
+
if (update !== null) onUpdate(update);
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
276
|
+
...listenOptions,
|
|
277
|
+
},
|
|
278
|
+
),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The typed `inbox` codegen exports: a client for each #[Inbox], by name.
|
|
284
|
+
*
|
|
285
|
+
* @param {string[]} names
|
|
286
|
+
* @param {{ fetch?: typeof fetch, document?: { cookie?: string } | null }} [options]
|
|
287
|
+
*/
|
|
288
|
+
export function inboxes(names, options = {}) {
|
|
289
|
+
/** @type {Record<string, ReturnType<typeof inboxClient>>} */
|
|
290
|
+
const clients = {};
|
|
291
|
+
for (const name of names) clients[name] = inboxClient(name, options);
|
|
292
|
+
return clients;
|
|
293
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The root as a program that typechecks server and client code together
|
|
2
|
+
// sees it: both entries' exports, for whichever kind of file imports them.
|
|
3
|
+
// See index.js for why there are three.
|
|
4
|
+
//
|
|
5
|
+
// TypeScript applies one set of conditions to a whole program, so a project
|
|
6
|
+
// whose one tsconfig covers its server components and its client components
|
|
7
|
+
// -- the shop's -- cannot have the root resolve as each file's environment
|
|
8
|
+
// will. It gets this: what a server component imports from the root, and
|
|
9
|
+
// what a client component does, each typed as it is. The bundler still
|
|
10
|
+
// holds the line between them: a client file importing a server-only name
|
|
11
|
+
// from the root fails the client build, which resolves the root to
|
|
12
|
+
// index.js, where the name does not exist.
|
|
13
|
+
|
|
14
|
+
export * from './client';
|
|
15
|
+
export * from './server';
|
|
16
|
+
export { Deferred, type DeferredProps } from './deferred';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `query` is two functions: a server component's, which calls a PHP query
|
|
20
|
+
* over the render's socket (`@tinvar/react/server`), and the client's, which
|
|
21
|
+
* names one for useHttp (`@tinvar/react/form`). The root is each one under
|
|
22
|
+
* its own condition, but a program checking both kinds of file together
|
|
23
|
+
* cannot tell which a file means -- typed as either, the other's callers
|
|
24
|
+
* would check against the wrong function. So it is neither here: import it
|
|
25
|
+
* from the subpath that says which.
|
|
26
|
+
*/
|
|
27
|
+
export interface AmbiguousQuery {
|
|
28
|
+
readonly 'import query() from @tinvar/react/server or @tinvar/react/form': never;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export declare const query: AmbiguousQuery;
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The root as the browser and the SSR environment resolve it, and as a
|
|
2
|
+
// program typechecking client code alone sees it, under `browser`. The RSC
|
|
3
|
+
// environment's, under `react-server`, is index.server.d.ts; a program
|
|
4
|
+
// checking both kinds of code together sees index.combined.d.ts.
|
|
5
|
+
export * from './client';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @tinvar/react -- the package root, resolved by environment.
|
|
2
|
+
//
|
|
3
|
+
// The root used to re-export the render-context helpers, which import
|
|
4
|
+
// node:async_hooks, beside the fragment client and the triggers, which want
|
|
5
|
+
// a DOM; so it could load in neither environment, and it never exported
|
|
6
|
+
// <Deferred>. It is now two entries, chosen by the conditions package.json
|
|
7
|
+
// lists:
|
|
8
|
+
//
|
|
9
|
+
// react-server index.server.js: what a server component calls -- query(),
|
|
10
|
+
// nonce(), the render context -- and <Deferred>.
|
|
11
|
+
// browser this file: what @tinvar/react/client exports, for the
|
|
12
|
+
// default browser and for the SSR environment, which renders client
|
|
13
|
+
// components on Node.
|
|
14
|
+
//
|
|
15
|
+
// The types follow the same conditions, with one case more. TypeScript
|
|
16
|
+
// applies one set of conditions to a whole program and adds neither of
|
|
17
|
+
// these itself, so a program of server code alone says `react-server` in
|
|
18
|
+
// its customConditions and sees index.server.d.ts; one of client code alone
|
|
19
|
+
// says `browser` and sees index.d.ts; and one that checks both together --
|
|
20
|
+
// an app's single tsconfig -- says neither and sees index.combined.d.ts:
|
|
21
|
+
// both entries' names, each typed as it is. The bundler, which does resolve
|
|
22
|
+
// per environment, is what refuses a server-only name in a client file.
|
|
23
|
+
// `query` is the one name both entries export, as two functions, so the
|
|
24
|
+
// combined types give neither: there it comes from `/server` or `/form`.
|
|
25
|
+
//
|
|
26
|
+
// The subpath entries stay, and remain the precise way in: a client
|
|
27
|
+
// component such as <Link> comes from its own subpath in either environment.
|
|
28
|
+
|
|
29
|
+
export * from './client.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @tinvar/react under the `react-server` condition: the RSC environment's
|
|
2
|
+
// root. See index.js for the other environments and why there are two.
|
|
3
|
+
//
|
|
4
|
+
// <Deferred> renders a client boundary, which the RSC environment turns into
|
|
5
|
+
// a client reference, so nothing of the browser's is loaded here.
|
|
6
|
+
|
|
7
|
+
export * from './server.js';
|
|
8
|
+
export { Deferred } from './deferred.jsx';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Context, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a Scroll page says about itself, without its items. A cursor page's
|
|
5
|
+
* `cursor` is the one it was read at (null for the first), its `previous`
|
|
6
|
+
* and `next` are cursors, and its `page` is 0 until placed (ADR 0120).
|
|
7
|
+
*/
|
|
8
|
+
export interface ScrollMeta {
|
|
9
|
+
id: string;
|
|
10
|
+
page: number;
|
|
11
|
+
cursor?: string | null;
|
|
12
|
+
previous: number | string | null;
|
|
13
|
+
next: number | string | null;
|
|
14
|
+
total: number | null;
|
|
15
|
+
perPage: number;
|
|
16
|
+
pageName: string;
|
|
17
|
+
reset: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The nearest <InfiniteScroll>'s state, and its two loads. */
|
|
21
|
+
export interface InfiniteScrollState {
|
|
22
|
+
/** The list's dot path. */
|
|
23
|
+
id: string;
|
|
24
|
+
reverse: boolean;
|
|
25
|
+
/** Which edge is loading, if one is. */
|
|
26
|
+
loading: 'next' | 'previous' | null;
|
|
27
|
+
/** Whether pages load only when asked: `manual`, or `manualAfter` reached. */
|
|
28
|
+
manual: boolean;
|
|
29
|
+
hasNext: boolean;
|
|
30
|
+
hasPrevious: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The first and last page shown: page numbers, or for a list read by
|
|
33
|
+
* cursor, places counted from the page that arrived whole (0).
|
|
34
|
+
*/
|
|
35
|
+
first: number;
|
|
36
|
+
last: number;
|
|
37
|
+
total: number | null;
|
|
38
|
+
perPage: number;
|
|
39
|
+
/** Resolve with whether a page was merged in. */
|
|
40
|
+
loadNext(): Promise<boolean>;
|
|
41
|
+
loadPrevious(): Promise<boolean>;
|
|
42
|
+
/** How many times the list has started again: a reset, a whole page, a cursor page that could not be placed. */
|
|
43
|
+
epoch: number;
|
|
44
|
+
/** Where a cursor page read at this cursor goes, once placed. */
|
|
45
|
+
place(cursor: string | null): number | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export declare const InfiniteScrollContext: Context<InfiniteScrollState | null>;
|
|
49
|
+
|
|
50
|
+
/** The nearest <InfiniteScroll>'s state, for a client component: a "Load more", a count. */
|
|
51
|
+
export declare function useInfiniteScroll(): InfiniteScrollState;
|
|
52
|
+
|
|
53
|
+
/** The client half of <InfiniteScroll>; rendered by it, not by hand. */
|
|
54
|
+
export declare function InfiniteScrollRoot(props: {
|
|
55
|
+
field: ScrollMeta;
|
|
56
|
+
buffer?: number;
|
|
57
|
+
manual?: boolean;
|
|
58
|
+
manualAfter?: number;
|
|
59
|
+
reverse?: boolean;
|
|
60
|
+
preserveUrl?: boolean;
|
|
61
|
+
previous?: ReactNode;
|
|
62
|
+
next?: ReactNode;
|
|
63
|
+
children?: ReactNode;
|
|
64
|
+
}): ReactNode;
|
|
65
|
+
|
|
66
|
+
export declare function extendRange<E extends { page: number }>(
|
|
67
|
+
range: { first: E; last: E },
|
|
68
|
+
edge: E,
|
|
69
|
+
): { first: E; last: E };
|
|
70
|
+
export declare function scrollContainerOf(element: Element | null): Element | null;
|
|
71
|
+
export declare function itemsOf(
|
|
72
|
+
root: Element,
|
|
73
|
+
id: string,
|
|
74
|
+
): { page: number; element: Element; key: string | null; index: number; cursor: string | null }[];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a cursor page read at `cursor` goes among those shown: where it is,
|
|
78
|
+
* after the last (read at its `next`), before the first (at its
|
|
79
|
+
* `previous`); undefined for one none of them leads to.
|
|
80
|
+
*/
|
|
81
|
+
export declare function placeOf(
|
|
82
|
+
range: {
|
|
83
|
+
first: { page: number; previous: number | string | null };
|
|
84
|
+
last: { page: number; next: number | string | null };
|
|
85
|
+
places: ReadonlyMap<string, number>;
|
|
86
|
+
},
|
|
87
|
+
cursor: string | null,
|
|
88
|
+
): number | undefined;
|
|
89
|
+
|
|
90
|
+
/** How many pages either side of the reader's are loaded again when they come back to it. */
|
|
91
|
+
export declare const RESTORE_PAGES: number;
|
|
92
|
+
|
|
93
|
+
/** Where the reader is in a list, or null when no item is in view. */
|
|
94
|
+
export declare function anchorOf(
|
|
95
|
+
root: Element,
|
|
96
|
+
id: string,
|
|
97
|
+
container: Element | null,
|
|
98
|
+
range: { first: { page: number }; last: { page: number } },
|
|
99
|
+
base?: ((visible: { page: number; cursor: string | null }) => number) | null,
|
|
100
|
+
): import('./router.js').ScrollAnchor | null;
|
|
101
|
+
|
|
102
|
+
/** Scrolls the list so the anchor's item is where it was; whether the item is there. */
|
|
103
|
+
export declare function scrollToAnchor(
|
|
104
|
+
root: Element,
|
|
105
|
+
id: string,
|
|
106
|
+
container: Element | null,
|
|
107
|
+
anchor: import('./router.js').ScrollAnchor,
|
|
108
|
+
): boolean;
|