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