@webjsdev/core 0.7.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -147,8 +147,143 @@ function defaultHasChanged(a, b) {
147
147
  * ```
148
148
  */
149
149
 
150
- // Base class choice: real HTMLElement on the browser, a dummy on the server.
151
- const Base = isBrowser ? HTMLElement : /** @type {any} */ (class {});
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
  // ---------------------------------------------------------------------------
@@ -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', 'setAttribute', 'getAttribute', 'removeAttribute',
327
- 'hasAttribute', 'dispatchEvent', 'querySelector', 'querySelectorAll',
331
+ 'attachShadow', 'querySelector', 'querySelectorAll',
328
332
  'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
329
333
  ]);
330
334
 
@@ -383,7 +387,7 @@ 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
- const opening = selfClose ? `<${tag}${attrs}>` : match;
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,8 +398,29 @@ async function injectDSD(html, ctx) {
394
398
  // properties` type. Property bindings take priority on a name
395
399
  // collision because they preserve the original JS reference.
396
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);
397
411
  applyAttrsToInstance(instance, attrMap, Cls);
398
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);
399
424
  let tpl = instance.render ? instance.render() : '';
400
425
  if (tpl && typeof tpl.then === 'function') tpl = await tpl;
401
426
  // Render the template to HTML. injectDSD recurses on the result so
@@ -764,6 +789,55 @@ function parseAttrs(attrStr) {
764
789
  return out;
765
790
  }
766
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
+
767
841
  /**
768
842
  * Coerce attribute strings to typed properties on a component instance
769
843
  * based on its static `properties` declaration.
@@ -780,7 +854,12 @@ function applyAttrsToInstance(instance, attrs, Cls) {
780
854
  if (def.type === Number) instance[propName] = Number(raw);
781
855
  else if (def.type === Boolean) instance[propName] = raw !== 'false';
782
856
  else if (def.type === Object || def.type === Array) {
783
- try { instance[propName] = JSON.parse(raw); } catch { instance[propName] = raw; }
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 `&quot;` 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; }
784
863
  } else instance[propName] = raw;
785
864
  }
786
865
  }
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