@webjsdev/core 0.7.4 → 0.7.6
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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +2 -2
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +2 -2
- package/package.json +1 -1
- package/src/component.js +205 -2
- package/src/render-server.js +125 -3
- package/src/router-client.js +17 -2
- package/src/serialize.js +20 -8
- package/src/task.js +13 -0
package/package.json
CHANGED
package/src/component.js
CHANGED
|
@@ -147,8 +147,143 @@ function defaultHasChanged(a, b) {
|
|
|
147
147
|
* ```
|
|
148
148
|
*/
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Inert `ElementInternals`-shaped object returned by the server shim's
|
|
152
|
+
* `attachInternals()`. Modeled on `@lit-labs/ssr-dom-shim`'s
|
|
153
|
+
* `ElementInternalsShim` (lit repo, `packages/labs/ssr-dom-shim/src/lib/
|
|
154
|
+
* element-internals.ts`): form-association, validity, and custom-state
|
|
155
|
+
* calls are no-ops at SSR (no form, no constraint validation, no `:state()`
|
|
156
|
+
* matching server-side), so a component that calls `this.attachInternals()`
|
|
157
|
+
* in its constructor renders instead of crashing. The browser runs the real
|
|
158
|
+
* `attachInternals()` on hydration.
|
|
159
|
+
*
|
|
160
|
+
* Deliberate deviation from lit: lit's `checkValidity` / `reportValidity`
|
|
161
|
+
* THROW on the server. webjs returns `true` instead, to keep SSR
|
|
162
|
+
* progressive-enhancement-safe (a stray validity call in a constructor must
|
|
163
|
+
* not 500 the page); the browser does the real validation.
|
|
164
|
+
* @returns {any}
|
|
165
|
+
*/
|
|
166
|
+
function makeServerInternals() {
|
|
167
|
+
return {
|
|
168
|
+
states: new Set(),
|
|
169
|
+
shadowRoot: null,
|
|
170
|
+
form: null,
|
|
171
|
+
labels: [],
|
|
172
|
+
role: '',
|
|
173
|
+
willValidate: true,
|
|
174
|
+
validity: /** @type {any} */ ({}),
|
|
175
|
+
validationMessage: '',
|
|
176
|
+
setFormValue() {},
|
|
177
|
+
setValidity() {},
|
|
178
|
+
checkValidity() { return true; },
|
|
179
|
+
reportValidity() { return true; },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Server-side stand-in for `HTMLElement`. The SSR pipeline constructs
|
|
185
|
+
* component instances in Node, where `HTMLElement` does not exist, so the
|
|
186
|
+
* base class is this shim. It is modeled on `@lit-labs/ssr-dom-shim`'s
|
|
187
|
+
* `ElementShim` (lit repo, `packages/labs/ssr-dom-shim/src/index.ts`): the
|
|
188
|
+
* attribute methods (`getAttribute` / `setAttribute` / `hasAttribute` /
|
|
189
|
+
* `removeAttribute` / `toggleAttribute`, the `attributes` getter) are backed
|
|
190
|
+
* by a Map, so lit muscle-memory patterns that read attributes in `render()`
|
|
191
|
+
* or set them while deriving state work server-side; the SSR walker seeds
|
|
192
|
+
* the Map from the element's source attributes and reads it back to surface
|
|
193
|
+
* reflected/added attributes in the output. Event methods are no-ops (no
|
|
194
|
+
* server event loop), and `attachInternals()` returns the inert object
|
|
195
|
+
* above. The genuinely browser-only surface (`querySelector`, layout reads,
|
|
196
|
+
* `attachShadow`, `focus`) is deliberately absent and still throws at SSR,
|
|
197
|
+
* which the `no-browser-globals-in-render` rule and the SSR crash hint flag.
|
|
198
|
+
*
|
|
199
|
+
* Deliberate deviation from lit: this shim lowercases attribute names so
|
|
200
|
+
* `getAttribute('Foo')` after `setAttribute('foo', x)` resolves, matching how
|
|
201
|
+
* a real browser treats HTML attribute names as case-insensitive. lit's shim
|
|
202
|
+
* keys the Map by the raw name (a known fidelity gap in lit-labs).
|
|
203
|
+
*/
|
|
204
|
+
class ServerElement {
|
|
205
|
+
constructor() {
|
|
206
|
+
/**
|
|
207
|
+
* Backing store for the attribute methods. Keys are lowercased
|
|
208
|
+
* attribute names (HTML attributes are case-insensitive). Seeded by the
|
|
209
|
+
* SSR walker from the element's source attributes.
|
|
210
|
+
* @type {Map<string, string>}
|
|
211
|
+
*/
|
|
212
|
+
this.__ssrAttrs = new Map();
|
|
213
|
+
/** @type {any} */
|
|
214
|
+
this.__internals = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Mirrors `Element.attributes`: an array of `{ name, value }`. */
|
|
218
|
+
get attributes() {
|
|
219
|
+
return [...this.__ssrAttrs].map(([name, value]) => ({ name, value }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {string} name */
|
|
223
|
+
getAttribute(name) {
|
|
224
|
+
const v = this.__ssrAttrs.get(String(name).toLowerCase());
|
|
225
|
+
return v === undefined ? null : v;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} name @param {unknown} value */
|
|
229
|
+
setAttribute(name, value) {
|
|
230
|
+
// Emulate the browser casting all values to string (lit does the same).
|
|
231
|
+
this.__ssrAttrs.set(String(name).toLowerCase(), String(value));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @param {string} name */
|
|
235
|
+
removeAttribute(name) {
|
|
236
|
+
this.__ssrAttrs.delete(String(name).toLowerCase());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param {string} name */
|
|
240
|
+
hasAttribute(name) {
|
|
241
|
+
return this.__ssrAttrs.has(String(name).toLowerCase());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @param {string} name @param {boolean} [force] */
|
|
245
|
+
toggleAttribute(name, force) {
|
|
246
|
+
// Steps mirror https://dom.spec.whatwg.org/#dom-element-toggleattribute
|
|
247
|
+
const key = String(name).toLowerCase();
|
|
248
|
+
const present = this.__ssrAttrs.has(key);
|
|
249
|
+
const next = force === undefined ? !present : force;
|
|
250
|
+
if (next) {
|
|
251
|
+
this.__ssrAttrs.set(key, '');
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
this.__ssrAttrs.delete(key);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @returns {string[]} */
|
|
259
|
+
getAttributeNames() {
|
|
260
|
+
return [...this.__ssrAttrs.keys()];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// No server event loop: listeners never fire at SSR. The no-op keeps a
|
|
264
|
+
// constructor that wires delegated listeners (a common lit pattern) from
|
|
265
|
+
// crashing; the browser re-runs the constructor on hydration where the
|
|
266
|
+
// real HTMLElement methods apply.
|
|
267
|
+
addEventListener() {}
|
|
268
|
+
removeEventListener() {}
|
|
269
|
+
dispatchEvent() { return true; }
|
|
270
|
+
|
|
271
|
+
/** @returns {any} */
|
|
272
|
+
attachInternals() {
|
|
273
|
+
// Match the browser (and lit's shim): a second attach is an error.
|
|
274
|
+
if (this.__internals !== null) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"Failed to execute 'attachInternals' on 'HTMLElement': " +
|
|
277
|
+
'ElementInternals for the specified element was already attached.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
this.__internals = makeServerInternals();
|
|
281
|
+
return this.__internals;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Base class choice: real HTMLElement on the browser, the shim on the server.
|
|
286
|
+
const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
|
|
152
287
|
|
|
153
288
|
export class WebComponent extends Base {
|
|
154
289
|
/** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
|
|
@@ -467,6 +602,13 @@ export class WebComponent extends Base {
|
|
|
467
602
|
|
|
468
603
|
_activate() {
|
|
469
604
|
this._connected = true;
|
|
605
|
+
// Reflect declared reflect:true properties from their current value now
|
|
606
|
+
// that the element is connected. Constructor / willUpdate defaults were
|
|
607
|
+
// set while disconnected (the setter skips reflection then), so this is
|
|
608
|
+
// what makes a freshly-created client element carry the same reflected
|
|
609
|
+
// attributes the SSR walker emitted. Same-value reflects are no-ops, so a
|
|
610
|
+
// hydrated element (whose attribute already arrived from SSR) is unchanged.
|
|
611
|
+
this._reflectDeclaredAttributes();
|
|
470
612
|
const Ctor = /** @type any */ (this.constructor);
|
|
471
613
|
if (Ctor.shadow === true) {
|
|
472
614
|
const hadSSRShadow = !!this.shadowRoot;
|
|
@@ -815,6 +957,67 @@ export class WebComponent extends Base {
|
|
|
815
957
|
}
|
|
816
958
|
}
|
|
817
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Run the pre-render half of the update cycle synchronously for SSR.
|
|
962
|
+
*
|
|
963
|
+
* The SSR walker constructs the instance, applies attributes/props, then
|
|
964
|
+
* calls this BEFORE `render()`. It mirrors how lit drives the update
|
|
965
|
+
* lifecycle server-side: `willUpdate` runs so derived state computed there
|
|
966
|
+
* is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
|
|
967
|
+
* runs, then reflect:true properties are written to attributes so they
|
|
968
|
+
* appear in the serialized output. It deliberately does NOT run
|
|
969
|
+
* `shouldUpdate` (SSR always produces the first paint, there is no prior
|
|
970
|
+
* render to skip), `update` (the walker commits by calling `render`
|
|
971
|
+
* itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
|
|
972
|
+
* DOM work).
|
|
973
|
+
*
|
|
974
|
+
* `_isUpdating` is set so a property assignment inside `willUpdate` folds
|
|
975
|
+
* into the current `changedProperties` snapshot instead of scheduling a
|
|
976
|
+
* fresh cycle (the SSR scheduler short-circuits anyway, since the element
|
|
977
|
+
* is not connected). Hook throws propagate to the walker's per-component
|
|
978
|
+
* try/catch, which logs an actionable hint and skips DSD for that element,
|
|
979
|
+
* the same as a throwing `render()`.
|
|
980
|
+
*/
|
|
981
|
+
performServerUpdate() {
|
|
982
|
+
const changedProperties = this._changedProperties;
|
|
983
|
+
this._isUpdating = true;
|
|
984
|
+
try {
|
|
985
|
+
this.willUpdate(changedProperties);
|
|
986
|
+
for (const c of this.__controllers) {
|
|
987
|
+
if (c.hostUpdate) c.hostUpdate();
|
|
988
|
+
}
|
|
989
|
+
this._reflectDeclaredAttributes();
|
|
990
|
+
} finally {
|
|
991
|
+
this._isUpdating = false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Reflect every reflect:true (non-state) property to its attribute from its
|
|
997
|
+
* CURRENT value, regardless of whether the value arrived via the setter.
|
|
998
|
+
*
|
|
999
|
+
* The property setter only reflects on a connected change, so a value set
|
|
1000
|
+
* in the constructor (a default) or in `willUpdate` is not reflected by the
|
|
1001
|
+
* setter alone. This syncs those: the SSR walker calls it in
|
|
1002
|
+
* `performServerUpdate` (writing into the server attribute shim, which the
|
|
1003
|
+
* walker reads back into the rendered HTML), and `_activate` calls it on
|
|
1004
|
+
* the client's first connected render. Running it on both sides is what
|
|
1005
|
+
* keeps a server-rendered element and a freshly-created client element
|
|
1006
|
+
* agree on their reflected attributes (matching lit, which reflects during
|
|
1007
|
+
* the first update). `_reflectAttribute`'s re-entrancy guard makes a
|
|
1008
|
+
* same-value reflect a no-op, so re-reflecting an attribute that already
|
|
1009
|
+
* arrived from SSR does not churn.
|
|
1010
|
+
* @private
|
|
1011
|
+
*/
|
|
1012
|
+
_reflectDeclaredAttributes() {
|
|
1013
|
+
const props = /** @type {any} */ (this.constructor).properties || {};
|
|
1014
|
+
for (const name of Object.keys(props)) {
|
|
1015
|
+
const decl = props[name];
|
|
1016
|
+
if (!decl || !decl.reflect || decl.state) continue;
|
|
1017
|
+
this._reflectAttribute(name, this[name], decl);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
818
1021
|
// ---------------------------------------------------------------------------
|
|
819
1022
|
// Lifecycle hooks: override in subclasses
|
|
820
1023
|
// ---------------------------------------------------------------------------
|
package/src/render-server.js
CHANGED
|
@@ -314,6 +314,45 @@ async function renderTemplate(tr, ctx) {
|
|
|
314
314
|
return out;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// Browser-only names whose absence during SSR produces a recognisable error.
|
|
318
|
+
// Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
|
|
319
|
+
// these at edit time; this turns the runtime SSR crash into the same guidance.
|
|
320
|
+
const SSR_BROWSER_GLOBALS = new Set([
|
|
321
|
+
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
322
|
+
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
323
|
+
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
324
|
+
]);
|
|
325
|
+
// Attribute methods (get/set/has/remove/toggleAttribute), the event methods
|
|
326
|
+
// (add/removeEventListener, dispatchEvent), and attachInternals are backed by
|
|
327
|
+
// the server-side element shim and work at SSR, so they are NOT listed here.
|
|
328
|
+
// What remains is the genuinely browser-only HTMLElement surface that still
|
|
329
|
+
// has no server stand-in and throws at SSR.
|
|
330
|
+
const SSR_HTMLELEMENT_METHODS = new Set([
|
|
331
|
+
'attachShadow', 'querySelector', 'querySelectorAll',
|
|
332
|
+
'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* If `e` is the recognisable failure of touching a browser-only API during
|
|
337
|
+
* SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
|
|
338
|
+
* HTMLElement method that does not exist on the bare server-side instance),
|
|
339
|
+
* return an actionable, member-naming hint; otherwise null.
|
|
340
|
+
* @param {unknown} e
|
|
341
|
+
* @returns {string | null}
|
|
342
|
+
*/
|
|
343
|
+
function browserMemberHint(e) {
|
|
344
|
+
const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
|
|
345
|
+
let m = /^(\w+) is not defined$/.exec(msg);
|
|
346
|
+
if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
|
|
347
|
+
return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
|
|
348
|
+
}
|
|
349
|
+
m = /\.(\w+) is not a function$/.exec(msg);
|
|
350
|
+
if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
|
|
351
|
+
return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
317
356
|
/**
|
|
318
357
|
* Scan an HTML string for registered custom elements and inject
|
|
319
358
|
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
@@ -348,7 +387,7 @@ async function injectDSD(html, ctx) {
|
|
|
348
387
|
// Track which custom elements actually appeared: used by SSR to emit
|
|
349
388
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
350
389
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
351
|
-
|
|
390
|
+
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
352
391
|
try {
|
|
353
392
|
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
354
393
|
const instance = new /** @type any */ (Cls)();
|
|
@@ -359,8 +398,29 @@ async function injectDSD(html, ctx) {
|
|
|
359
398
|
// properties` type. Property bindings take priority on a name
|
|
360
399
|
// collision because they preserve the original JS reference.
|
|
361
400
|
const propValues = consumePropAttrs(attrMap);
|
|
401
|
+
// Names already present in the source opening tag (including the
|
|
402
|
+
// data-webjs-prop-* bindings, which were stripped from attrMap above
|
|
403
|
+
// but remain in the emitted `attrs` string). Reflected/added
|
|
404
|
+
// attributes are appended only when their name is NOT already here, so
|
|
405
|
+
// existing output stays byte-identical when nothing reflects.
|
|
406
|
+
const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
|
|
407
|
+
// Seed the server attribute shim so `this.getAttribute(...)` /
|
|
408
|
+
// `this.hasAttribute(...)` in willUpdate / render read the source
|
|
409
|
+
// attributes (a lit muscle-memory pattern) instead of reading empty.
|
|
410
|
+
seedServerAttrs(instance, attrMap);
|
|
362
411
|
applyAttrsToInstance(instance, attrMap, Cls);
|
|
363
412
|
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
|
|
413
|
+
// Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
|
|
414
|
+
// then reflect reflect:true props) so derived state computed there is
|
|
415
|
+
// correct in the SSR'd HTML, matching how lit runs the update cycle at
|
|
416
|
+
// SSR. WebComponent instances expose performServerUpdate; bare
|
|
417
|
+
// Base-extending kit components (no lifecycle) do not, so it is guarded.
|
|
418
|
+
if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
|
|
419
|
+
// Surface attributes the component set before render (reflected
|
|
420
|
+
// reflect:true props, or an explicit this.setAttribute in the
|
|
421
|
+
// constructor / willUpdate) that were not already in the source tag.
|
|
422
|
+
// Appending keeps the original tag byte-identical when nothing changed.
|
|
423
|
+
opening = appendReflectedAttrs(opening, instance, presentAttrNames);
|
|
364
424
|
let tpl = instance.render ? instance.render() : '';
|
|
365
425
|
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
|
|
366
426
|
// Render the template to HTML. injectDSD recurses on the result so
|
|
@@ -443,7 +503,15 @@ async function injectDSD(html, ctx) {
|
|
|
443
503
|
});
|
|
444
504
|
}
|
|
445
505
|
} catch (e) {
|
|
446
|
-
|
|
506
|
+
const hint = browserMemberHint(e);
|
|
507
|
+
if (hint) {
|
|
508
|
+
console.error(
|
|
509
|
+
`[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
|
|
510
|
+
e,
|
|
511
|
+
);
|
|
512
|
+
} else {
|
|
513
|
+
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
514
|
+
}
|
|
447
515
|
}
|
|
448
516
|
}
|
|
449
517
|
if (!edits.length) return html;
|
|
@@ -721,6 +789,55 @@ function parseAttrs(attrStr) {
|
|
|
721
789
|
return out;
|
|
722
790
|
}
|
|
723
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Seed the element's attributes from the source opening tag so reads like
|
|
794
|
+
* `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
|
|
795
|
+
* render return the real value during SSR. Goes through `setAttribute`, which
|
|
796
|
+
* both the server element shim (Node SSR) and a real `HTMLElement`
|
|
797
|
+
* (renderToString called in a browser, e.g. tests) implement, so the path
|
|
798
|
+
* does not depend on the shim's internal store. A bare Base-extending kit
|
|
799
|
+
* component without `setAttribute` is skipped.
|
|
800
|
+
*
|
|
801
|
+
* @param {any} instance
|
|
802
|
+
* @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
|
|
803
|
+
*/
|
|
804
|
+
function seedServerAttrs(instance, attrs) {
|
|
805
|
+
if (!instance || typeof instance.setAttribute !== 'function') return;
|
|
806
|
+
for (const [name, raw] of Object.entries(attrs)) {
|
|
807
|
+
instance.setAttribute(name, unescapeAttr(raw));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Append attributes the component set before render (reflected reflect:true
|
|
813
|
+
* properties, or an explicit `this.setAttribute` in the constructor /
|
|
814
|
+
* willUpdate) to the element's opening tag, skipping any name already present
|
|
815
|
+
* in the source tag. Reads via the standard `getAttributeNames` /
|
|
816
|
+
* `getAttribute` API so it works whether the instance is the server shim or a
|
|
817
|
+
* real `HTMLElement`. Returns the opening tag unchanged when there is nothing
|
|
818
|
+
* to add, so existing SSR output stays byte-identical when no component
|
|
819
|
+
* reflects, which preserves the elision on-vs-off differential invariant.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} opening the element's opening tag, ending in `>`
|
|
822
|
+
* @param {any} instance
|
|
823
|
+
* @param {Set<string>} presentAttrNames lowercased names already in the source tag
|
|
824
|
+
* @returns {string}
|
|
825
|
+
*/
|
|
826
|
+
function appendReflectedAttrs(opening, instance, presentAttrNames) {
|
|
827
|
+
if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
|
|
828
|
+
let extra = '';
|
|
829
|
+
for (const rawName of instance.getAttributeNames()) {
|
|
830
|
+
const name = String(rawName).toLowerCase();
|
|
831
|
+
if (presentAttrNames.has(name)) continue;
|
|
832
|
+
const value = instance.getAttribute(rawName);
|
|
833
|
+
extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
|
|
834
|
+
}
|
|
835
|
+
if (!extra) return opening;
|
|
836
|
+
// Insert before the closing `>` (the opening tag is normalised to end in
|
|
837
|
+
// `>`; a self-closing source tag was already rewritten without the slash).
|
|
838
|
+
return `${opening.slice(0, -1)}${extra}>`;
|
|
839
|
+
}
|
|
840
|
+
|
|
724
841
|
/**
|
|
725
842
|
* Coerce attribute strings to typed properties on a component instance
|
|
726
843
|
* based on its static `properties` declaration.
|
|
@@ -737,7 +854,12 @@ function applyAttrsToInstance(instance, attrs, Cls) {
|
|
|
737
854
|
if (def.type === Number) instance[propName] = Number(raw);
|
|
738
855
|
else if (def.type === Boolean) instance[propName] = raw !== 'false';
|
|
739
856
|
else if (def.type === Object || def.type === Array) {
|
|
740
|
-
|
|
857
|
+
// `raw` is the entity-encoded attribute text (parseAttrs returns the
|
|
858
|
+
// literal characters between the quotes), so decode the HTML entities
|
|
859
|
+
// before JSON.parse. A JSON attribute carries `"` for every `"`;
|
|
860
|
+
// parsing it raw throws and would silently fall back to the string,
|
|
861
|
+
// leaving an Object/Array prop holding a string at SSR.
|
|
862
|
+
try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
|
|
741
863
|
} else instance[propName] = raw;
|
|
742
864
|
}
|
|
743
865
|
}
|
package/src/router-client.js
CHANGED
|
@@ -1003,7 +1003,17 @@ function drainPrefetchQueue() {
|
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
1005
|
/**
|
|
1006
|
-
* Store a speculative entry under LRU + cap
|
|
1006
|
+
* Store a speculative entry under LRU + cap, then announce that the
|
|
1007
|
+
* fragment is cached and consumable.
|
|
1008
|
+
*
|
|
1009
|
+
* The `webjs:prefetch` event fires the instant a speculative fragment
|
|
1010
|
+
* becomes consumable (after the response body has been read), which is
|
|
1011
|
+
* strictly later than the prefetch request going out. App code can
|
|
1012
|
+
* listen to instrument prefetch hit rate, and tests can await it to know
|
|
1013
|
+
* a subsequent click will consume the cache rather than refetch. The
|
|
1014
|
+
* detail carries the cached URL and a `from: 'prefetch'` tag so a single
|
|
1015
|
+
* listener can disambiguate it from `webjs:navigate`.
|
|
1016
|
+
*
|
|
1007
1017
|
* @param {string} key
|
|
1008
1018
|
* @param {PrefetchEntry} entry
|
|
1009
1019
|
*/
|
|
@@ -1014,6 +1024,11 @@ function prefetchStore(key, entry) {
|
|
|
1014
1024
|
const oldest = prefetchCache.keys().next().value;
|
|
1015
1025
|
prefetchCache.delete(oldest);
|
|
1016
1026
|
}
|
|
1027
|
+
if (typeof document !== 'undefined') {
|
|
1028
|
+
document.dispatchEvent(new CustomEvent('webjs:prefetch', {
|
|
1029
|
+
detail: { url: entry.finalUrl, key, from: 'prefetch' },
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1017
1032
|
}
|
|
1018
1033
|
|
|
1019
1034
|
/**
|
|
@@ -1267,7 +1282,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
1267
1282
|
}
|
|
1268
1283
|
}
|
|
1269
1284
|
|
|
1270
|
-
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId } }));
|
|
1285
|
+
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
|
|
1271
1286
|
}
|
|
1272
1287
|
|
|
1273
1288
|
/**
|
package/src/serialize.js
CHANGED
|
@@ -321,15 +321,26 @@ function decode(v, ctx) {
|
|
|
321
321
|
|
|
322
322
|
function decodeTagged(v, tag, ctx) {
|
|
323
323
|
const id = v[ID_KEY];
|
|
324
|
+
// Register the decoded value under its `_id` so a later `Ref` to it
|
|
325
|
+
// resolves. Container tags (Map/Set/Arr/Error/object) register BEFORE
|
|
326
|
+
// decoding children (so a cycle through the container resolves); leaf
|
|
327
|
+
// rich types (Date, typed arrays, Blob, ...) have no children and register
|
|
328
|
+
// through this helper at return. Without it, a SHARED Date or typed array
|
|
329
|
+
// (the same instance appearing twice) decodes the first copy but throws
|
|
330
|
+
// "Dangling reference" on the second.
|
|
331
|
+
const reg = (value) => {
|
|
332
|
+
if (typeof id === 'number') ctx.refs.set(id, value);
|
|
333
|
+
return value;
|
|
334
|
+
};
|
|
324
335
|
switch (tag) {
|
|
325
336
|
case 'undef': return undefined;
|
|
326
337
|
case 'NaN': return NaN;
|
|
327
338
|
case 'Inf': return Infinity;
|
|
328
339
|
case '-Inf': return -Infinity;
|
|
329
340
|
case '-0': return -0;
|
|
330
|
-
case 'BigInt': return BigInt(v.v);
|
|
331
|
-
case 'Sym': return Symbol.for(v.v);
|
|
332
|
-
case 'Date': return v.v == null ? new Date(NaN) : new Date(v.v);
|
|
341
|
+
case 'BigInt': return reg(BigInt(v.v));
|
|
342
|
+
case 'Sym': return reg(Symbol.for(v.v));
|
|
343
|
+
case 'Date': return reg(v.v == null ? new Date(NaN) : new Date(v.v));
|
|
333
344
|
case 'Error': {
|
|
334
345
|
const e = new Error(v.v.message);
|
|
335
346
|
e.name = v.v.name;
|
|
@@ -365,16 +376,17 @@ function decodeTagged(v, tag, ctx) {
|
|
|
365
376
|
case 'Blob': {
|
|
366
377
|
if (typeof Blob === 'undefined') throw new TypeError('Blob is not available in this environment.');
|
|
367
378
|
const bytes = b64ToBytes(v.v);
|
|
368
|
-
return new Blob([bytes], { type: v.t || '' });
|
|
379
|
+
return reg(new Blob([bytes], { type: v.t || '' }));
|
|
369
380
|
}
|
|
370
381
|
case 'File': {
|
|
371
382
|
if (typeof File === 'undefined') throw new TypeError('File is not available in this environment.');
|
|
372
383
|
const bytes = b64ToBytes(v.v);
|
|
373
|
-
return new File([bytes], v.n, { type: v.t || '', lastModified: v.m });
|
|
384
|
+
return reg(new File([bytes], v.n, { type: v.t || '', lastModified: v.m }));
|
|
374
385
|
}
|
|
375
386
|
case 'FD': {
|
|
376
387
|
if (typeof FormData === 'undefined') throw new TypeError('FormData is not available in this environment.');
|
|
377
388
|
const fd = new FormData();
|
|
389
|
+
reg(fd);
|
|
378
390
|
for (const [k, val] of v.v) {
|
|
379
391
|
const decoded = decode(val, ctx);
|
|
380
392
|
fd.append(k, decoded);
|
|
@@ -385,17 +397,17 @@ function decodeTagged(v, tag, ctx) {
|
|
|
385
397
|
// Typed arrays + binary buffers
|
|
386
398
|
if (tag === 'buf') {
|
|
387
399
|
const bytes = b64ToBytes(v.v);
|
|
388
|
-
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
400
|
+
return reg(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
389
401
|
}
|
|
390
402
|
if (tag === 'dv') {
|
|
391
403
|
const bytes = b64ToBytes(v.v);
|
|
392
|
-
return new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
404
|
+
return reg(new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)));
|
|
393
405
|
}
|
|
394
406
|
const Ctor = TAG_TO_TYPED_ARRAY[tag];
|
|
395
407
|
if (Ctor) {
|
|
396
408
|
const bytes = b64ToBytes(v.v);
|
|
397
409
|
const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
398
|
-
return new Ctor(sliced);
|
|
410
|
+
return reg(new Ctor(sliced));
|
|
399
411
|
}
|
|
400
412
|
throw new TypeError(`Unknown serialization tag: ${tag}`);
|
|
401
413
|
}
|
package/src/task.js
CHANGED
|
@@ -93,6 +93,16 @@
|
|
|
93
93
|
* @readonly
|
|
94
94
|
* @enum {number}
|
|
95
95
|
*/
|
|
96
|
+
// The SSR walker now runs controllers' hostUpdate during the pre-render pass
|
|
97
|
+
// (issue #217). A Task must NOT invoke its task function server-side (that
|
|
98
|
+
// would fire a fetch during SSR); the browser runs it on hydration. Read live
|
|
99
|
+
// (not cached) so the auto-run gate reflects the current environment: SSR
|
|
100
|
+
// ships the task's INITIAL state, unchanged from before hostUpdate fired at
|
|
101
|
+
// SSR, and the browser runs it on connect.
|
|
102
|
+
function inBrowser() {
|
|
103
|
+
return typeof window !== 'undefined';
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
export const TaskStatus = /** @type {const} */ ({
|
|
97
107
|
INITIAL: 0,
|
|
98
108
|
PENDING: 1,
|
|
@@ -326,6 +336,9 @@ export class Task {
|
|
|
326
336
|
*/
|
|
327
337
|
hostUpdate() {
|
|
328
338
|
if (!this._autoRun) return;
|
|
339
|
+
// Never auto-run server-side: SSR ships the INITIAL state and the task
|
|
340
|
+
// function (typically a fetch) runs only in the browser on hydration.
|
|
341
|
+
if (!inBrowser()) return;
|
|
329
342
|
|
|
330
343
|
const nextArgs = this._argsFn();
|
|
331
344
|
|