@uniflowed/router 0.0.0-alpha.18 → 0.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +187 -5
- package/handler.js +15 -6
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +87 -17
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +72 -17
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: reading the payload's rows out of a
|
|
4
|
+
// streaming document.
|
|
5
|
+
//
|
|
6
|
+
// `./payload.js` is the format and knows nothing about a browser. This is the
|
|
7
|
+
// other half: row 0 arrives in the document's `<script id="__uf_data">` and
|
|
8
|
+
// every later row arrives in a `<script data-uf-row="n">` React inserts when
|
|
9
|
+
// the value it holds resolves — which is *after* the parser reached the end of
|
|
10
|
+
// what had been sent, and usually after `hydrateRoot` has already been called.
|
|
11
|
+
// So a reader that only looked once would see the rows that happened to be
|
|
12
|
+
// early and wait forever for the rest.
|
|
13
|
+
//
|
|
14
|
+
// # A `MutationObserver`, and not an executable script
|
|
15
|
+
//
|
|
16
|
+
// The obvious mechanism is React's own: an inline `<script>` that pushes into
|
|
17
|
+
// a global. `internal/runtime.js` explains at length why uf's data element is
|
|
18
|
+
// `application/json` instead — "a script that is executed is a script a
|
|
19
|
+
// content security policy has to allow" — and the payload inherits the whole
|
|
20
|
+
// of that argument, several times over: there is now one element per deferred
|
|
21
|
+
// value rather than one per document, and each one carries application data
|
|
22
|
+
// that a `script` element with no type would hand to the JavaScript parser.
|
|
23
|
+
//
|
|
24
|
+
// The cost of keeping that property is that nothing calls uf when a row lands,
|
|
25
|
+
// so uf has to watch. A `MutationObserver` on the document, `childList` and
|
|
26
|
+
// `subtree`, is the whole of it. It is installed before `hydrateRoot` and
|
|
27
|
+
// disconnects itself the moment the last row the model named has arrived, so a
|
|
28
|
+
// page with no deferred values never has one and a page with three has one for
|
|
29
|
+
// as long as its slowest value takes.
|
|
30
|
+
//
|
|
31
|
+
// # Why the observer beats React to the row
|
|
32
|
+
//
|
|
33
|
+
// It has to, or a boundary that the server completed would be hydrated while
|
|
34
|
+
// this side still thought the value was pending. It does, for two reasons that
|
|
35
|
+
// are each sufficient. React writes a completed boundary's content into a
|
|
36
|
+
// hidden `<div>` *before* the inline script that moves it into place, so the
|
|
37
|
+
// row element is in the document one mutation earlier than React's own
|
|
38
|
+
// completion path; and a `MutationObserver` callback is a microtask, while
|
|
39
|
+
// React's hydration of a completed boundary is scheduled work in a later task.
|
|
40
|
+
//
|
|
41
|
+
// If it ever lost that race the failure is still bounded rather than wrong:
|
|
42
|
+
// `use` on a pending promise suspends, React keeps the boundary's fallback for
|
|
43
|
+
// one more microtask, and the row resolves it. What must not happen — and
|
|
44
|
+
// cannot, since both sides render the row element from the same value through
|
|
45
|
+
// the same `payloadJson` — is the two sides writing different bytes into it.
|
|
46
|
+
//
|
|
47
|
+
// # Rows nobody asked for
|
|
48
|
+
//
|
|
49
|
+
// Ignored. The ids that matter are the ones row 0 referred to, `resolve` is
|
|
50
|
+
// what records them, and an element carrying any other id is a document that
|
|
51
|
+
// says more than its model does. Refusing the page over it would be a
|
|
52
|
+
// hydration that fails because of something no component rendered; dropping it
|
|
53
|
+
// is the reading this module can defend.
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
type PayloadRowMessage,
|
|
57
|
+
PAYLOAD_ROW_ATTRIBUTE,
|
|
58
|
+
PayloadRowError,
|
|
59
|
+
PayloadValueError,
|
|
60
|
+
parseRowMessage,
|
|
61
|
+
} from "./payload.js";
|
|
62
|
+
|
|
63
|
+
/** The document half of a payload: the promises, and the watch that fills them. */
|
|
64
|
+
export type PayloadReader = {|
|
|
65
|
+
/**
|
|
66
|
+
* The value of row `id`, as a promise.
|
|
67
|
+
*
|
|
68
|
+
* The `RowResolver` `decodePayload` is handed. Calling it is what tells this
|
|
69
|
+
* reader that the id is one the page is waiting for.
|
|
70
|
+
*/
|
|
71
|
+
readonly resolve: (id: number) => Promise<mixed>,
|
|
72
|
+
/**
|
|
73
|
+
* Start reading. Applies every row already in the document, then watches for
|
|
74
|
+
* the rest; returns without waiting for any of them.
|
|
75
|
+
*/
|
|
76
|
+
readonly watch: () => void,
|
|
77
|
+
/** Stop watching, whether or not every row arrived. */
|
|
78
|
+
readonly stop: () => void,
|
|
79
|
+
|};
|
|
80
|
+
|
|
81
|
+
/** The parts of a `Document` this module uses, so it needs no DOM lib. */
|
|
82
|
+
type DocumentLike = interface {
|
|
83
|
+
readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
|
|
84
|
+
readonly documentElement: mixed,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The parts of an `Element` this module uses. */
|
|
88
|
+
type ElementLike = interface {
|
|
89
|
+
readonly getAttribute: (name: string) => string | null,
|
|
90
|
+
readonly textContent: string | null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** One row the page is waiting for. */
|
|
94
|
+
type Slot = {|
|
|
95
|
+
readonly promise: Promise<mixed>,
|
|
96
|
+
readonly settle: (message: PayloadRowMessage) => void,
|
|
97
|
+
arrived: boolean,
|
|
98
|
+
|};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A reader over one document.
|
|
102
|
+
*
|
|
103
|
+
* `observe` is passed in rather than reached for so that this module needs no
|
|
104
|
+
* `MutationObserver` global to be *loaded* — the tests drive it with a
|
|
105
|
+
* document they mutate by hand, and a runtime without the constructor gets a
|
|
106
|
+
* reader that reads what is already there and never watches, which is exactly
|
|
107
|
+
* what a prerendered document needs.
|
|
108
|
+
*/
|
|
109
|
+
export function createPayloadReader(
|
|
110
|
+
document: DocumentLike,
|
|
111
|
+
observe?: ?(callback: () => void) => (() => void) | null,
|
|
112
|
+
): PayloadReader {
|
|
113
|
+
const slots: Map<number, Slot> = new Map();
|
|
114
|
+
let disconnect: (() => void) | null = null;
|
|
115
|
+
let watching = false;
|
|
116
|
+
|
|
117
|
+
function slotFor(id: number): Slot {
|
|
118
|
+
const existing = slots.get(id);
|
|
119
|
+
if (existing != null) {
|
|
120
|
+
return existing;
|
|
121
|
+
}
|
|
122
|
+
let settle: (message: PayloadRowMessage) => void = () => {};
|
|
123
|
+
const promise = new Promise<mixed>((fulfil, reject) => {
|
|
124
|
+
settle = (message) => {
|
|
125
|
+
if (message.error !== undefined) {
|
|
126
|
+
// A `PayloadRowError` rather than a plain one, carrying the row's
|
|
127
|
+
// exact text: the page's error boundary sees an `Error` either way,
|
|
128
|
+
// and `internal/runtime.js` needs the text back verbatim when it
|
|
129
|
+
// re-renders this row's element. See `PayloadRowError`.
|
|
130
|
+
reject(new PayloadRowError(message.error));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
fulfil(message.value);
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
// Marked handled the moment it exists. A row that says the value failed
|
|
137
|
+
// rejects this promise, and whether anything is listening by then depends
|
|
138
|
+
// on whether React has rendered the row's element yet — so without this an
|
|
139
|
+
// ordinary loader failure would arrive as an unhandled rejection, which
|
|
140
|
+
// recent Node turns into an exit.
|
|
141
|
+
promise.catch(() => {});
|
|
142
|
+
const slot: Slot = { promise, settle, arrived: false };
|
|
143
|
+
slots.set(id, slot);
|
|
144
|
+
return slot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function apply(element: ElementLike): void {
|
|
148
|
+
const attribute = element.getAttribute(PAYLOAD_ROW_ATTRIBUTE);
|
|
149
|
+
if (attribute == null) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const id = Number.parseInt(attribute, 10);
|
|
153
|
+
if (!Number.isSafeInteger(id)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const slot = slots.get(id);
|
|
157
|
+
// A row for an id the model never referred to, or one already applied. See
|
|
158
|
+
// the header: neither is this reader's to complain about.
|
|
159
|
+
if (slot == null || slot.arrived) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
slot.arrived = true;
|
|
163
|
+
try {
|
|
164
|
+
slot.settle(parseRowMessage(element.textContent ?? "", id));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
slot.settle({
|
|
167
|
+
error:
|
|
168
|
+
error instanceof PayloadValueError
|
|
169
|
+
? error.message
|
|
170
|
+
: `@uniflowed/router: row ${String(id)} could not be read.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (finished()) {
|
|
174
|
+
stop();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sweep(): void {
|
|
179
|
+
for (const element of document.querySelectorAll(`script[${PAYLOAD_ROW_ATTRIBUTE}]`)) {
|
|
180
|
+
apply(element);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finished(): boolean {
|
|
185
|
+
for (const slot of slots.values()) {
|
|
186
|
+
if (!slot.arrived) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stop(): void {
|
|
194
|
+
watching = false;
|
|
195
|
+
const off = disconnect;
|
|
196
|
+
disconnect = null;
|
|
197
|
+
if (off != null) {
|
|
198
|
+
off();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
resolve(id: number): Promise<mixed> {
|
|
204
|
+
const slot = slotFor(id);
|
|
205
|
+
// A reference discovered after the watch started — a client navigation
|
|
206
|
+
// decoding a payload of its own — still gets whatever is already in the
|
|
207
|
+
// document, so the order the two calls happen in does not matter.
|
|
208
|
+
if (watching) {
|
|
209
|
+
sweep();
|
|
210
|
+
}
|
|
211
|
+
return slot.promise;
|
|
212
|
+
},
|
|
213
|
+
watch(): void {
|
|
214
|
+
if (watching || slots.size === 0) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
watching = true;
|
|
218
|
+
sweep();
|
|
219
|
+
if (finished() || observe == null) {
|
|
220
|
+
watching = false;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
disconnect = observe(sweep);
|
|
224
|
+
},
|
|
225
|
+
stop,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The `observe` a browser has.
|
|
231
|
+
*
|
|
232
|
+
* Separate from [`createPayloadReader`] so the reader itself stays testable
|
|
233
|
+
* without one, and so the `MutationObserver` global is touched in exactly one
|
|
234
|
+
* place — a runtime that has no such constructor gets `null` and a reader that
|
|
235
|
+
* reads the document once, which is the right answer for a prerendered file
|
|
236
|
+
* where every row is already in it.
|
|
237
|
+
*/
|
|
238
|
+
export function domObserver(
|
|
239
|
+
document: DocumentLike,
|
|
240
|
+
): ((callback: () => void) => (() => void) | null) | null {
|
|
241
|
+
if (typeof MutationObserver === "undefined") {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const root = document.documentElement;
|
|
245
|
+
if (root == null) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return (callback) => {
|
|
249
|
+
const observer = new MutationObserver(() => {
|
|
250
|
+
callback();
|
|
251
|
+
});
|
|
252
|
+
// $FlowFixMe[incompatible-call] `documentElement` is a `Node`; the interface above says only what is read.
|
|
253
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
254
|
+
return () => {
|
|
255
|
+
observer.disconnect();
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
}
|