@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.
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
package/src/testing.js ADDED
@@ -0,0 +1,634 @@
1
+ // The JavaScript half of the testing layer: what codegen's mocks of
2
+ // `@queries/*` and `@actions/*` are built on, and the harness server
3
+ // components render in (tinvar-spec.md, "Testing", "Client components").
4
+ //
5
+ // "Vitest plus React Testing Library on the JavaScript side. Codegen emits:
6
+ // typed fakes from Data objects (`fakeRackData({ name: 'R-14' })`); mock
7
+ // versions of `@queries/*` and `@actions/*`, with call recording. Server
8
+ // components render in the same harness against those mocks."
9
+ //
10
+ // The fakes are plain functions codegen writes beside the types; nothing of
11
+ // them is here. What is here is the mocks' state -- the calls each one was
12
+ // made with, and what it answers -- and renderServer(), which awaits a tree
13
+ // of server components into one React Testing Library can render.
14
+ //
15
+ // This module never runs anywhere but a Vitest worker. It refuses to load
16
+ // outside one, and in any process that says production, before anything of
17
+ // it is defined; and `queryFunctions()` in @tinvar/renderer/vite fails a
18
+ // Vite build whose graph reaches it. A mock that answered in production
19
+ // would be an action that does nothing and says it succeeded (ADR 0061).
20
+
21
+ import { cloneElement, isValidElement } from 'react';
22
+ import { ActionError } from './action.js';
23
+ import { runInRenderContext } from './server.js';
24
+
25
+ /** Found by `queryFunctions()`, which fails any Vite build whose graph holds it. */
26
+ export const TESTING_RUNTIME = 'tinvar:testing-runtime';
27
+
28
+ /** The variables any of which, saying `production`, refuse this module. Mirrors Tinvar\Support\DevMode. */
29
+ const PRODUCTION_MARKERS = ['NODE_ENV', 'TINVAR_ENV', 'APP_ENV'];
30
+
31
+ refuseOutsideTests(globalThis.process?.env);
32
+
33
+ /**
34
+ * Throws unless this is a Vitest worker in a process that is not production.
35
+ * Exported for its own test; it has already run by the time anything can
36
+ * import it.
37
+ *
38
+ * Vitest sets VITEST in every worker it starts. A browser has no `process`
39
+ * at all, so a bundle that somehow carried this module stops here too.
40
+ *
41
+ * @param {Record<string, string | undefined> | undefined} env
42
+ */
43
+ export function refuseOutsideTests(env) {
44
+ if (env === undefined || env === null || typeof env !== 'object') {
45
+ throw new Error(
46
+ '[tinvar] @tinvar/react/testing loaded where there is no process: it runs only in a Vitest worker.',
47
+ );
48
+ }
49
+ for (const name of PRODUCTION_MARKERS) {
50
+ if (
51
+ String(env[name] ?? '')
52
+ .trim()
53
+ .toLowerCase() === 'production'
54
+ ) {
55
+ throw new Error(
56
+ `[tinvar] @tinvar/react/testing is refused: ${name}=production. Its mocks answer for queries and ` +
57
+ 'actions without running them, so it never loads in production.',
58
+ );
59
+ }
60
+ }
61
+ if (!env.VITEST) {
62
+ throw new Error('[tinvar] @tinvar/react/testing runs only in a Vitest worker, and VITEST is not set.');
63
+ }
64
+ }
65
+
66
+ /** Marks an action reference; the same symbol action.js marks real ones with. */
67
+ const REFERENCE = Symbol.for('tinvar.action');
68
+
69
+ /** Where a mock keeps its entry. */
70
+ const MOCK = Symbol('tinvar.mock');
71
+
72
+ const ACTION_PREFIX = '/_action/';
73
+ const QUERY_PREFIX = '/_query/';
74
+
75
+ /**
76
+ * @typedef {{
77
+ * kind: 'query' | 'action',
78
+ * name: string,
79
+ * calls: unknown[],
80
+ * fallback: () => unknown,
81
+ * answer: { type: 'value', value: unknown } | { type: 'refuse', status: number, errors: Record<string, string[]> } | null,
82
+ * }} Entry
83
+ */
84
+
85
+ /**
86
+ * Every mock this worker has made, by kind and wire name: an action is
87
+ * reached by name from a <Form>'s fetch as well as by its reference.
88
+ *
89
+ * @type {Map<string, Entry>}
90
+ */
91
+ const entries = new Map();
92
+
93
+ /**
94
+ * @param {'query' | 'action'} kind
95
+ * @param {string} name
96
+ * @param {() => unknown} fallback
97
+ * @returns {Entry}
98
+ */
99
+ function register(kind, name, fallback) {
100
+ if (typeof name !== 'string' || name === '') {
101
+ throw new TypeError(`[tinvar] A mocked ${kind} needs its wire name.`);
102
+ }
103
+ if (typeof fallback !== 'function') {
104
+ throw new TypeError(
105
+ `[tinvar] The mocked ${kind} ${name} needs a function that makes its default answer.`,
106
+ );
107
+ }
108
+ // A module evaluated again -- vi.resetModules() -- makes its mocks again;
109
+ // they keep the calls and answers a test gave the first ones.
110
+ const key = `${kind}:${name}`;
111
+ const existing = entries.get(key);
112
+ if (existing) {
113
+ existing.fallback = fallback;
114
+ return existing;
115
+ }
116
+ /** @type {Entry} */
117
+ const entry = { kind, name, calls: [], fallback, answer: null };
118
+ entries.set(key, entry);
119
+ return entry;
120
+ }
121
+
122
+ /**
123
+ * What a mock answers for one call: what the test gave it, or else a fresh
124
+ * default from its fake.
125
+ *
126
+ * @param {Entry} entry
127
+ * @param {unknown} args
128
+ */
129
+ function answerFor(entry, args) {
130
+ const answer = entry.answer;
131
+ if (answer === null) return entry.fallback();
132
+ if (answer.type === 'value') {
133
+ return typeof answer.value === 'function' ? answer.value(args) : answer.value;
134
+ }
135
+ if (entry.kind === 'action') {
136
+ throw new ActionError(entry.name, refusalOutcome(answer.status, answer.errors));
137
+ }
138
+ throw new Error(`[tinvar] The query ${entry.name} was refused (${answer.status}), as the test asked.`);
139
+ }
140
+
141
+ /**
142
+ * The outcome transport.js classifies a refusal as, for an ActionError.
143
+ *
144
+ * @param {number} status
145
+ * @param {Record<string, string[]>} errors
146
+ */
147
+ function refusalOutcome(status, errors) {
148
+ const headers = new Headers();
149
+ return status === 422
150
+ ? { type: 'validation', status: 422, errors, body: { errors }, headers }
151
+ : { type: 'http', status, body: null, headers, code: null, reference: null, retryAfter: null };
152
+ }
153
+
154
+ /**
155
+ * A stand-in for one `@queries/*` function: called as the real one is, it
156
+ * records its arguments and resolves with what the test said, or a fake.
157
+ * What codegen's mock query modules are made of.
158
+ *
159
+ * @param {string} name the query's wire name
160
+ * @param {() => unknown} fallback
161
+ */
162
+ export function mockQuery(name, fallback) {
163
+ const entry = register('query', name, fallback);
164
+ /** @param {Record<string, unknown>} [args] */
165
+ const call = async (args = {}) => {
166
+ entry.calls.push(args);
167
+ return answerFor(entry, args);
168
+ };
169
+ Object.defineProperties(call, {
170
+ query: { value: name, enumerable: true },
171
+ [MOCK]: { value: entry },
172
+ });
173
+ return Object.freeze(call);
174
+ }
175
+
176
+ /**
177
+ * A stand-in for one `@actions/*` reference. It is a reference -- <Form>,
178
+ * useForm and useHttp take it, `.optimistic()` works -- and called directly
179
+ * it records its arguments and resolves with what the test said, or an
180
+ * empty ActionResult. A post a <Form> makes reaches it through
181
+ * actionFetch(), which a test installs as fetch.
182
+ *
183
+ * @param {string} name the action's wire name
184
+ * @param {() => unknown} fallback
185
+ */
186
+ export function mockAction(name, fallback) {
187
+ return actionReference(register('action', name, fallback), null);
188
+ }
189
+
190
+ /**
191
+ * @param {Entry} entry
192
+ * @param {((state: Record<string, any>) => Record<string, unknown>) | null} patch
193
+ */
194
+ function actionReference(entry, patch) {
195
+ /** @param {Record<string, unknown>} [args] */
196
+ const call = async (args = {}) => {
197
+ entry.calls.push(args);
198
+ return answerFor(entry, args);
199
+ };
200
+ Object.defineProperties(call, {
201
+ action: { value: entry.name, enumerable: true },
202
+ url: { value: ACTION_PREFIX + encodeURIComponent(entry.name), enumerable: true },
203
+ optimisticPatch: { value: patch },
204
+ [REFERENCE]: { value: true },
205
+ [MOCK]: { value: entry },
206
+ optimistic: {
207
+ /** @param {(state: Record<string, any>) => Record<string, unknown>} next */
208
+ value: (next) => {
209
+ if (typeof next !== 'function') {
210
+ throw new TypeError('[tinvar] optimistic() takes a function from the current state to a patch.');
211
+ }
212
+ return actionReference(entry, next);
213
+ },
214
+ },
215
+ });
216
+ return Object.freeze(call);
217
+ }
218
+
219
+ /** @param {unknown} mock @returns {Entry} */
220
+ function entryOf(mock) {
221
+ const entry = mock !== null && typeof mock === 'function' ? /** @type {any} */ (mock)[MOCK] : undefined;
222
+ if (entry === undefined) {
223
+ throw new TypeError(
224
+ '[tinvar] That is not a mocked query or action. Import it from @queries/* or @actions/* in a test ' +
225
+ 'run with tinvarTesting(), which puts the mocks codegen wrote in their place.',
226
+ );
227
+ }
228
+ return entry;
229
+ }
230
+
231
+ /**
232
+ * The arguments each call to a mock was made with, oldest first: a direct
233
+ * call's as given, a <Form>'s as it posted them.
234
+ *
235
+ * @param {unknown} mock
236
+ * @returns {unknown[]}
237
+ */
238
+ export function calls(mock) {
239
+ return [...entryOf(mock).calls];
240
+ }
241
+
242
+ /**
243
+ * What a mock answers from now on, until resetMocks(): a value, or a
244
+ * function of the call's arguments. A promise that never settles keeps a
245
+ * server component suspended; a function that throws fails the call.
246
+ *
247
+ * @param {unknown} mock
248
+ * @param {unknown} value
249
+ */
250
+ export function answer(mock, value) {
251
+ entryOf(mock).answer = { type: 'value', value };
252
+ }
253
+
254
+ /**
255
+ * Has a mock refuse from now on: an action rejects with the ActionError the
256
+ * endpoint's answer would give -- field errors with 422 -- and a <Form>
257
+ * posting to it gets that status; a query rejects.
258
+ *
259
+ * @param {unknown} mock
260
+ * @param {{ status?: number, errors?: Record<string, string[]> }} [refusal]
261
+ */
262
+ export function refuse(mock, { status = 422, errors = {} } = {}) {
263
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
264
+ throw new RangeError(`[tinvar] A refusal is a 4xx or 5xx status, not ${status}.`);
265
+ }
266
+ entryOf(mock).answer = { type: 'refuse', status, errors };
267
+ }
268
+
269
+ /**
270
+ * A fake's defaults under a test's overrides, at any depth: a plain object
271
+ * given for a field that holds one is laid over that field's own defaults,
272
+ * so `fakeOrderData({ customer: { name: 'Ada' } })` keeps the customer's
273
+ * other fields. Anything else given -- a list, null, a string -- takes the
274
+ * field's place whole, and a field given as `undefined` keeps its default.
275
+ * What codegen's fakes return; neither argument is changed.
276
+ *
277
+ * The keys an object can be given its prototype by (`__proto__`,
278
+ * `constructor`, `prototype`) are refused, not copied: a fake is built from
279
+ * what a test wrote, and a merge that let one through would change every
280
+ * object in the worker.
281
+ *
282
+ * @template T
283
+ * @param {T} base
284
+ * @param {unknown} overrides
285
+ * @returns {T}
286
+ */
287
+ export function withOverrides(base, overrides) {
288
+ return /** @type {T} */ (layered(base, overrides, 'overrides'));
289
+ }
290
+
291
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
292
+
293
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
294
+ function isPlainObject(value) {
295
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
296
+ const prototype = Object.getPrototypeOf(value);
297
+ return prototype === Object.prototype || prototype === null;
298
+ }
299
+
300
+ /** @param {unknown} base @param {unknown} over @param {string} where @returns {unknown} */
301
+ function layered(base, over, where) {
302
+ if (over === undefined) return base;
303
+ if (!isPlainObject(over) || !isPlainObject(base)) return over;
304
+ /** @type {Record<string, unknown>} */
305
+ const out = { ...base };
306
+ for (const key of Object.keys(over)) {
307
+ if (UNSAFE_KEYS.has(key)) {
308
+ throw new TypeError(
309
+ `[tinvar] ${where} names \`${key}\`, which no fake has: it would change the prototype.`,
310
+ );
311
+ }
312
+ out[key] = layered(base[key], over[key], `${where}.${key}`);
313
+ }
314
+ return out;
315
+ }
316
+
317
+ /** Forgets every call and answer, so each test starts from the fakes. */
318
+ export function resetMocks() {
319
+ for (const entry of entries.values()) {
320
+ entry.calls = [];
321
+ entry.answer = null;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * A fetch for component tests: a post to `/_action/{name}` answered by that
327
+ * action's mock, and one to `/_query/{name}` -- what useHttp() sends -- by
328
+ * that query's mock, each recorded and then answered as its endpoint would;
329
+ * anything else refused with a 404, never sent. Install it with
330
+ * `vi.stubGlobal('fetch', actionFetch)`.
331
+ *
332
+ * A query's call is recorded with the arguments it was sent under `args`,
333
+ * as a direct call's are, and answered `{ value }`; a refusal the test
334
+ * asked for is its status, with the field errors at 422.
335
+ *
336
+ * @param {RequestInfo | URL} input
337
+ * @param {RequestInit} [init]
338
+ * @returns {Promise<Response>}
339
+ */
340
+ export async function actionFetch(input, init = {}) {
341
+ // The page's own origin only, as the endpoint is: a post elsewhere is not
342
+ // one of the application's actions, whatever its path says.
343
+ const here = new URL(globalThis.location?.href ?? 'http://localhost/');
344
+ const request = input instanceof Request ? input : null;
345
+ const url = new URL(request?.url ?? String(input), here);
346
+ const method = (init.method ?? request?.method ?? 'GET').toUpperCase();
347
+ const ours = url.origin === here.origin;
348
+ const kind =
349
+ ours && url.pathname.startsWith(ACTION_PREFIX)
350
+ ? 'action'
351
+ : ours && url.pathname.startsWith(QUERY_PREFIX)
352
+ ? 'query'
353
+ : null;
354
+ const name =
355
+ kind === null
356
+ ? null
357
+ : actionOf(url.pathname.slice((kind === 'action' ? ACTION_PREFIX : QUERY_PREFIX).length));
358
+ const entry = name === null ? undefined : entries.get(`${kind}:${name}`);
359
+ if (entry === undefined || method !== 'POST') {
360
+ return json(404, {
361
+ error: {
362
+ code: 'not_mocked',
363
+ message: `No mocked ${kind ?? 'action'} answers ${method} ${url.pathname}.`,
364
+ },
365
+ });
366
+ }
367
+
368
+ const sent = await bodyOf(init.body ?? (request === null ? null : await request.text()));
369
+ if (entry.kind === 'query') return queryAnswer(entry, sent);
370
+
371
+ const args = sent;
372
+ entry.calls.push(args);
373
+ try {
374
+ const body = await answerFor(entry, args);
375
+ return json(200, body ?? { value: null, flash: {}, revalidated: [], payloads: {} });
376
+ } catch (error) {
377
+ if (error instanceof ActionError) {
378
+ return json(error.status, error.kind === 'validation' ? { errors: error.errors } : null);
379
+ }
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * A query's answer over the wire: `{ value }`, or the refusal the test asked
386
+ * for. Its arguments are what useHttp() sends under `args`; a body without
387
+ * them is refused with the 422 the endpoint gives, and not recorded.
388
+ *
389
+ * @param {Entry} entry
390
+ * @param {unknown} sent
391
+ */
392
+ async function queryAnswer(entry, sent) {
393
+ const args = isPlainObject(sent) ? (sent.args ?? {}) : null;
394
+ if (!isPlainObject(args)) {
395
+ return json(422, { errors: { args: ['The arguments are an object under `args`.'] } });
396
+ }
397
+ entry.calls.push(args);
398
+ const answer = entry.answer;
399
+ if (answer?.type === 'refuse') {
400
+ return json(answer.status, answer.status === 422 ? { errors: answer.errors } : null);
401
+ }
402
+ return json(200, { value: (await answerFor(entry, args)) ?? null });
403
+ }
404
+
405
+ /** An action's name from its path segment, or null for one that does not decode. @param {string} segment */
406
+ function actionOf(segment) {
407
+ try {
408
+ return decodeURIComponent(segment);
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /** @param {BodyInit | null | undefined} body */
415
+ async function bodyOf(body) {
416
+ if (typeof body === 'string') {
417
+ try {
418
+ return JSON.parse(body);
419
+ } catch {
420
+ return body;
421
+ }
422
+ }
423
+ if (body instanceof FormData) {
424
+ /** @type {Record<string, unknown>} */
425
+ const out = {};
426
+ for (const [key, value] of body.entries()) out[key] = value;
427
+ return out;
428
+ }
429
+ return {};
430
+ }
431
+
432
+ /** @param {number} status @param {unknown} body */
433
+ function json(status, body) {
434
+ return new Response(body === null ? '' : JSON.stringify(body), {
435
+ status,
436
+ headers: { 'content-type': 'application/json' },
437
+ });
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Server components
442
+
443
+ /** Where 'use client' functions are kept: one set however often this module is loaded. */
444
+ const CLIENT = Symbol.for('tinvar.testing.client');
445
+ /** @type {WeakSet<Function>} */
446
+ const clientComponents = /** @type {any} */ (globalThis)[CLIENT] ?? new WeakSet();
447
+ /** @type {any} */ (globalThis)[CLIENT] = clientComponents;
448
+
449
+ /**
450
+ * Marks the exports of a 'use client' module as client components, which
451
+ * renderServer() leaves for React to render. `tinvarTesting()` adds the call
452
+ * to each such module; a test has no need of it.
453
+ *
454
+ * @param {...unknown} exports
455
+ */
456
+ export function markClient(...exports) {
457
+ for (const value of exports) {
458
+ if (typeof value === 'function') clientComponents.add(value);
459
+ }
460
+ }
461
+
462
+ /** @param {unknown} type */
463
+ export function isClientComponent(type) {
464
+ return typeof type === 'function' && clientComponents.has(type);
465
+ }
466
+
467
+ /**
468
+ * Renders the server components of a tree, as the renderer's rsc
469
+ * environment does, and hands back what is left: host elements and client
470
+ * components, with every server component's output in its place, for React
471
+ * Testing Library's render().
472
+ *
473
+ * A server component is any function component not exported from a
474
+ * 'use client' module. It is called with its props and awaited, inside a
475
+ * render context whose `shared()` answers from `options.shared` -- a key not
476
+ * given fails, as a key PHP does not share does -- and whose query()
477
+ * answers from the mocks. Suspense boundaries are waited out, so what comes
478
+ * back is the tree as it finally streams; a query the test answered with a
479
+ * promise that never settles never comes back.
480
+ *
481
+ * The props a server component gives a client component or an element are
482
+ * held to what Flight can send: a function there that is not an action
483
+ * reference fails the render, as it would in the real one.
484
+ *
485
+ * @param {import('react').ReactNode} node
486
+ * @param {{ shared?: Record<string, unknown> }} [options]
487
+ * @returns {Promise<import('react').ReactNode>}
488
+ */
489
+ export function renderServer(node, { shared = {} } = {}) {
490
+ const context = {
491
+ requestId: 'test',
492
+ nonce: 'test-nonce',
493
+ get allowed() {
494
+ return [...entries.values()].filter((e) => e.kind === 'query').map((e) => e.name);
495
+ },
496
+ client: {
497
+ /** @param {string} name @param {Record<string, unknown>} args */
498
+ call: async (name, args = {}) => {
499
+ if (name.startsWith('$shared.')) {
500
+ const key = name.slice('$shared.'.length);
501
+ if (!Object.hasOwn(shared, key)) {
502
+ throw new Error(
503
+ `[tinvar] A component read shared('${key}'), and renderServer() was given no value for it. ` +
504
+ `Pass { shared: { ${key}: ... } }.`,
505
+ );
506
+ }
507
+ return shared[key];
508
+ }
509
+ const entry = entries.get(`query:${name}`);
510
+ if (entry === undefined) throw new Error(`[tinvar] No mocked query is named ${name}.`);
511
+ entry.calls.push(args);
512
+ return answerFor(entry, args);
513
+ },
514
+ },
515
+ mints: {
516
+ /** @param {string} component */
517
+ mint: async (component) => `test-fragment-token:${component}`,
518
+ },
519
+ };
520
+
521
+ return runInRenderContext(context, () => resolve(node));
522
+ }
523
+
524
+ /**
525
+ * @param {unknown} node
526
+ * @returns {Promise<any>}
527
+ */
528
+ async function resolve(node) {
529
+ if (Array.isArray(node)) return Promise.all(node.map((child) => resolve(child)));
530
+ if (isThenable(node)) return resolve(await node);
531
+ if (!isValidElement(node)) return node;
532
+
533
+ const element = /** @type {import('react').ReactElement<Record<string, unknown>>} */ (node);
534
+ const type = element.type;
535
+
536
+ if (typeof type === 'function' && !isClientComponent(type) && !type.prototype?.isReactComponent) {
537
+ let output;
538
+ try {
539
+ output = type(element.props);
540
+ } catch (error) {
541
+ throw explainHooks(error, type);
542
+ }
543
+ return resolve(await output);
544
+ }
545
+
546
+ // A host element, a fragment, Suspense, a provider, or a client
547
+ // component: rendered by React. What it was handed may hold server
548
+ // components still -- children, a fallback -- so those are rendered.
549
+ // Everything here came out of a server component, so a host element's
550
+ // props and a client component's are what Flight would have to send.
551
+ const sent = typeof type === 'string' ? `<${type}>` : isClientComponent(type) ? `<${nameOf(type)}>` : null;
552
+ /** @type {Record<string, unknown>} */
553
+ const props = {};
554
+ let changed = false;
555
+ for (const [key, value] of Object.entries(element.props)) {
556
+ if (sent !== null) assertSendable(value, `${sent}'s ${key}`);
557
+ if (holdsElements(value, key === 'children')) {
558
+ props[key] = await resolve(value);
559
+ changed = true;
560
+ }
561
+ }
562
+ return changed ? cloneElement(element, props) : element;
563
+ }
564
+
565
+ /**
566
+ * Whether a prop is something to render: an element, or a list holding
567
+ * one, and as children a promise too -- what an async child streams as. A
568
+ * promise in any other prop is one Flight sends as a promise, for the
569
+ * client component to use(), so it is left one. A list of plain data -- a
570
+ * page's products -- is left as it is.
571
+ *
572
+ * @param {unknown} value
573
+ * @param {boolean} children
574
+ * @returns {boolean}
575
+ */
576
+ function holdsElements(value, children) {
577
+ if (isValidElement(value) || (children && isThenable(value))) return true;
578
+ return Array.isArray(value) && value.some((item) => holdsElements(item, children));
579
+ }
580
+
581
+ /**
582
+ * What Flight refuses: a function, unless it is an action reference (which
583
+ * the real build sends as `{ action, url }`), and a symbol.
584
+ *
585
+ * @param {unknown} value
586
+ * @param {string} where
587
+ * @param {Set<unknown>} [seen]
588
+ */
589
+ function assertSendable(value, where, seen = new Set()) {
590
+ if (typeof value === 'symbol') {
591
+ throw new TypeError(`[tinvar] ${where} is a symbol, which a server component cannot hand a client one.`);
592
+ }
593
+ if (typeof value === 'function') {
594
+ if (/** @type {any} */ (value)[REFERENCE] === true) return;
595
+ throw new TypeError(
596
+ `[tinvar] ${where} is a function, which a server component cannot hand a client one ` +
597
+ '("Functions cannot be passed directly to Client Components"). Pass an action reference or data.',
598
+ );
599
+ }
600
+ if (value === null || typeof value !== 'object' || seen.has(value) || isValidElement(value)) return;
601
+ seen.add(value);
602
+ for (const [key, inner] of Object.entries(value)) assertSendable(inner, `${where}.${key}`, seen);
603
+ }
604
+
605
+ /** @param {unknown} value @returns {value is PromiseLike<unknown>} */
606
+ function isThenable(value) {
607
+ return (
608
+ value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value).then) === 'function'
609
+ );
610
+ }
611
+
612
+ /** @param {Function} type */
613
+ function nameOf(type) {
614
+ return /** @type {any} */ (type).displayName || type.name || 'a client component';
615
+ }
616
+
617
+ /**
618
+ * A hook called outside React means a client component was called as a
619
+ * server one: its module has no 'use client', or the test runs without
620
+ * tinvarTesting(), which marks those that do.
621
+ *
622
+ * @param {unknown} error
623
+ * @param {Function} type
624
+ */
625
+ function explainHooks(error, type) {
626
+ const message = error instanceof Error ? error.message : String(error);
627
+ if (!/hook|reading 'use[A-Z]/i.test(message)) return error;
628
+ return new Error(
629
+ `[tinvar] ${nameOf(type)} was rendered as a server component and called a hook (${message}). ` +
630
+ "A component with hooks is a client one: its module needs 'use client', and the test config " +
631
+ 'tinvarTesting() from @tinvar/renderer/vitest.',
632
+ { cause: error },
633
+ );
634
+ }
@@ -0,0 +1,48 @@
1
+ /** What one export takes and writes, as codegen describes it. */
2
+ export interface ExportShape {
3
+ args: object;
4
+ formats: string;
5
+ }
6
+
7
+ /** What one import takes and reads, as codegen describes it. */
8
+ export interface ImportShape {
9
+ args: object;
10
+ columns: string;
11
+ }
12
+
13
+ /** A start the server refused: its status, its error code, and a 422's field errors. */
14
+ export declare class TransferError extends Error {
15
+ readonly transfer: string;
16
+ readonly status: number;
17
+ readonly code: string | null;
18
+ readonly errors: Record<string, string[]>;
19
+ }
20
+
21
+ /** The record a start queued: its id, for the page that lists the user's transfers. */
22
+ export interface Started {
23
+ transfer: string;
24
+ status: 'queued';
25
+ }
26
+
27
+ export interface Transfers<
28
+ E extends { [N in keyof E]: ExportShape },
29
+ I extends { [N in keyof I]: ImportShape },
30
+ > {
31
+ /** Starts an export: the file is written by a worker, and linked from the user's list once it is. */
32
+ export<N extends keyof E & string>(
33
+ name: N,
34
+ options?: { args?: E[N]['args']; format?: E[N]['formats']; signal?: AbortSignal; fetch?: typeof fetch },
35
+ ): Promise<Started>;
36
+ /** Sends a CSV file to an import: its rows are applied by a worker, all of them or none. */
37
+ import<N extends keyof I & string>(
38
+ name: N,
39
+ file: Blob,
40
+ options?: { args?: I[N]['args']; signal?: AbortSignal; fetch?: typeof fetch },
41
+ ): Promise<Started>;
42
+ }
43
+
44
+ /** Builds the typed `transfer` codegen exports. */
45
+ export declare function transfers<
46
+ E extends { [N in keyof E]: ExportShape },
47
+ I extends { [N in keyof I]: ImportShape },
48
+ >(): Transfers<E, I>;