@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,198 @@
1
+ // The boundaries of one <Deferred group>, fetched together.
2
+ //
3
+ // Inertia's `Inertia::defer($fn, 'group')` loads every deferred prop in a
4
+ // group in one request. Here a group is the boundaries on one page that
5
+ // share a `group` name, and they load in one request too: when any of them
6
+ // is triggered, every one of them that has something to load goes in a
7
+ // single `GET /_fragment/<token>/<token>/...`, whose payload is the list of
8
+ // their subtrees (ADR 0018). The server verifies each token, runs the page's
9
+ // chain once, and renders them in one render, so their queries batch as a
10
+ // page's do; here, each boundary is handed its own subtree.
11
+ //
12
+ // No React: the boundaries are React's, and talk to this through a small
13
+ // member interface, so the batching rules can be read -- and tested --
14
+ // without mounting anything.
15
+
16
+ import { FragmentHandle } from './fragment-client.js';
17
+
18
+ /**
19
+ * The most boundaries one request may carry. Mirrors
20
+ * FragmentEndpoint::MAX_GROUP: the endpoint answers a longer list with the
21
+ * same 404 as any refused token, which is final: a group sent past it whole
22
+ * never loaded, and every member gave up on its first try.
23
+ */
24
+ export const MAX_GROUP = 16;
25
+
26
+ /**
27
+ * What a boundary gives its group.
28
+ *
29
+ * @typedef {{
30
+ * handle: FragmentHandle,
31
+ * ready: (again: boolean, asked: boolean) => boolean,
32
+ * start: (again: boolean, load?: Promise<unknown>) => void,
33
+ * skip: (again: boolean) => void,
34
+ * }} Member
35
+ *
36
+ * ready whether it would load now: not already loading, not given up, and
37
+ * not loaded unless it always refetches -- or `again`, for a
38
+ * revalidation. `asked` says whether it was triggered itself, which
39
+ * a boundary showing its rescue slot waits for.
40
+ * start runs its load: the one given, its share of the group's request,
41
+ * or with none its own request, when it is the only one ready.
42
+ * skip it asked and was not ready.
43
+ */
44
+
45
+ class Group {
46
+ /** @type {Set<Member>} */
47
+ members = new Set();
48
+ /** @type {Set<Member>} members that asked since the last flush */
49
+ asked = new Set();
50
+ again = false;
51
+ scheduled = false;
52
+ /** @type {Set<{ members: Set<Member>, controller: AbortController }>} */
53
+ flights = new Set();
54
+
55
+ /**
56
+ * @param {() => ((stream: ReadableStream<Uint8Array>) => unknown) | undefined} decode
57
+ * @param {() => Record<string, string>} claims
58
+ */
59
+ constructor(decode, claims) {
60
+ this.decode = decode;
61
+ this.claims = claims;
62
+ }
63
+
64
+ /** @param {Member} member @param {boolean} again */
65
+ request(member, again) {
66
+ this.asked.add(member);
67
+ this.again ||= again;
68
+ if (this.scheduled) return;
69
+ this.scheduled = true;
70
+
71
+ // A task, not a microtask: boundaries scrolled into view together are
72
+ // reported by one observer each, and those callbacks are not all in one
73
+ // microtask. Waiting a task gathers them into the one request.
74
+ setTimeout(() => this.flush(), 0);
75
+ }
76
+
77
+ flush() {
78
+ this.scheduled = false;
79
+ const { again, asked } = this;
80
+ this.again = false;
81
+ this.asked = new Set();
82
+
83
+ const batch = [];
84
+ for (const member of this.members) {
85
+ if (member.ready(again, asked.has(member))) batch.push(member);
86
+ else if (asked.has(member)) member.skip(again);
87
+ }
88
+
89
+ // Past the endpoint's cap, in requests of at most MAX_GROUP: each its own
90
+ // flight, which succeeds or fails on its own, as two groups' would.
91
+ for (let i = 0; i < batch.length; i += MAX_GROUP) {
92
+ this.send(batch.slice(i, i + MAX_GROUP), again);
93
+ }
94
+ }
95
+
96
+ /** @param {Member[]} batch @param {boolean} again */
97
+ send(batch, again) {
98
+ // One alone is an ordinary request: its payload's root is its subtree,
99
+ // as a lone boundary's is.
100
+ if (batch.length === 1) {
101
+ batch[0].start(again);
102
+ return;
103
+ }
104
+
105
+ const controller = new AbortController();
106
+ const flight = { members: new Set(batch), controller };
107
+ this.flights.add(flight);
108
+
109
+ const loading = FragmentHandle.loadGroup(
110
+ batch.map((member) => member.handle),
111
+ { signal: controller.signal, decode: this.decode() ?? ((stream) => stream), headers: this.claims() },
112
+ );
113
+ loading.then(
114
+ () => this.flights.delete(flight),
115
+ () => this.flights.delete(flight),
116
+ );
117
+
118
+ batch.forEach((member, i) =>
119
+ member.start(
120
+ again,
121
+ loading.then((nodes) => nodes[i]),
122
+ ),
123
+ );
124
+ }
125
+
126
+ /** @param {Member} member */
127
+ leave(member) {
128
+ this.members.delete(member);
129
+ this.asked.delete(member);
130
+
131
+ // Abandoned once nobody it was for is left -- the page moved on -- and
132
+ // let go of now: its abort settles later, after the group's last leave
133
+ // has looked to see whether anything is still in flight.
134
+ for (const flight of this.flights) {
135
+ flight.members.delete(member);
136
+ if (flight.members.size === 0) {
137
+ this.flights.delete(flight);
138
+ flight.controller.abort();
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Every group under one <FragmentProvider>, by name. */
145
+ export class FragmentGroups {
146
+ /** @type {Map<string, Group>} */
147
+ #groups = new Map();
148
+ #decode;
149
+ #claims;
150
+
151
+ /**
152
+ * @param {() => ((stream: ReadableStream<Uint8Array>) => unknown) | undefined} decode the provider's, when asked
153
+ * @param {() => Record<string, string>} [claims] the once-shared holds to claim, when asked (ADR 0074)
154
+ */
155
+ constructor(decode, claims = () => ({})) {
156
+ this.#decode = decode;
157
+ this.#claims = claims;
158
+ }
159
+
160
+ /**
161
+ * @param {string} name
162
+ * @param {Member} member
163
+ * @returns {() => void} leave
164
+ */
165
+ join(name, member) {
166
+ let group = this.#groups.get(name);
167
+ if (!group) {
168
+ group = new Group(this.#decode, this.#claims);
169
+ this.#groups.set(name, group);
170
+ }
171
+ group.members.add(member);
172
+
173
+ return () => {
174
+ group.leave(member);
175
+ // Unregistered once empty -- unless a newer group has taken the name.
176
+ if (group.members.size === 0 && group.flights.size === 0 && this.#groups.get(name) === group) {
177
+ this.#groups.delete(name);
178
+ }
179
+ };
180
+ }
181
+
182
+ /** How many groups have members: one that has none is forgotten. */
183
+ get size() {
184
+ return this.#groups.size;
185
+ }
186
+
187
+ /**
188
+ * Asks for the group to load: on the next task, every member that is
189
+ * ready loads, in one request.
190
+ *
191
+ * @param {string} name
192
+ * @param {Member} member the one asking
193
+ * @param {boolean} again a revalidation: loaded members load again too
194
+ */
195
+ request(name, member, again) {
196
+ this.#groups.get(name)?.request(member, again);
197
+ }
198
+ }
@@ -0,0 +1,37 @@
1
+ /** When an action's revalidation is replacing what deferred boundaries show. */
2
+
3
+ export interface RevalidationState {
4
+ /** An action's revalidation of the mounted page is in flight. */
5
+ active: boolean;
6
+ /** Bumped each time one settles having revalidated the page. */
7
+ generation: number;
8
+ /** Bumped by refreshDeferred(): loaded boundaries fetch again, without reloading. */
9
+ quiet: number;
10
+ }
11
+
12
+ export declare const AT_REST: RevalidationState;
13
+
14
+ /**
15
+ * Marks the boundaries on the page as reloading until the returned settle()
16
+ * is called: with `revalidated: true` (the default) once the revalidated page
17
+ * is mounted, so loaded boundaries fetch their fragments again; with `false`
18
+ * when the action ended without revalidating it.
19
+ */
20
+ export declare function beginRevalidation(): (outcome?: { revalidated?: boolean }) => void;
21
+
22
+ /**
23
+ * Asks every loaded boundary on the page to fetch its content again, reloading
24
+ * until the fresh content is on screen -- for an action whose response named
25
+ * the tags it invalidated but re-rendered no route.
26
+ */
27
+ export declare function revalidateDeferred(): void;
28
+
29
+ /**
30
+ * Asks every loaded boundary on the page to fetch its content again without
31
+ * marking it reloading -- for a poll, where nothing an action did changed the
32
+ * page and dimming it on a timer would be motion on a data refresh.
33
+ */
34
+ export declare function refreshDeferred(): void;
35
+
36
+ export declare function revalidationState(): RevalidationState;
37
+ export declare function onRevalidation(listener: () => void): () => void;
@@ -0,0 +1,108 @@
1
+ // When an action's revalidation is replacing what deferred boundaries show.
2
+ //
3
+ // tinvar-spec.md, "Rescue and reloading": "A `reloading` flag lets a boundary
4
+ // dim stale content while an action's revalidation streams in."
5
+ //
6
+ // The router owns actions and the payloads they bring back; the boundaries
7
+ // own what they do about one. This is the seam between them, and it is
8
+ // deliberately narrow: whoever runs an action against the mounted page -- the
9
+ // router, a form helper -- calls beginRevalidation() when it starts, and the
10
+ // returned settle() once the revalidated page is mounted, or the action has
11
+ // ended without revalidating anything.
12
+ //
13
+ // - From begin to settle, every boundary with content on screen is
14
+ // `reloading`: what it shows may be about to change.
15
+ // - On settle({ revalidated: true }), each of them fetches its fragment
16
+ // again -- a re-rendered page hands it a re-issued token for the same
17
+ // content, which it keeps, so nothing else would -- and stays
18
+ // `reloading` until the fresh content is on screen.
19
+ // - On settle({ revalidated: false }), they stop reloading and keep what
20
+ // they show.
21
+ //
22
+ // No React and no DOM, so the router -- which never imports React -- can
23
+ // call it.
24
+
25
+ let active = 0;
26
+ let generation = 0;
27
+ let quiet = 0;
28
+ const listeners = new Set();
29
+
30
+ /** @type {{ active: boolean, generation: number, quiet: number }} */
31
+ let snapshot = Object.freeze({ active: false, generation: 0, quiet: 0 });
32
+
33
+ /** What a server render reads: nothing is being revalidated there. */
34
+ export const AT_REST = Object.freeze({ active: false, generation: 0, quiet: 0 });
35
+
36
+ /**
37
+ * Marks the boundaries on the page as reloading, until the returned settle()
38
+ * is called. Each call needs its own settle; overlapping revalidations keep
39
+ * the boundaries reloading until the last one settles.
40
+ *
41
+ * @returns {(outcome?: { revalidated?: boolean }) => void}
42
+ */
43
+ export function beginRevalidation() {
44
+ active++;
45
+ publish();
46
+
47
+ let settled = false;
48
+ return ({ revalidated = true } = {}) => {
49
+ // Once: a caller settling in both a then and a finally must not end
50
+ // someone else's revalidation.
51
+ if (settled) return;
52
+ settled = true;
53
+ active--;
54
+ if (revalidated) generation++;
55
+ publish();
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Asks every loaded boundary on the page to fetch its content again, as an
61
+ * action's re-render of the page does: reloading until the fresh content is
62
+ * on screen.
63
+ *
64
+ * For an action that re-rendered no route but whose response named the
65
+ * query tags it invalidated. "When an action revalidates a tag, the client
66
+ * re-fetches any mounted fragment that read it" (tinvar-spec.md, "Deferred
67
+ * components") -- and which fragment read which tag is the server's to
68
+ * know, not the client's, so every loaded one fetches again.
69
+ */
70
+ export function revalidateDeferred() {
71
+ beginRevalidation()({ revalidated: true });
72
+ }
73
+
74
+ /**
75
+ * Asks every loaded boundary on the page to fetch its content again, without
76
+ * marking it reloading. For a poll: content that may have moved on, where no
77
+ * action changed anything, and where dimming the page every few seconds
78
+ * would be motion on a data refresh (MER-10.5). A boundary still on its
79
+ * fallback is untouched -- it loads on its own trigger -- and a load is
80
+ * still subject to the boundary's backoff and failure limit.
81
+ */
82
+ export function refreshDeferred() {
83
+ quiet++;
84
+ publish();
85
+ }
86
+
87
+ /**
88
+ * For useSyncExternalStore: the same object until something changes.
89
+ *
90
+ * @returns {{ active: boolean, generation: number, quiet: number }}
91
+ */
92
+ export function revalidationState() {
93
+ return snapshot;
94
+ }
95
+
96
+ /**
97
+ * @param {() => void} listener
98
+ * @returns {() => void} unsubscribe
99
+ */
100
+ export function onRevalidation(listener) {
101
+ listeners.add(listener);
102
+ return () => listeners.delete(listener);
103
+ }
104
+
105
+ function publish() {
106
+ snapshot = Object.freeze({ active: active > 0, generation, quiet });
107
+ for (const listener of listeners) listener();
108
+ }
@@ -0,0 +1,26 @@
1
+ /** When a deferred boundary decides to fetch. */
2
+
3
+ export interface TriggerOptions {
4
+ /** A <Deferred> without one hands it on as undefined. */
5
+ buffer?: number | undefined;
6
+ always?: boolean | undefined;
7
+ /** The scroll container `visible` measures against; the document without one. */
8
+ root?: Element | null;
9
+ }
10
+
11
+ export type Trigger = (element: Element, load: () => void, options?: TriggerOptions) => () => void;
12
+
13
+ export declare const onVisible: Trigger;
14
+ export declare const onIdle: Trigger;
15
+ export declare const onInteraction: Trigger;
16
+ export declare const onManual: Trigger;
17
+
18
+ export declare const TRIGGERS: Record<'visible' | 'idle' | 'interaction' | 'manual', Trigger>;
19
+
20
+ /** An unknown name loads immediately rather than leaving a boundary empty. */
21
+ export declare function installTrigger(
22
+ name: string,
23
+ element: Element,
24
+ load: () => void,
25
+ options?: TriggerOptions,
26
+ ): () => void;
@@ -0,0 +1,172 @@
1
+ // When a deferred boundary decides to fetch.
2
+ //
3
+ // tinvar-spec.md, "Deferred components":
4
+ //
5
+ // visible The placeholder scrolls into view (IntersectionObserver)
6
+ // idle The browser is idle after hydration
7
+ // interaction The user hovers, focuses or clicks inside the placeholder
8
+ // manual Client code calls .load() from a ref
9
+ //
10
+ // Each trigger is a function taking the placeholder element and a callback,
11
+ // and returning a teardown. They are deliberately plain: no React, no shared
12
+ // state, nothing that needs a bundler. That makes them testable in isolation,
13
+ // which is why they live here instead of inside the component.
14
+ //
15
+ // Every trigger fires its callback AT MOST ONCE and tears itself down on the
16
+ // way. A boundary that fetched twice because a user scrolled past it twice
17
+ // would be a self-inflicted request storm, and `always` (refetch each time it
18
+ // becomes visible) is a separate opt-in rather than the default.
19
+
20
+ /** Pixels before the element enters the viewport, per the spec's `buffer`. */
21
+ const DEFAULT_BUFFER = 0;
22
+
23
+ /**
24
+ * `root` is the scroll container to measure against, for an element inside
25
+ * one -- <InfiniteScroll> in a chat panel, say. Without it, the document.
26
+ *
27
+ * @param {Element} element
28
+ * @param {() => void} load
29
+ * @param {{buffer?: number, always?: boolean, root?: Element | null}} [options]
30
+ * @returns {() => void} teardown
31
+ */
32
+ export function onVisible(element, load, options = {}) {
33
+ // A number of pixels, or none. Anything else -- NaN, a string with a unit,
34
+ // Infinity -- made a rootMargin the observer's constructor throws on, and
35
+ // the boundary failed in its effect instead of loading.
36
+ const requested = Number(options.buffer ?? DEFAULT_BUFFER);
37
+ const buffer = Number.isFinite(requested) ? requested : DEFAULT_BUFFER;
38
+
39
+ // No IntersectionObserver means no way to know when this scrolls into view.
40
+ // Loading immediately is the right failure: the content is the point, and a
41
+ // boundary that silently never loads is worse than one that loads early.
42
+ if (typeof IntersectionObserver === 'undefined') {
43
+ load();
44
+ return () => {};
45
+ }
46
+
47
+ let fired = false;
48
+
49
+ const observer = new IntersectionObserver(
50
+ (entries) => {
51
+ // One delivery can carry several records for the element -- it
52
+ // entered, left and entered again between two runs of the observer's
53
+ // task -- and each intersecting one called load. disconnect() stops
54
+ // later deliveries, not the rest of this one. Once per delivery, and
55
+ // without `always` once at all.
56
+ if (fired || !entries.some((entry) => entry.isIntersecting)) return;
57
+
58
+ if (!options.always) {
59
+ fired = true;
60
+ observer.disconnect();
61
+ }
62
+ load();
63
+ },
64
+ {
65
+ // The DOCUMENT as root, not the implicit one. With the implicit root,
66
+ // the spec ignores rootMargin for a target outside the top-level
67
+ // document -- so in any page rendered inside a frame, `buffer` was
68
+ // silently a no-op. Measured in Chromium: a target 50px past the fold
69
+ // with a 200px margin reports not-intersecting under the implicit
70
+ // root and intersecting with root: document. The same in a top-level
71
+ // page, and correct in a frame.
72
+ root: options.root ?? (typeof document === 'undefined' ? null : document),
73
+ rootMargin: `${buffer}px`,
74
+ },
75
+ );
76
+
77
+ observer.observe(element);
78
+
79
+ return () => observer.disconnect();
80
+ }
81
+
82
+ /**
83
+ * The element is unused, but the signature is uniform so TRIGGERS can call
84
+ * every trigger the same way.
85
+ *
86
+ * @param {Element} _element
87
+ * @param {() => void} load
88
+ * @returns {() => void}
89
+ */
90
+ export function onIdle(_element, load) {
91
+ // requestIdleCallback is still missing in Safari. A timeout is the honest
92
+ // fallback: "idle" means "not now, but soon", and a frame or two later is
93
+ // a closer reading than never.
94
+ if (typeof requestIdleCallback !== 'function') {
95
+ const timer = setTimeout(load, 1);
96
+ return () => clearTimeout(timer);
97
+ }
98
+
99
+ const handle = requestIdleCallback(() => load(), { timeout: 2000 });
100
+
101
+ // Guarded the same way the scheduling call is. A polyfill or test shim can
102
+ // provide one without the other, and a bare reference in the teardown
103
+ // throws ReferenceError at exactly the moment a boundary is being removed.
104
+ return () => {
105
+ if (typeof cancelIdleCallback === 'function') cancelIdleCallback(handle);
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @param {Element} element
111
+ * @param {() => void} load
112
+ * @returns {() => void}
113
+ */
114
+ export function onInteraction(element, load) {
115
+ // focusin, not focus: focus does not bubble, so a focusable child would
116
+ // never reach a listener on the placeholder.
117
+ const events = ['pointerenter', 'focusin', 'click'];
118
+
119
+ const fire = () => {
120
+ teardown();
121
+ load();
122
+ };
123
+
124
+ const teardown = () => {
125
+ for (const event of events) element.removeEventListener(event, fire);
126
+ };
127
+
128
+ for (const event of events) element.addEventListener(event, fire, { once: false });
129
+
130
+ return teardown;
131
+ }
132
+
133
+ /**
134
+ * `manual`: nothing watches, the handle's load() is the only trigger.
135
+ *
136
+ * @returns {() => void}
137
+ */
138
+ export function onManual() {
139
+ return () => {};
140
+ }
141
+
142
+ export const TRIGGERS = {
143
+ visible: onVisible,
144
+ idle: onIdle,
145
+ interaction: onInteraction,
146
+ manual: onManual,
147
+ };
148
+
149
+ /**
150
+ * Installs the named trigger.
151
+ *
152
+ * An unknown trigger name is a developer error, and the safe reading is NOT
153
+ * "never load" -- that ships a page with a permanently empty boundary. It
154
+ * loads and says so.
155
+ *
156
+ * @param {string} name
157
+ * @param {Element} element
158
+ * @param {() => void} load
159
+ * @param {{buffer?: number, always?: boolean}} [options]
160
+ * @returns {() => void}
161
+ */
162
+ export function installTrigger(name, element, load, options = {}) {
163
+ const trigger = TRIGGERS[name];
164
+
165
+ if (!trigger) {
166
+ console.error(`[tinvar] Unknown <Deferred load="${name}">; loading immediately.`);
167
+ load();
168
+ return () => {};
169
+ }
170
+
171
+ return trigger(element, load, options);
172
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface DeferredProps {
4
+ /** The server component to render later, by its name in the app's registry. */
5
+ component: string;
6
+ /** Signed, not encrypted: readable in page source, so never a secret. */
7
+ props?: Record<string, unknown>;
8
+ load?: 'visible' | 'idle' | 'interaction' | 'manual';
9
+ fallback?: ReactNode;
10
+ /** Shown beside the content while the boundary fetches it again; useDeferred().fetching in a client component. */
11
+ fetching?: ReactNode;
12
+ /** Shown once the server's render of the fragment throws; reported to PHP, and the page carries on. */
13
+ rescue?: ReactNode;
14
+ /** Pixels before the boundary is visible to load it: an IntersectionObserver rootMargin. */
15
+ buffer?: number;
16
+ /** Fetch again each time the boundary comes back into view, with the usual backoff and limits. */
17
+ always?: boolean;
18
+ /** Boundaries on a page sharing a group load together, in one request. */
19
+ group?: string;
20
+ id?: string;
21
+ }
22
+
23
+ /** A boundary whose subtree renders after the page has loaded, on a trigger. */
24
+ export declare function Deferred(props: DeferredProps): Promise<ReactNode>;
@@ -0,0 +1,100 @@
1
+ // <Deferred>: a server component that renders a placeholder, not its children.
2
+ //
3
+ // tinvar-spec.md, "Deferred components":
4
+ //
5
+ // "During the page render, the renderer does not render the children. It
6
+ // emits a placeholder carrying a fragment token: component ID, props,
7
+ // route, panel, build, session hash and expiry under one MAC."
8
+ //
9
+ // "Does not render the children" is the whole value. Suspense streams work
10
+ // that is already happening; Deferred skips the work entirely until someone
11
+ // needs it. So this component must never touch `children` -- doing so would
12
+ // run the very queries it exists to avoid, and the page would pay for a
13
+ // subtree nobody has asked to see.
14
+ //
15
+ // That is why the boundary is declared by `component` and `props` rather than
16
+ // by the children themselves: PHP renders the named component when the
17
+ // fragment is requested, and the children are only ever a type-level hint of
18
+ // what will appear there.
19
+
20
+ import { mintFragmentToken } from './server.js';
21
+ import { DeferredBoundary } from './deferred-boundary.js';
22
+
23
+ /**
24
+ * `props` are SIGNED, not encrypted.
25
+ *
26
+ * The token looks opaque but is `v1.<base64url(json)>.<mac>`, so anything in
27
+ * props is readable from page source, a screenshot, or a page pasted into a
28
+ * bug report -- with no key and no session. The spec says props "must not
29
+ * contain secrets"; this is the reminder, because a developer looking at the
30
+ * rendered attribute has no reason to guess it is plaintext.
31
+ *
32
+ * `rescue` is shown instead of the fragment once the server's render of it
33
+ * throws: the failure is reported to PHP's exception handler and the page
34
+ * carries on (tinvar-spec.md, "Rescue and reloading"). An element, like
35
+ * `fallback`, rendered on either side; a client component in it reads the
36
+ * digest and a retry from useDeferred().
37
+ *
38
+ * `buffer`, `always` and `fetching` are Inertia's <WhenVisible>: load that
39
+ * many pixels before the boundary is visible; fetch again each time it
40
+ * comes back into view, paced by the same backoff and limits as any load;
41
+ * and show `fetching` beside the content while it does. `fetching` is an
42
+ * element rather than Inertia's render function, which could not cross from
43
+ * this server component to the client tree; a client component reads the
44
+ * same flag from useDeferred().
45
+ *
46
+ * `group` is Inertia's `Inertia::defer($fn, 'group')`: boundaries on a page
47
+ * sharing one load together, in one request the server renders as one --
48
+ * whichever is triggered first loads them all (ADR 0018).
49
+ *
50
+ * @param {{
51
+ * component: string,
52
+ * props?: Record<string, unknown>,
53
+ * load?: 'visible' | 'idle' | 'interaction' | 'manual',
54
+ * fallback?: unknown,
55
+ * fetching?: unknown,
56
+ * rescue?: unknown,
57
+ * buffer?: number,
58
+ * always?: boolean,
59
+ * group?: string,
60
+ * id?: string,
61
+ * }} options
62
+ */
63
+ export async function Deferred({
64
+ component,
65
+ props = {},
66
+ load = 'visible',
67
+ fallback = null,
68
+ fetching,
69
+ rescue,
70
+ buffer,
71
+ always,
72
+ group,
73
+ id,
74
+ }) {
75
+ if (typeof component !== 'string' || component === '') {
76
+ throw new Error('<Deferred> needs a `component` naming the server component to render later.');
77
+ }
78
+
79
+ const token = await mintFragmentToken(component, props);
80
+
81
+ // The boundary is a client component: React renders the fallback now and
82
+ // the fragment once the trigger fires, through Suspense. It carries the
83
+ // token and nothing else. The token is readable in page source by design
84
+ // -- it is not a capability, and the fragment endpoint re-runs the
85
+ // originating route's authorization on use -- but it is still per-session,
86
+ // so the markup it sits in is never cacheable across visitors.
87
+ return (
88
+ <DeferredBoundary
89
+ token={token}
90
+ load={load}
91
+ buffer={buffer}
92
+ always={always}
93
+ group={group}
94
+ id={id}
95
+ fallback={fallback}
96
+ fetching={fetching}
97
+ rescue={rescue}
98
+ />
99
+ );
100
+ }