@webjsdev/core 0.7.5 → 0.7.7
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/index-browser.js +10 -2
- package/index.js +9 -2
- package/package.json +1 -1
- package/src/component.js +319 -2
- package/src/directives.js +7 -1
- package/src/render-server.js +100 -7
- package/src/task.js +13 -0
package/index-browser.js
CHANGED
|
@@ -48,8 +48,16 @@ export { WebjsFrame } from './src/webjs-frame.js';
|
|
|
48
48
|
// Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
|
|
49
49
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
50
50
|
|
|
51
|
-
// Directives, also available via '@webjsdev/core/directives'
|
|
52
|
-
|
|
51
|
+
// Directives, also available via '@webjsdev/core/directives'. The full
|
|
52
|
+
// lit-html-parity set is re-exported here so the dist browser bundle (which
|
|
53
|
+
// the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
|
|
54
|
+
// every directive, matching what src/directives.js exports in dev.
|
|
55
|
+
export {
|
|
56
|
+
unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
|
|
57
|
+
templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
|
|
58
|
+
until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
|
|
59
|
+
watch, isWatch,
|
|
60
|
+
} from './src/directives.js';
|
|
53
61
|
|
|
54
62
|
// Context Protocol, also available via '@webjsdev/core/context'
|
|
55
63
|
export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
|
package/index.js
CHANGED
|
@@ -30,8 +30,15 @@ export { WebjsFrame } from './src/webjs-frame.js';
|
|
|
30
30
|
// Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
|
|
31
31
|
export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
|
|
32
32
|
|
|
33
|
-
// Directives, also available via '@webjsdev/core/directives'
|
|
34
|
-
|
|
33
|
+
// Directives, also available via '@webjsdev/core/directives'. The full
|
|
34
|
+
// lit-html-parity set is re-exported so the bare specifier exposes the same
|
|
35
|
+
// directive surface in Node as the browser bundle does.
|
|
36
|
+
export {
|
|
37
|
+
unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
|
|
38
|
+
templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
|
|
39
|
+
until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
|
|
40
|
+
watch, isWatch,
|
|
41
|
+
} from './src/directives.js';
|
|
35
42
|
|
|
36
43
|
// Context Protocol, also available via '@webjsdev/core/context'
|
|
37
44
|
export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
|
package/package.json
CHANGED
package/src/component.js
CHANGED
|
@@ -147,8 +147,257 @@ 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
|
+
/**
|
|
264
|
+
* Minimal `Element.closest()` for SSR. The SSR walker threads the chain
|
|
265
|
+
* of enclosing custom-element instances into each instance
|
|
266
|
+
* (`__ssrAncestors`); this walks self-then-ancestors and returns the
|
|
267
|
+
* nearest whose tag matches. Only bare tag-name selectors are supported
|
|
268
|
+
* server-side (`closest('ui-tabs')`), which is what compound components
|
|
269
|
+
* need to read parent state for a correct first paint; anything more
|
|
270
|
+
* specific (class, attribute, or descendant selectors) returns null,
|
|
271
|
+
* matching the pre-shim behaviour. The browser runs the real `closest()`
|
|
272
|
+
* on hydration.
|
|
273
|
+
* @param {string} selector
|
|
274
|
+
* @returns {any}
|
|
275
|
+
*/
|
|
276
|
+
closest(selector) {
|
|
277
|
+
const sel = String(selector).trim().toLowerCase();
|
|
278
|
+
// Tag-name selectors only at SSR; bail (null) on anything else.
|
|
279
|
+
if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null;
|
|
280
|
+
if (this.__ssrTag === sel) return this;
|
|
281
|
+
const chain = this.__ssrAncestors;
|
|
282
|
+
if (!Array.isArray(chain)) return null;
|
|
283
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
284
|
+
if (chain[i] && chain[i].__ssrTag === sel) return chain[i];
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `HTMLElement.dataset`: a live view over the element's `data-*`
|
|
291
|
+
* attributes, backed by the SSR attribute Map. Reading / writing
|
|
292
|
+
* `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase
|
|
293
|
+
* to kebab-case), so a `render()` that sets `this.dataset.state = 'on'`
|
|
294
|
+
* surfaces `data-state="on"` in the SSR'd host tag instead of crashing
|
|
295
|
+
* on an undefined `dataset`.
|
|
296
|
+
* @returns {Record<string, string>}
|
|
297
|
+
*/
|
|
298
|
+
get dataset() {
|
|
299
|
+
if (this.__dataset) return this.__dataset;
|
|
300
|
+
const el = this;
|
|
301
|
+
const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
302
|
+
this.__dataset = new Proxy(/** @type {Record<string,string>} */ ({}), {
|
|
303
|
+
get(_t, prop) {
|
|
304
|
+
if (typeof prop !== 'string') return undefined;
|
|
305
|
+
const v = el.getAttribute(toAttr(prop));
|
|
306
|
+
return v === null ? undefined : v;
|
|
307
|
+
},
|
|
308
|
+
set(_t, prop, value) {
|
|
309
|
+
if (typeof prop === 'string') el.setAttribute(toAttr(prop), value);
|
|
310
|
+
return true;
|
|
311
|
+
},
|
|
312
|
+
has(_t, prop) {
|
|
313
|
+
return typeof prop === 'string' && el.hasAttribute(toAttr(prop));
|
|
314
|
+
},
|
|
315
|
+
deleteProperty(_t, prop) {
|
|
316
|
+
if (typeof prop === 'string') el.removeAttribute(toAttr(prop));
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
ownKeys() {
|
|
320
|
+
return el.getAttributeNames()
|
|
321
|
+
.filter((n) => n.startsWith('data-'))
|
|
322
|
+
.map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase()));
|
|
323
|
+
},
|
|
324
|
+
getOwnPropertyDescriptor(_t, prop) {
|
|
325
|
+
if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) {
|
|
326
|
+
return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) };
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
return this.__dataset;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// IDL properties that reflect to a content attribute. A render() that
|
|
335
|
+
// mutates these on the host (a light-DOM compound-component pattern, e.g.
|
|
336
|
+
// `this.className = ...`, `this.hidden = !active`) then surfaces the
|
|
337
|
+
// matching attribute in the SSR'd host tag, matching the browser.
|
|
338
|
+
get className() { return this.getAttribute('class') ?? ''; }
|
|
339
|
+
set className(v) { this.setAttribute('class', v); }
|
|
340
|
+
get hidden() { return this.hasAttribute('hidden'); }
|
|
341
|
+
set hidden(v) { this.toggleAttribute('hidden', !!v); }
|
|
342
|
+
get id() { return this.getAttribute('id') ?? ''; }
|
|
343
|
+
set id(v) { this.setAttribute('id', v); }
|
|
344
|
+
get title() { return this.getAttribute('title') ?? ''; }
|
|
345
|
+
set title(v) { this.setAttribute('title', v); }
|
|
346
|
+
get slot() { return this.getAttribute('slot') ?? ''; }
|
|
347
|
+
set slot(v) { this.setAttribute('slot', v); }
|
|
348
|
+
get role() { return this.getAttribute('role'); }
|
|
349
|
+
set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); }
|
|
350
|
+
get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); }
|
|
351
|
+
set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
|
|
352
|
+
|
|
353
|
+
// No server event loop: listeners never fire at SSR. The no-op keeps a
|
|
354
|
+
// constructor that wires delegated listeners (a common lit pattern) from
|
|
355
|
+
// crashing; the browser re-runs the constructor on hydration where the
|
|
356
|
+
// real HTMLElement methods apply.
|
|
357
|
+
addEventListener() {}
|
|
358
|
+
removeEventListener() {}
|
|
359
|
+
dispatchEvent() { return true; }
|
|
360
|
+
|
|
361
|
+
/** @returns {any} */
|
|
362
|
+
attachInternals() {
|
|
363
|
+
// Match the browser (and lit's shim): a second attach is an error.
|
|
364
|
+
if (this.__internals !== null) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
"Failed to execute 'attachInternals' on 'HTMLElement': " +
|
|
367
|
+
'ElementInternals for the specified element was already attached.',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
this.__internals = makeServerInternals();
|
|
371
|
+
return this.__internals;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed).
|
|
376
|
+
// A render() that sets ARIA state via the IDL properties then surfaces the
|
|
377
|
+
// matching aria-* attribute in the SSR'd host tag, matching the browser. The
|
|
378
|
+
// IDL name maps to the content attribute by lowercasing the part after
|
|
379
|
+
// `aria` and prefixing `aria-` (ariaPressed -> aria-pressed).
|
|
380
|
+
const ARIA_IDL_PROPS = [
|
|
381
|
+
'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount',
|
|
382
|
+
'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled',
|
|
383
|
+
'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts',
|
|
384
|
+
'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine',
|
|
385
|
+
'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet',
|
|
386
|
+
'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription',
|
|
387
|
+
'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize',
|
|
388
|
+
'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText',
|
|
389
|
+
];
|
|
390
|
+
for (const idl of ARIA_IDL_PROPS) {
|
|
391
|
+
const attr = 'aria-' + idl.slice(4).toLowerCase();
|
|
392
|
+
Object.defineProperty(ServerElement.prototype, idl, {
|
|
393
|
+
configurable: true,
|
|
394
|
+
get() { return this.getAttribute(attr); },
|
|
395
|
+
set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Base class choice: real HTMLElement on the browser, the shim on the server.
|
|
400
|
+
const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
|
|
152
401
|
|
|
153
402
|
export class WebComponent extends Base {
|
|
154
403
|
/** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
|
|
@@ -467,6 +716,13 @@ export class WebComponent extends Base {
|
|
|
467
716
|
|
|
468
717
|
_activate() {
|
|
469
718
|
this._connected = true;
|
|
719
|
+
// Reflect declared reflect:true properties from their current value now
|
|
720
|
+
// that the element is connected. Constructor / willUpdate defaults were
|
|
721
|
+
// set while disconnected (the setter skips reflection then), so this is
|
|
722
|
+
// what makes a freshly-created client element carry the same reflected
|
|
723
|
+
// attributes the SSR walker emitted. Same-value reflects are no-ops, so a
|
|
724
|
+
// hydrated element (whose attribute already arrived from SSR) is unchanged.
|
|
725
|
+
this._reflectDeclaredAttributes();
|
|
470
726
|
const Ctor = /** @type any */ (this.constructor);
|
|
471
727
|
if (Ctor.shadow === true) {
|
|
472
728
|
const hadSSRShadow = !!this.shadowRoot;
|
|
@@ -815,6 +1071,67 @@ export class WebComponent extends Base {
|
|
|
815
1071
|
}
|
|
816
1072
|
}
|
|
817
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* Run the pre-render half of the update cycle synchronously for SSR.
|
|
1076
|
+
*
|
|
1077
|
+
* The SSR walker constructs the instance, applies attributes/props, then
|
|
1078
|
+
* calls this BEFORE `render()`. It mirrors how lit drives the update
|
|
1079
|
+
* lifecycle server-side: `willUpdate` runs so derived state computed there
|
|
1080
|
+
* is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
|
|
1081
|
+
* runs, then reflect:true properties are written to attributes so they
|
|
1082
|
+
* appear in the serialized output. It deliberately does NOT run
|
|
1083
|
+
* `shouldUpdate` (SSR always produces the first paint, there is no prior
|
|
1084
|
+
* render to skip), `update` (the walker commits by calling `render`
|
|
1085
|
+
* itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
|
|
1086
|
+
* DOM work).
|
|
1087
|
+
*
|
|
1088
|
+
* `_isUpdating` is set so a property assignment inside `willUpdate` folds
|
|
1089
|
+
* into the current `changedProperties` snapshot instead of scheduling a
|
|
1090
|
+
* fresh cycle (the SSR scheduler short-circuits anyway, since the element
|
|
1091
|
+
* is not connected). Hook throws propagate to the walker's per-component
|
|
1092
|
+
* try/catch, which logs an actionable hint and skips DSD for that element,
|
|
1093
|
+
* the same as a throwing `render()`.
|
|
1094
|
+
*/
|
|
1095
|
+
performServerUpdate() {
|
|
1096
|
+
const changedProperties = this._changedProperties;
|
|
1097
|
+
this._isUpdating = true;
|
|
1098
|
+
try {
|
|
1099
|
+
this.willUpdate(changedProperties);
|
|
1100
|
+
for (const c of this.__controllers) {
|
|
1101
|
+
if (c.hostUpdate) c.hostUpdate();
|
|
1102
|
+
}
|
|
1103
|
+
this._reflectDeclaredAttributes();
|
|
1104
|
+
} finally {
|
|
1105
|
+
this._isUpdating = false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Reflect every reflect:true (non-state) property to its attribute from its
|
|
1111
|
+
* CURRENT value, regardless of whether the value arrived via the setter.
|
|
1112
|
+
*
|
|
1113
|
+
* The property setter only reflects on a connected change, so a value set
|
|
1114
|
+
* in the constructor (a default) or in `willUpdate` is not reflected by the
|
|
1115
|
+
* setter alone. This syncs those: the SSR walker calls it in
|
|
1116
|
+
* `performServerUpdate` (writing into the server attribute shim, which the
|
|
1117
|
+
* walker reads back into the rendered HTML), and `_activate` calls it on
|
|
1118
|
+
* the client's first connected render. Running it on both sides is what
|
|
1119
|
+
* keeps a server-rendered element and a freshly-created client element
|
|
1120
|
+
* agree on their reflected attributes (matching lit, which reflects during
|
|
1121
|
+
* the first update). `_reflectAttribute`'s re-entrancy guard makes a
|
|
1122
|
+
* same-value reflect a no-op, so re-reflecting an attribute that already
|
|
1123
|
+
* arrived from SSR does not churn.
|
|
1124
|
+
* @private
|
|
1125
|
+
*/
|
|
1126
|
+
_reflectDeclaredAttributes() {
|
|
1127
|
+
const props = /** @type {any} */ (this.constructor).properties || {};
|
|
1128
|
+
for (const name of Object.keys(props)) {
|
|
1129
|
+
const decl = props[name];
|
|
1130
|
+
if (!decl || !decl.reflect || decl.state) continue;
|
|
1131
|
+
this._reflectAttribute(name, this[name], decl);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
818
1135
|
// ---------------------------------------------------------------------------
|
|
819
1136
|
// Lifecycle hooks: override in subclasses
|
|
820
1137
|
// ---------------------------------------------------------------------------
|
package/src/directives.js
CHANGED
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
* } from '@webjsdev/core/directives';
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
|
-
* `repeat()`
|
|
15
|
+
* `repeat()` is implemented in `./repeat.js`; it is re-exported here so the
|
|
16
|
+
* `@webjsdev/core/directives` subpath exposes the whole documented directive
|
|
17
|
+
* table in src/dev mode too (the dist browser bundle already carries it).
|
|
16
18
|
*
|
|
17
19
|
* @module directives
|
|
18
20
|
*/
|
|
19
21
|
|
|
22
|
+
// Re-export so the `/directives` subpath surface matches the documented table
|
|
23
|
+
// in both src and dist mode. The implementation stays in repeat.js.
|
|
24
|
+
export { repeat, isRepeat } from './repeat.js';
|
|
25
|
+
|
|
20
26
|
/* ================================================================
|
|
21
27
|
* unsafeHTML
|
|
22
28
|
* ================================================================ */
|
package/src/render-server.js
CHANGED
|
@@ -322,9 +322,13 @@ const SSR_BROWSER_GLOBALS = new Set([
|
|
|
322
322
|
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
323
323
|
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
324
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.
|
|
325
330
|
const SSR_HTMLELEMENT_METHODS = new Set([
|
|
326
|
-
'attachShadow', '
|
|
327
|
-
'hasAttribute', 'dispatchEvent', 'querySelector', 'querySelectorAll',
|
|
331
|
+
'attachShadow', 'querySelector', 'querySelectorAll',
|
|
328
332
|
'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
|
|
329
333
|
]);
|
|
330
334
|
|
|
@@ -358,7 +362,7 @@ function browserMemberHint(e) {
|
|
|
358
362
|
* @param {SuspenseCtx} [ctx]
|
|
359
363
|
* @returns {Promise<string>}
|
|
360
364
|
*/
|
|
361
|
-
async function injectDSD(html, ctx) {
|
|
365
|
+
async function injectDSD(html, ctx, ancestors = []) {
|
|
362
366
|
const tags = allTags();
|
|
363
367
|
if (!tags.length) return html;
|
|
364
368
|
// Sort longest tag name first so the regex alternation tries the most
|
|
@@ -383,10 +387,16 @@ async function injectDSD(html, ctx) {
|
|
|
383
387
|
// Track which custom elements actually appeared: used by SSR to emit
|
|
384
388
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
385
389
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
386
|
-
|
|
390
|
+
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
387
391
|
try {
|
|
388
392
|
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
389
393
|
const instance = new /** @type any */ (Cls)();
|
|
394
|
+
// Thread the ancestor chain (the enclosing custom-element instances)
|
|
395
|
+
// so the server element shim's closest() can resolve a parent at SSR.
|
|
396
|
+
// Set before performServerUpdate so a willUpdate() that reads a parent
|
|
397
|
+
// via closest() sees the chain. Each child recursion below extends it.
|
|
398
|
+
instance.__ssrTag = tag;
|
|
399
|
+
instance.__ssrAncestors = ancestors;
|
|
390
400
|
const attrMap = parseAttrs(attrs);
|
|
391
401
|
// Decode `data-webjs-prop-*` attributes first (rich-typed values
|
|
392
402
|
// emitted for `.prop=${val}` bindings in the parent template),
|
|
@@ -394,10 +404,34 @@ async function injectDSD(html, ctx) {
|
|
|
394
404
|
// properties` type. Property bindings take priority on a name
|
|
395
405
|
// collision because they preserve the original JS reference.
|
|
396
406
|
const propValues = consumePropAttrs(attrMap);
|
|
407
|
+
// Names already present in the source opening tag (including the
|
|
408
|
+
// data-webjs-prop-* bindings, which were stripped from attrMap above
|
|
409
|
+
// but remain in the emitted `attrs` string). Reflected/added
|
|
410
|
+
// attributes are appended only when their name is NOT already here, so
|
|
411
|
+
// existing output stays byte-identical when nothing reflects.
|
|
412
|
+
const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
|
|
413
|
+
// Seed the server attribute shim so `this.getAttribute(...)` /
|
|
414
|
+
// `this.hasAttribute(...)` in willUpdate / render read the source
|
|
415
|
+
// attributes (a lit muscle-memory pattern) instead of reading empty.
|
|
416
|
+
seedServerAttrs(instance, attrMap);
|
|
397
417
|
applyAttrsToInstance(instance, attrMap, Cls);
|
|
398
418
|
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
|
|
419
|
+
// Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
|
|
420
|
+
// then reflect reflect:true props) so derived state computed there is
|
|
421
|
+
// correct in the SSR'd HTML, matching how lit runs the update cycle at
|
|
422
|
+
// SSR. WebComponent instances expose performServerUpdate; bare
|
|
423
|
+
// Base-extending kit components (no lifecycle) do not, so it is guarded.
|
|
424
|
+
if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
|
|
399
425
|
let tpl = instance.render ? instance.render() : '';
|
|
400
426
|
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
|
|
427
|
+
// Surface attributes the component set up to and including render()
|
|
428
|
+
// that were not already in the source tag: reflected reflect:true
|
|
429
|
+
// props, an explicit this.setAttribute in the constructor / willUpdate,
|
|
430
|
+
// or a host-attribute mutation inside render() itself (a light-DOM
|
|
431
|
+
// compound-component pattern, e.g. this.dataset.state / this.className /
|
|
432
|
+
// this.hidden on the host). Reading after render() captures all three.
|
|
433
|
+
// Appending keeps the original tag byte-identical when nothing changed.
|
|
434
|
+
opening = appendReflectedAttrs(opening, instance, presentAttrNames);
|
|
401
435
|
// Render the template to HTML. injectDSD recurses on the result so
|
|
402
436
|
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
403
437
|
// get their own DSD pass.
|
|
@@ -407,7 +441,7 @@ async function injectDSD(html, ctx) {
|
|
|
407
441
|
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
408
442
|
// browser handles projection from the host's light-DOM children
|
|
409
443
|
// into the shadow tree natively. No framework substitution here.
|
|
410
|
-
const innerProcessed = await injectDSD(rawInner, ctx);
|
|
444
|
+
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
|
|
411
445
|
const rawStyles = /** @type any */ (Cls).styles;
|
|
412
446
|
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
413
447
|
const styleStr = stylesToString(styleList);
|
|
@@ -470,7 +504,7 @@ async function injectDSD(html, ctx) {
|
|
|
470
504
|
}
|
|
471
505
|
const partitioned = partitionAuthoredBySlot(authoredInner);
|
|
472
506
|
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
|
|
473
|
-
const innerProcessed = await injectDSD(innerWithSlots, ctx);
|
|
507
|
+
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
|
|
474
508
|
edits.push({
|
|
475
509
|
start: m.index,
|
|
476
510
|
end: closeEnd,
|
|
@@ -498,6 +532,11 @@ async function injectDSD(html, ctx) {
|
|
|
498
532
|
// independently against the original html, but those inner elements
|
|
499
533
|
// are processed by the recursive injectDSD call on innerWithSlots.
|
|
500
534
|
// Keeping both edits would double-process them and corrupt the output.
|
|
535
|
+
// A consequence: a nested instance's render() runs once per chain depth
|
|
536
|
+
// (the discarded top-level pass sees an empty ancestor chain, so its
|
|
537
|
+
// closest() reads null; the kept recursive pass has the real chain). The
|
|
538
|
+
// kept pass is the only output, and closest() is a read, so render() must
|
|
539
|
+
// stay pure at SSR (the standard SSR contract), not branch on side effects.
|
|
501
540
|
edits.sort((a, b) => a.start - b.start);
|
|
502
541
|
/** @type {{start:number, end:number, text:string}[]} */
|
|
503
542
|
const filtered = [];
|
|
@@ -764,6 +803,55 @@ function parseAttrs(attrStr) {
|
|
|
764
803
|
return out;
|
|
765
804
|
}
|
|
766
805
|
|
|
806
|
+
/**
|
|
807
|
+
* Seed the element's attributes from the source opening tag so reads like
|
|
808
|
+
* `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
|
|
809
|
+
* render return the real value during SSR. Goes through `setAttribute`, which
|
|
810
|
+
* both the server element shim (Node SSR) and a real `HTMLElement`
|
|
811
|
+
* (renderToString called in a browser, e.g. tests) implement, so the path
|
|
812
|
+
* does not depend on the shim's internal store. A bare Base-extending kit
|
|
813
|
+
* component without `setAttribute` is skipped.
|
|
814
|
+
*
|
|
815
|
+
* @param {any} instance
|
|
816
|
+
* @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
|
|
817
|
+
*/
|
|
818
|
+
function seedServerAttrs(instance, attrs) {
|
|
819
|
+
if (!instance || typeof instance.setAttribute !== 'function') return;
|
|
820
|
+
for (const [name, raw] of Object.entries(attrs)) {
|
|
821
|
+
instance.setAttribute(name, unescapeAttr(raw));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Append attributes the component set before render (reflected reflect:true
|
|
827
|
+
* properties, or an explicit `this.setAttribute` in the constructor /
|
|
828
|
+
* willUpdate) to the element's opening tag, skipping any name already present
|
|
829
|
+
* in the source tag. Reads via the standard `getAttributeNames` /
|
|
830
|
+
* `getAttribute` API so it works whether the instance is the server shim or a
|
|
831
|
+
* real `HTMLElement`. Returns the opening tag unchanged when there is nothing
|
|
832
|
+
* to add, so existing SSR output stays byte-identical when no component
|
|
833
|
+
* reflects, which preserves the elision on-vs-off differential invariant.
|
|
834
|
+
*
|
|
835
|
+
* @param {string} opening the element's opening tag, ending in `>`
|
|
836
|
+
* @param {any} instance
|
|
837
|
+
* @param {Set<string>} presentAttrNames lowercased names already in the source tag
|
|
838
|
+
* @returns {string}
|
|
839
|
+
*/
|
|
840
|
+
function appendReflectedAttrs(opening, instance, presentAttrNames) {
|
|
841
|
+
if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
|
|
842
|
+
let extra = '';
|
|
843
|
+
for (const rawName of instance.getAttributeNames()) {
|
|
844
|
+
const name = String(rawName).toLowerCase();
|
|
845
|
+
if (presentAttrNames.has(name)) continue;
|
|
846
|
+
const value = instance.getAttribute(rawName);
|
|
847
|
+
extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
|
|
848
|
+
}
|
|
849
|
+
if (!extra) return opening;
|
|
850
|
+
// Insert before the closing `>` (the opening tag is normalised to end in
|
|
851
|
+
// `>`; a self-closing source tag was already rewritten without the slash).
|
|
852
|
+
return `${opening.slice(0, -1)}${extra}>`;
|
|
853
|
+
}
|
|
854
|
+
|
|
767
855
|
/**
|
|
768
856
|
* Coerce attribute strings to typed properties on a component instance
|
|
769
857
|
* based on its static `properties` declaration.
|
|
@@ -780,7 +868,12 @@ function applyAttrsToInstance(instance, attrs, Cls) {
|
|
|
780
868
|
if (def.type === Number) instance[propName] = Number(raw);
|
|
781
869
|
else if (def.type === Boolean) instance[propName] = raw !== 'false';
|
|
782
870
|
else if (def.type === Object || def.type === Array) {
|
|
783
|
-
|
|
871
|
+
// `raw` is the entity-encoded attribute text (parseAttrs returns the
|
|
872
|
+
// literal characters between the quotes), so decode the HTML entities
|
|
873
|
+
// before JSON.parse. A JSON attribute carries `"` for every `"`;
|
|
874
|
+
// parsing it raw throws and would silently fall back to the string,
|
|
875
|
+
// leaving an Object/Array prop holding a string at SSR.
|
|
876
|
+
try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
|
|
784
877
|
} else instance[propName] = raw;
|
|
785
878
|
}
|
|
786
879
|
}
|
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
|
|