@govela/runtime 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/package.json +19 -4
- package/runtime-blocks.js +875 -0
- package/runtime-hydrate.js +415 -0
- package/runtime.js +562 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vela hydration (docs/HYDRATION_ARCHITECTURE.md §4, §6, §7, §8.3).
|
|
3
|
+
*
|
|
4
|
+
* hydrate() adopts server-rendered HTML with the same compiled fragments
|
|
5
|
+
* that create DOM: it runs them with a hydrate cursor, which takes the next
|
|
6
|
+
* server node where the create cursor would make one (C-4.1, C-4.2). The
|
|
7
|
+
* payload stream that follows the root range restores component state in
|
|
8
|
+
* render order (S-6.7, S-6.8). Only builds that hydrate ship this module.
|
|
9
|
+
*
|
|
10
|
+
* Mismatches (X-7.1) follow a policy (§7.3): `repair` (the development
|
|
11
|
+
* default) remounts the nearest enclosing block with a create cursor, or
|
|
12
|
+
* the whole root (X-7.3), and warns; `fail` releases everything adopted
|
|
13
|
+
* without touching the DOM and throws (X-7.4); production builds repair
|
|
14
|
+
* with one console.error and no value verification.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { $$isDev } from './runtime.js';
|
|
18
|
+
import { $$owner, $$setHydration, $$createCursor, $$listen } from './runtime-blocks.js';
|
|
19
|
+
|
|
20
|
+
export const MISMATCH = 'VELA-HYDRATION-MISMATCH';
|
|
21
|
+
|
|
22
|
+
// The default verification policy of development builds: repair, fail, or
|
|
23
|
+
// off (--hydration-verify, V-11.3c). The compiler rewrites this line.
|
|
24
|
+
const VERIFY = 'repair';
|
|
25
|
+
|
|
26
|
+
function describe(node) {
|
|
27
|
+
if (!node) return 'the end of the parent';
|
|
28
|
+
if (node.nodeType === 1) return `<${node.localName}>`;
|
|
29
|
+
if (node.nodeType === 3) return `text ${JSON.stringify(node.data)}`;
|
|
30
|
+
if (node.nodeType === 8) return `<!--${node.data}-->`;
|
|
31
|
+
return `node type ${node.nodeType}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mismatch(message, kind = 'structure') {
|
|
35
|
+
const error = new Error(`${MISMATCH}: ${message}`);
|
|
36
|
+
error.code = MISMATCH;
|
|
37
|
+
error.kind = kind;
|
|
38
|
+
return error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// formatReport writes a report as V-11.3b shows it:
|
|
42
|
+
// [vela] hydration mismatch at src/X.vela:14:9 (site 7, host formatCurrency)
|
|
43
|
+
// server: "a" client: "b"
|
|
44
|
+
function formatReport(report) {
|
|
45
|
+
let text = '[vela] hydration mismatch';
|
|
46
|
+
if (report.source) {
|
|
47
|
+
text += ` at ${report.source} (site ${report.site}${report.host ? `, host ${report.host}` : ''})`;
|
|
48
|
+
}
|
|
49
|
+
if ('server' in report) {
|
|
50
|
+
return `${text}\n server: ${JSON.stringify(report.server)} client: ${JSON.stringify(report.client)}`;
|
|
51
|
+
}
|
|
52
|
+
return `${text}: ${report.message.replace(`${MISMATCH}: `, '')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isMismatch(error) {
|
|
56
|
+
return error && error.code === MISMATCH;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A lone surrogate is written to HTML as U+FFFD; it compares equal (T-6.3b).
|
|
60
|
+
function htmlText(value) {
|
|
61
|
+
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// sameAttribute compares an attribute value before and after a client
|
|
65
|
+
// write; style values are compared as declarations.
|
|
66
|
+
function sameAttribute(name, before, after) {
|
|
67
|
+
if (before === after) return true;
|
|
68
|
+
if (name !== 'style' || before === null || after === null) return false;
|
|
69
|
+
const probe = document.createElement('div');
|
|
70
|
+
probe.setAttribute('style', before);
|
|
71
|
+
const normalized = probe.style.cssText;
|
|
72
|
+
probe.setAttribute('style', after);
|
|
73
|
+
return normalized === probe.style.cssText;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// hydrateCursor walks server DOM from position pos ({ parent, next, stack,
|
|
77
|
+
// owners }), which every cursor of one root shares.
|
|
78
|
+
function hydrateCursor(pos, owner, session) {
|
|
79
|
+
const cursor = {
|
|
80
|
+
owner,
|
|
81
|
+
hydrating: true,
|
|
82
|
+
sub(childOwner) {
|
|
83
|
+
pos.owners.push(childOwner);
|
|
84
|
+
return hydrateCursor(pos, childOwner, session);
|
|
85
|
+
},
|
|
86
|
+
expect(ok, message) {
|
|
87
|
+
if (!ok) {
|
|
88
|
+
const error = mismatch(message);
|
|
89
|
+
error.site = session.site;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
// at names the next adopted site (V-11.3b): [id, source, host].
|
|
94
|
+
at(site) {
|
|
95
|
+
session.site = site;
|
|
96
|
+
},
|
|
97
|
+
element(tag) {
|
|
98
|
+
const node = pos.next;
|
|
99
|
+
cursor.expect(node && node.nodeType === 1 && node.localName === tag, `expected <${tag}>, found ${describe(node)}`);
|
|
100
|
+
pos.next = node.nextSibling;
|
|
101
|
+
return node;
|
|
102
|
+
},
|
|
103
|
+
// A text run with an empty value is not present in parsed HTML; it is
|
|
104
|
+
// inserted, which is not a mismatch (C-4.3).
|
|
105
|
+
text(value) {
|
|
106
|
+
const node = pos.next;
|
|
107
|
+
const site = session.take();
|
|
108
|
+
if (node && node.nodeType === 3) {
|
|
109
|
+
pos.next = node.nextSibling;
|
|
110
|
+
if (session.verify && node.data !== htmlText(value)) {
|
|
111
|
+
session.value(`text is ${JSON.stringify(node.data)}, the client computes ${JSON.stringify(value)}`, () => {
|
|
112
|
+
node.data = value;
|
|
113
|
+
}, { site, server: node.data, client: value });
|
|
114
|
+
}
|
|
115
|
+
return node;
|
|
116
|
+
}
|
|
117
|
+
cursor.expect(value === '', `expected text ${JSON.stringify(value)}, found ${describe(node)}`);
|
|
118
|
+
const text = document.createTextNode('');
|
|
119
|
+
pos.parent.insertBefore(text, node);
|
|
120
|
+
return text;
|
|
121
|
+
},
|
|
122
|
+
comment() {
|
|
123
|
+
throw new Error('vela: a fragment created an anchor comment while hydrating');
|
|
124
|
+
},
|
|
125
|
+
static(template) {
|
|
126
|
+
const nodes = [];
|
|
127
|
+
const expected = template.content.childNodes;
|
|
128
|
+
for (let i = 0; i < expected.length; i++) {
|
|
129
|
+
const node = pos.next;
|
|
130
|
+
const want = expected[i];
|
|
131
|
+
cursor.expect(node && node.nodeType === want.nodeType && (want.nodeType !== 1 || node.localName === want.localName),
|
|
132
|
+
`expected ${describe(want)}, found ${describe(node)}`);
|
|
133
|
+
nodes.push(node);
|
|
134
|
+
pos.next = node.nextSibling;
|
|
135
|
+
}
|
|
136
|
+
return nodes;
|
|
137
|
+
},
|
|
138
|
+
enter(element) {
|
|
139
|
+
pos.stack.push([pos.parent, pos.next]);
|
|
140
|
+
pos.parent = element;
|
|
141
|
+
pos.next = element.firstChild;
|
|
142
|
+
},
|
|
143
|
+
exit() {
|
|
144
|
+
cursor.expect(pos.next === null, `unexpected ${describe(pos.next)} at the end of <${pos.parent.localName}>`);
|
|
145
|
+
[pos.parent, pos.next] = pos.stack.pop();
|
|
146
|
+
},
|
|
147
|
+
// Initial DOM writes are skipped: the server wrote the same values
|
|
148
|
+
// (F-2.2). Development builds run them and report a changed attribute
|
|
149
|
+
// as a value mismatch (X-7.2).
|
|
150
|
+
init(write) {
|
|
151
|
+
const site = session.take();
|
|
152
|
+
if (!session.observer) return;
|
|
153
|
+
session.observer.takeRecords();
|
|
154
|
+
write();
|
|
155
|
+
for (const record of session.observer.takeRecords()) {
|
|
156
|
+
if (record.type !== 'attributes') continue;
|
|
157
|
+
const now = record.target.getAttribute(record.attributeName);
|
|
158
|
+
if (!sameAttribute(record.attributeName, record.oldValue, now)) {
|
|
159
|
+
session.value(`<${record.target.localName}> ${record.attributeName} is ${JSON.stringify(record.oldValue)}, the client computes ${JSON.stringify(now)}`, null, { site, server: record.oldValue, client: now });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
// adopt takes the live value of a bound form control: a value the user
|
|
164
|
+
// entered before hydration goes into state through the setter (C-4.5).
|
|
165
|
+
adopt(element, property, read, write) {
|
|
166
|
+
const live = element[property];
|
|
167
|
+
let state = read();
|
|
168
|
+
if (property === 'value') state = state == null ? '' : String(state);
|
|
169
|
+
if (live !== state) session.after.push(() => write(live));
|
|
170
|
+
},
|
|
171
|
+
listen(target, event, handler, options) {
|
|
172
|
+
$$listen(owner, target, event, handler, options);
|
|
173
|
+
},
|
|
174
|
+
// block adopts a block; under repair a mismatch inside it remounts the
|
|
175
|
+
// block from its server range with a create cursor.
|
|
176
|
+
block(kind, ...args) {
|
|
177
|
+
const site = session.take();
|
|
178
|
+
if (session.policy === 'fail') {
|
|
179
|
+
try {
|
|
180
|
+
return kind(cursor, ...args);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (isMismatch(error) && !error.site) error.site = site;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const start = pos.next;
|
|
187
|
+
const mark = pos.owners.length;
|
|
188
|
+
try {
|
|
189
|
+
return kind(cursor, ...args);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!isMismatch(error) || !start || start.nodeType !== 8 || !start.data.startsWith('[') || start.parentNode !== pos.parent) throw error;
|
|
192
|
+
let end = null;
|
|
193
|
+
let depth = 0;
|
|
194
|
+
for (let node = start; node; node = node.nextSibling) {
|
|
195
|
+
if (node.nodeType !== 8) continue;
|
|
196
|
+
if (node.data.startsWith('[')) depth++;
|
|
197
|
+
else if (node.data === ']' && --depth === 0) {
|
|
198
|
+
end = node;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!end) throw error;
|
|
203
|
+
if (!error.site) error.site = site;
|
|
204
|
+
session.report(error);
|
|
205
|
+
for (const adopted of pos.owners.splice(mark)) adopted.release();
|
|
206
|
+
// Entries of the abandoned range can no longer be matched to
|
|
207
|
+
// instances; later instances initialize as on the client.
|
|
208
|
+
session.lost = true;
|
|
209
|
+
const after = end.nextSibling;
|
|
210
|
+
for (let node = start; node !== after;) {
|
|
211
|
+
const next = node.nextSibling;
|
|
212
|
+
node.remove();
|
|
213
|
+
node = next;
|
|
214
|
+
}
|
|
215
|
+
pos.next = after;
|
|
216
|
+
return kind($$createCursor(pos.parent, after, owner), ...args);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
// open consumes a range start marker and returns its state token.
|
|
220
|
+
open() {
|
|
221
|
+
const node = pos.next;
|
|
222
|
+
cursor.expect(node && node.nodeType === 8 && node.data.startsWith('['), `expected a block range, found ${describe(node)}`);
|
|
223
|
+
pos.next = node.nextSibling;
|
|
224
|
+
return node.data.slice(1);
|
|
225
|
+
},
|
|
226
|
+
// close consumes a range end marker, which becomes the block's anchor.
|
|
227
|
+
close() {
|
|
228
|
+
const node = pos.next;
|
|
229
|
+
cursor.expect(node && node.nodeType === 8 && node.data === ']', `expected the end of a block range, found ${describe(node)}`);
|
|
230
|
+
pos.next = node.nextSibling;
|
|
231
|
+
return node;
|
|
232
|
+
},
|
|
233
|
+
// mount adopts a fragment at the current position. Its range is the
|
|
234
|
+
// adopted nodes, or null when it has none.
|
|
235
|
+
mount(fragmentFn, scope, fragmentOwner) {
|
|
236
|
+
const mountOwner = fragmentOwner || $$owner(null);
|
|
237
|
+
pos.owners.push(mountOwner);
|
|
238
|
+
const before = pos.next ? pos.next.previousSibling : pos.parent.lastChild;
|
|
239
|
+
const handle = fragmentFn(hydrateCursor(pos, mountOwner, session), scope);
|
|
240
|
+
const start = before ? before.nextSibling : pos.parent.firstChild;
|
|
241
|
+
const range = start && start !== pos.next
|
|
242
|
+
? { start, end: pos.next ? pos.next.previousSibling : pos.parent.lastChild }
|
|
243
|
+
: null;
|
|
244
|
+
return { owner: mountOwner, handle, range, scope };
|
|
245
|
+
},
|
|
246
|
+
// mountRow adopts an #each row inside its own range (M-5.2).
|
|
247
|
+
mountRow(fragmentFn, scope) {
|
|
248
|
+
const start = pos.next;
|
|
249
|
+
const token = cursor.open();
|
|
250
|
+
cursor.expect(token === '', `expected an #each row, found <!--[${token}-->`);
|
|
251
|
+
const mounted = cursor.mount(fragmentFn, scope);
|
|
252
|
+
mounted.range = { start, end: cursor.close() };
|
|
253
|
+
return mounted;
|
|
254
|
+
},
|
|
255
|
+
entry(kind) {
|
|
256
|
+
return session.entry(kind);
|
|
257
|
+
},
|
|
258
|
+
afterCommit(callback) {
|
|
259
|
+
session.after.push(callback);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
return cursor;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function createSession(entries, options, container) {
|
|
266
|
+
let index = 0;
|
|
267
|
+
const dev = $$isDev();
|
|
268
|
+
const requested = options.policy || VERIFY;
|
|
269
|
+
// off repairs structural mismatches without comparing values.
|
|
270
|
+
const policy = dev ? (requested === 'off' ? 'repair' : requested) : 'production';
|
|
271
|
+
let reported = false;
|
|
272
|
+
const session = {
|
|
273
|
+
cursors: [],
|
|
274
|
+
after: [],
|
|
275
|
+
entries,
|
|
276
|
+
policy,
|
|
277
|
+
lost: false,
|
|
278
|
+
verify: dev && policy !== 'production' && requested !== 'off',
|
|
279
|
+
observer: null,
|
|
280
|
+
site: null,
|
|
281
|
+
// take returns the site named by the last at() and clears it.
|
|
282
|
+
take() {
|
|
283
|
+
const site = session.site;
|
|
284
|
+
session.site = null;
|
|
285
|
+
return site;
|
|
286
|
+
},
|
|
287
|
+
next(kind) {
|
|
288
|
+
if (session.lost) return null;
|
|
289
|
+
const entry = entries[index++];
|
|
290
|
+
if (!entry || entry[0] !== kind) {
|
|
291
|
+
throw mismatch(`expected a payload "${kind}" entry, found ${entry ? JSON.stringify(entry[0]) : 'the end of the stream'}`, 'stream');
|
|
292
|
+
}
|
|
293
|
+
return entry;
|
|
294
|
+
},
|
|
295
|
+
// state returns the restored state of a component instance (S-6.4,
|
|
296
|
+
// S-6.8): omitted bindings are left out so that their initializers run.
|
|
297
|
+
state(component) {
|
|
298
|
+
const entry = session.next('s');
|
|
299
|
+
if (!entry) return null;
|
|
300
|
+
if (entry[1] !== component) throw mismatch(`expected the state of <${component}>, found <${entry[1]}>`, 'stream');
|
|
301
|
+
const state = Object.assign({}, entry[2]);
|
|
302
|
+
for (const name of (entry[3] && entry[3].omitted) || []) delete state[name];
|
|
303
|
+
return state;
|
|
304
|
+
},
|
|
305
|
+
entry(kind) {
|
|
306
|
+
const entry = session.next(kind);
|
|
307
|
+
return entry ? entry[2] : null;
|
|
308
|
+
},
|
|
309
|
+
done() {
|
|
310
|
+
return session.lost || index === entries.length;
|
|
311
|
+
},
|
|
312
|
+
// report passes every mismatch to onMismatch (X-7.5) and logs it.
|
|
313
|
+
report(error) {
|
|
314
|
+
const report = { code: MISMATCH, kind: error.kind || 'structure', message: error.message, policy };
|
|
315
|
+
// Source position, site ID, host symbol, and both values (V-11.3b).
|
|
316
|
+
const site = error.site;
|
|
317
|
+
if (site) Object.assign(report, { site: site[0], source: site[1], host: site[2] || undefined });
|
|
318
|
+
if ('server' in error) Object.assign(report, { server: error.server, client: error.client });
|
|
319
|
+
if (options.onMismatch) options.onMismatch(report);
|
|
320
|
+
if (policy === 'production') {
|
|
321
|
+
if (!reported) console.error(MISMATCH);
|
|
322
|
+
reported = true;
|
|
323
|
+
} else if (policy === 'repair') {
|
|
324
|
+
console.warn(formatReport(report));
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
// value handles a value mismatch: fail throws, repair writes the client
|
|
328
|
+
// value (or keeps the written one) and warns.
|
|
329
|
+
value(message, repair, details) {
|
|
330
|
+
const error = mismatch(message, 'value');
|
|
331
|
+
if (details) {
|
|
332
|
+
error.site = details.site;
|
|
333
|
+
error.server = details.server;
|
|
334
|
+
error.client = details.client;
|
|
335
|
+
}
|
|
336
|
+
if (policy === 'fail') throw error;
|
|
337
|
+
session.report(error);
|
|
338
|
+
if (repair) repair();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
// Attribute verification observes the writes of init(); text needs no
|
|
342
|
+
// observer.
|
|
343
|
+
const Observer = session.verify && (globalThis.MutationObserver || (document.defaultView && document.defaultView.MutationObserver));
|
|
344
|
+
if (Observer) {
|
|
345
|
+
session.observer = new Observer(() => {});
|
|
346
|
+
session.observer.observe(container, { subtree: true, attributes: true, attributeOldValue: true });
|
|
347
|
+
}
|
|
348
|
+
return session;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// hydrate adopts the root range and payload script that a Go renderer
|
|
352
|
+
// wrote into container (A-8.7) and returns the component instance, which
|
|
353
|
+
// then behaves exactly like one created by the factory.
|
|
354
|
+
//
|
|
355
|
+
// options: policy ('repair' | 'fail' | 'off'; production builds use their
|
|
356
|
+
// own policy), onMismatch(report) (X-7.5), slots.
|
|
357
|
+
export function hydrate(Component, container, options = {}) {
|
|
358
|
+
let script = null;
|
|
359
|
+
for (const node of container.childNodes) {
|
|
360
|
+
if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) script = node;
|
|
361
|
+
}
|
|
362
|
+
if (!script) throw mismatch('the container has no payload script (data-vela-root)');
|
|
363
|
+
const payload = JSON.parse(script.textContent);
|
|
364
|
+
if (payload.v !== 3) throw mismatch(`unsupported payload version ${payload.v}`);
|
|
365
|
+
const props = payload.props || {};
|
|
366
|
+
|
|
367
|
+
let first = container.firstChild;
|
|
368
|
+
while (first && first.nodeType === 3 && first.data.trim() === '') first = first.nextSibling;
|
|
369
|
+
const pos = { parent: container, next: first, stack: [], owners: [] };
|
|
370
|
+
const session = createSession(payload.entries || [], options, container);
|
|
371
|
+
const owner = $$owner(null);
|
|
372
|
+
pos.owners.push(owner);
|
|
373
|
+
const cursor = hydrateCursor(pos, owner, session);
|
|
374
|
+
const previous = $$setHydration(session);
|
|
375
|
+
let instance = null;
|
|
376
|
+
let failure = null;
|
|
377
|
+
try {
|
|
378
|
+
cursor.open();
|
|
379
|
+
session.cursors.push(cursor);
|
|
380
|
+
try {
|
|
381
|
+
instance = Component(container, props, options.slots || {});
|
|
382
|
+
} finally {
|
|
383
|
+
session.cursors.pop();
|
|
384
|
+
}
|
|
385
|
+
cursor.close();
|
|
386
|
+
cursor.expect(pos.next === script || (pos.next && pos.next.nodeType === 3 && pos.next.nextSibling === script), `unexpected ${describe(pos.next)} after the root range`);
|
|
387
|
+
cursor.expect(session.done(), 'the payload stream has entries no component consumed');
|
|
388
|
+
} catch (error) {
|
|
389
|
+
failure = error;
|
|
390
|
+
} finally {
|
|
391
|
+
$$setHydration(previous);
|
|
392
|
+
if (session.observer) session.observer.disconnect();
|
|
393
|
+
}
|
|
394
|
+
if (failure) {
|
|
395
|
+
// Rollback (X-7.4): release everything adopted without touching the
|
|
396
|
+
// DOM; the server DOM and payload stay for a retry.
|
|
397
|
+
if (instance && instance.abandon) instance.abandon();
|
|
398
|
+
for (let i = pos.owners.length - 1; i >= 0; i--) pos.owners[i].release();
|
|
399
|
+
if (!isMismatch(failure) || session.policy === 'fail') throw failure;
|
|
400
|
+
// A mismatch at the root range remounts the root (X-7.3).
|
|
401
|
+
session.report(failure);
|
|
402
|
+
while (container.firstChild) container.firstChild.remove();
|
|
403
|
+
return Component(container, props, options.slots || {});
|
|
404
|
+
}
|
|
405
|
+
script.remove();
|
|
406
|
+
for (const callback of session.after) callback();
|
|
407
|
+
// The root owner holds what the root cursor adopted; it goes with the
|
|
408
|
+
// instance (Q-10.2 item 5).
|
|
409
|
+
const destroy = instance.destroy;
|
|
410
|
+
instance.destroy = function () {
|
|
411
|
+
destroy.call(this);
|
|
412
|
+
owner.release();
|
|
413
|
+
};
|
|
414
|
+
return instance;
|
|
415
|
+
}
|