@webjsdev/core 0.8.0 → 0.9.0

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.
Files changed (81) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +22 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-reader.js +197 -23
  19. package/src/binding-prefixes.js +0 -12
  20. package/src/component/lifecycle.js +760 -209
  21. package/src/component/reactive.js +2 -10
  22. package/src/component/server-element.js +23 -1
  23. package/src/component.d.ts +151 -41
  24. package/src/context.d.ts +94 -18
  25. package/src/context.js +341 -217
  26. package/src/converters.d.ts +7 -0
  27. package/src/converters.js +38 -0
  28. package/src/css.d.ts +32 -6
  29. package/src/css.js +178 -14
  30. package/src/directive.d.ts +58 -0
  31. package/src/directive.js +119 -0
  32. package/src/directives.d.ts +38 -1
  33. package/src/directives.js +158 -1
  34. package/src/form-action.d.ts +13 -0
  35. package/src/form-action.js +139 -4
  36. package/src/hot.js +23 -0
  37. package/src/html.d.ts +4 -0
  38. package/src/html.js +35 -3
  39. package/src/is-server.d.ts +2 -0
  40. package/src/is-server.js +18 -0
  41. package/src/jsx-guard.js +130 -0
  42. package/src/metadata.d.ts +22 -3
  43. package/src/nav.d.ts +4 -1
  44. package/src/nav.js +44 -11
  45. package/src/optimistic.js +2 -2
  46. package/src/refresh-header.js +52 -0
  47. package/src/registry.js +10 -1
  48. package/src/render-client/adopt-slots.js +8 -3
  49. package/src/render-client/parts.js +745 -31
  50. package/src/render-client/reconciler.js +56 -11
  51. package/src/render-client/template-compiler.js +139 -44
  52. package/src/render-client.d.ts +5 -1
  53. package/src/render-server/attrs.js +2 -2
  54. package/src/render-server/compile.js +34 -0
  55. package/src/render-server/errors.js +37 -0
  56. package/src/render-server/expand.js +61 -8
  57. package/src/render-server/served-attrs.js +1 -2
  58. package/src/render-server/walk.js +135 -3
  59. package/src/render-server.d.ts +3 -0
  60. package/src/render-server.js +2 -0
  61. package/src/repeat.d.ts +7 -2
  62. package/src/repeat.js +20 -6
  63. package/src/router-client/dom-differ.js +17 -0
  64. package/src/router-client/fetch-apply.js +86 -5
  65. package/src/router-client/navigator.js +45 -9
  66. package/src/router-client/prefetch.js +3 -4
  67. package/src/router-client/root-attrs.js +43 -0
  68. package/src/router-client/stream.js +7 -0
  69. package/src/router-client/swap.js +4 -1
  70. package/src/routes.d.ts +75 -18
  71. package/src/serialize.d.ts +2 -0
  72. package/src/serialize.js +35 -4
  73. package/src/signal.d.ts +7 -0
  74. package/src/signal.js +26 -6
  75. package/src/slot/polyfills.js +3 -3
  76. package/src/slot.js +4 -8
  77. package/src/static-html.d.ts +22 -0
  78. package/src/static-html.js +144 -0
  79. package/src/task.d.ts +63 -20
  80. package/src/task.js +326 -293
  81. package/src/webjs-config.d.ts +1 -8
@@ -73,9 +73,13 @@ export function isAttributeBacked(decl) {
73
73
  * applied to the JSON and converter branches alone, which left a `String`-typed
74
74
  * prop holding the raw source text at SSR while `getAttribute()` on the client
75
75
  * returned the decoded one.
76
+ * @param {string} [propName] the property being read, named in the Boolean warning
76
77
  * @returns {unknown}
77
78
  */
78
- export function readAttributeValue(def, value) {
79
+ export function readAttributeValue(def, value, propName) {
80
+ // Lit's function form: a bare `converter: (value, type) => ...` IS the
81
+ // `fromAttribute` half, and reflection uses the default serialization.
82
+ if (typeof def.converter === 'function') return def.converter(value, def.type);
79
83
  if (def.converter && def.converter.fromAttribute) {
80
84
  // Deliberately UNGUARDED, and deliberately first (#1340). An author who
81
85
  // supplies a converter owns the whole read, which is the same rule
@@ -109,7 +113,13 @@ export function readAttributeValue(def, value) {
109
113
  return def.converter.fromAttribute(value, def.type);
110
114
  }
111
115
  if (def.type === Number) return value == null ? null : Number(value);
112
- if (def.type === Boolean) return value != null && value !== 'false';
116
+ if (def.type === Boolean) {
117
+ // A boolean attribute is on whenever it is PRESENT, as in HTML and Lit,
118
+ // so `open="false"` is true. Say so once per property, since the literal
119
+ // reads as off to anyone coming from a JSX prop.
120
+ if (value === 'false') warnFalseBoolean(def, propName);
121
+ return value != null;
122
+ }
113
123
  if (def.type === Object || def.type === Array) {
114
124
  // An attribute that is not parseable JSON yields `null` rather than the raw
115
125
  // string (#1253), because a STRING is never a valid value for a property
@@ -135,11 +145,49 @@ export function readAttributeValue(def, value) {
135
145
  return value;
136
146
  }
137
147
 
148
+ /** Declarations already warned about a literal "false". @type {WeakSet<object>} */
149
+ const WARNED_FALSE = new WeakSet();
150
+
151
+ /**
152
+ * @param {object} def
153
+ * @param {string | undefined} propName
154
+ */
155
+ function warnFalseBoolean(def, propName) {
156
+ if (WARNED_FALSE.has(def)) return;
157
+ WARNED_FALSE.add(def);
158
+ const name = propName ? `\`${String(propName)}\`` : 'A Boolean property';
159
+ console.warn(
160
+ `[webjs] ${name} received the attribute value "false", which is TRUE: a Boolean property is on whenever its ` +
161
+ `attribute is present, as in HTML and Lit. To turn it off, leave the attribute out, or bind it with ` +
162
+ `?attr=\${false} in a template.`,
163
+ );
164
+ }
165
+
138
166
  /** Kebab-case a property name for its default HTML attribute. @param {string} s */
139
167
  function hyphenate(s) {
140
168
  return s.replace(/([A-Z])/g, '-$1').toLowerCase();
141
169
  }
142
170
 
171
+ /**
172
+ * The attribute a declared property reads and reflects to, Lit's
173
+ * `attribute` option: a string is used as written, `false` (or
174
+ * `state: true`) means none, and anything else (absent, or `true`) is Lit's
175
+ * default name, the LOWERCASED property name (`isOpen` -> `isopen`). The
176
+ * kebab spelling (`is-open`) stays readable as an alias (see
177
+ * `attributeBackedProps`) but is never what a property reflects to.
178
+ *
179
+ * @param {string} propName
180
+ * @param {{ attribute?: string | boolean, state?: boolean }} decl normalised declaration
181
+ * @returns {string | undefined}
182
+ */
183
+ export function attributeNameFor(propName, decl) {
184
+ if (!isAttributeBacked(decl)) return undefined;
185
+ if (typeof decl.attribute === 'string') return decl.attribute;
186
+ // A symbol-keyed property has no default attribute name, as in Lit.
187
+ if (typeof propName !== 'string') return undefined;
188
+ return propName.toLowerCase();
189
+ }
190
+
143
191
  /**
144
192
  * Resolve an attribute NAME to the property it feeds, and to that property's
145
193
  * normalised declaration.
@@ -182,14 +230,20 @@ function hyphenate(s) {
182
230
  * which case the caller must IGNORE the attribute entirely (#1341).
183
231
  */
184
232
  export function resolveAttributeProperty(Cls, attrName) {
185
- // Primary names first, so a prop literally named `foobar` keeps `foobar`
186
- // even when a sibling `fooBar` would answer to it as an alias.
233
+ // Primary names first, then aliases. Two props can share a primary name
234
+ // (`fooBar` and `foobar` both default to `foobar`); the prop literally
235
+ // named after the attribute keeps it, so a sibling never steals a real prop.
236
+ let primaryHit;
187
237
  let aliasHit;
188
238
  for (const { propName, def, primary, alias } of attributeBackedProps(Cls)) {
189
- if (primary === attrName) return { propName, def };
190
- if (aliasHit === undefined && alias === attrName) aliasHit = { propName, def };
239
+ if (primary === attrName) {
240
+ if (propName === attrName) return { propName, def };
241
+ if (primaryHit === undefined) primaryHit = { propName, def };
242
+ } else if (aliasHit === undefined && alias === attrName) {
243
+ aliasHit = { propName, def };
244
+ }
191
245
  }
192
- return aliasHit;
246
+ return primaryHit ?? aliasHit;
193
247
  }
194
248
 
195
249
  /**
@@ -209,13 +263,128 @@ export function observedAttributeNames(Cls) {
209
263
  return [...names];
210
264
  }
211
265
 
266
+ /**
267
+ * Every reactive property `Cls` declares, its own over its superclasses', the
268
+ * way Lit's `finalize` merges `elementProperties` down the chain. A class's own
269
+ * `static properties` (Lit's spelling, a field or a `static get`) and the map
270
+ * the `WebComponent({ ... })` factory puts on the base it returns are the same
271
+ * thing here, so a subclass adding props keeps its parent's, and a factory
272
+ * class may add a `static properties` block of its own.
273
+ *
274
+ * Memoized per class: the accessors (`installAccessors`), the attribute list
275
+ * (`attributeBackedProps`) and reflection all read this one map, so they
276
+ * cannot disagree about which props exist. The map is the same OBJECT for as
277
+ * long as the class's declarations, and its superclasses', are unchanged, so
278
+ * a memo derived from it is keyed on that identity.
279
+ *
280
+ * A memo holds its superclasses' declarations merged in, so `createProperty`
281
+ * on a parent has to reach every subclass already read, which a WeakMap
282
+ * cannot enumerate. So each memo records its parent's map and its own class's
283
+ * version, and stays valid while both are unchanged: a change rebuilds exactly
284
+ * the changed class and the classes below it. Checking that walks the chain,
285
+ * so it runs only after some class changed (`epoch` moved); until then a read
286
+ * is one lookup.
287
+ *
288
+ * @param {any} Cls the component class
289
+ * @returns {Record<string, any>}
290
+ */
291
+ export function declaredProperties(Cls) {
292
+ if (!Cls || typeof Cls !== 'function') return (Cls && Cls.properties) || {};
293
+ const hit = DECLARED.get(Cls);
294
+ if (hit && hit.epoch === epoch) return hit.merged;
295
+ const parent = Object.getPrototypeOf(Cls);
296
+ const inherited = parent && parent !== Function.prototype ? declaredProperties(parent) : ROOT;
297
+ const version = VERSION.get(Cls) ?? 0;
298
+ if (hit && hit.inherited === inherited && hit.version === version) {
299
+ // Unaffected by whatever changed: keep the same map, valid at this epoch.
300
+ hit.epoch = epoch;
301
+ return hit.merged;
302
+ }
303
+ const own = Object.hasOwn(Cls, 'properties') ? Cls.properties : undefined;
304
+ const created = CREATED.get(Cls);
305
+ let merged = own && typeof own === 'object' ? { ...inherited, ...own } : inherited;
306
+ if (created) merged = { ...merged, ...created };
307
+ DECLARED.set(Cls, { epoch, inherited, version, merged });
308
+ return merged;
309
+ }
310
+
311
+ /** @type {WeakMap<Function, { epoch: number, inherited: Record<string, any>, version: number, merged: Record<string, any> }>} */
312
+ const DECLARED = new WeakMap();
313
+
314
+ /** The declarations above every class: none. Shared, so a root-level memo can be compared by identity. */
315
+ const ROOT = Object.freeze({});
316
+
317
+ /** Per class, how many times its own declarations changed (`createProperty`). @type {WeakMap<Function, number>} */
318
+ const VERSION = new WeakMap();
319
+
320
+ /** Moves whenever any class's declarations change, so a memo knows to re-check its chain. */
321
+ let epoch = 0;
322
+
323
+ /**
324
+ * The current declarations epoch, for a memo outside this module derived from
325
+ * `declaredProperties` (lifecycle.js's `elementProperties`): while it has not
326
+ * moved, no class's declarations changed and a memo needs no re-check.
327
+ * @returns {number}
328
+ */
329
+ export function declarationsEpoch() {
330
+ return epoch;
331
+ }
332
+
333
+ /**
334
+ * Properties added to a class through Lit's `static createProperty(name,
335
+ * options)`, per class. Merged LAST in `declaredProperties`, so a created
336
+ * property wins over a same-named `static properties` entry, as it does in Lit
337
+ * (`createProperty` writes straight into `elementProperties`).
338
+ * @type {WeakMap<Function, Record<string, any>>}
339
+ */
340
+ const CREATED = new WeakMap();
341
+
342
+ /**
343
+ * Normalise one declaration: the bare-constructor shorthand (`count: Number`)
344
+ * becomes `{ type: Number }`, an options object is returned as is.
345
+ * @param {unknown} decl
346
+ * @returns {Record<string, any>}
347
+ */
348
+ export function normalizeDecl(decl) {
349
+ return typeof decl === 'object' && decl !== null ? /** @type {any} */ (decl) : { type: decl };
350
+ }
351
+
352
+ /**
353
+ * Record a property created through `static createProperty()` and drop the
354
+ * class's memoized declarations so the next read includes it.
355
+ * @param {Function} Cls
356
+ * @param {string} name
357
+ * @param {Record<string, any>} decl
358
+ */
359
+ export function addCreatedProperty(Cls, name, decl) {
360
+ let own = CREATED.get(Cls);
361
+ if (!own) CREATED.set(Cls, (own = {}));
362
+ own[name] = decl;
363
+ invalidateClass(Cls);
364
+ }
365
+
366
+ /**
367
+ * Drop every memo this module keeps for `Cls`, so its declarations are read
368
+ * again on next use.
369
+ * @param {Function} Cls
370
+ */
371
+ export function invalidateClass(Cls) {
372
+ // This class's memo is rebuilt, which gives it a new map, and every
373
+ // subclass's memo then sees a parent map it was not built from. Classes on
374
+ // other chains re-check once and keep their maps.
375
+ VERSION.set(Cls, (VERSION.get(Cls) ?? 0) + 1);
376
+ epoch++;
377
+ }
378
+
212
379
  /**
213
380
  * The attribute-backed props of `Cls` with the names each answers to.
214
381
  *
215
382
  * `primary` is the custom `attribute` option, matched VERBATIM, or else the
216
- * kebab-cased property name. `alias` is the LOWERCASED property name, lit's
217
- * default attribute name, present only when the prop has no custom `attribute`
218
- * and the lowercased name differs from the kebab one. Without it,
383
+ * LOWERCASED property name, Lit's default attribute name and the one a
384
+ * reflected property writes. `alias` is the KEBAB-cased property name, kept
385
+ * as a read-only alias (WebJs's earlier default, and the spelling HTML
386
+ * authors reach for), present only when the prop has no custom `attribute`
387
+ * and the kebab name differs from the lowercase one. Without it,
219
388
  * `<quiz-player quizId=${id}>` against `{ quizId: Number }` reached neither
220
389
  * side: the parser lowercases the name to `quizid`, which matched nothing, so
221
390
  * the prop silently stayed undefined on the server AND the client (#1540).
@@ -229,31 +398,36 @@ function attributeBackedProps(Cls) {
229
398
  // Memoized per class (#1646): the SSR walk resolves every source attribute
230
399
  // of every instance through this list, and rebuilding it (one hyphenate per
231
400
  // declared prop) was a measurable share of a component's render. A class's
232
- // declarations never change after it is defined (a `static properties`
233
- // block throws at runtime, and the factory fixes the set), so the first
234
- // walk is the only one.
401
+ // declarations are fixed once it is defined (`declaredProperties` reads them
402
+ // once per class), so the first walk is the only one.
235
403
  if (Cls && typeof Cls === 'function') {
404
+ // Valid while no declarations changed anywhere (one lookup); after a
405
+ // change, while the class's declarations map is the same object, which it
406
+ // stays unless this class or a superclass changed.
236
407
  const hit = BACKED_PROPS.get(Cls);
237
- if (hit) return hit;
408
+ if (hit && hit.epoch === epoch) return hit.built;
409
+ const merged = declaredProperties(Cls);
410
+ if (hit && hit.merged === merged) { hit.epoch = epoch; return hit.built; }
238
411
  const built = buildAttributeBackedProps(Cls);
239
- BACKED_PROPS.set(Cls, built);
412
+ BACKED_PROPS.set(Cls, { epoch, merged, built });
240
413
  return built;
241
414
  }
242
415
  return buildAttributeBackedProps(Cls);
243
416
  }
244
417
 
245
- /** @type {WeakMap<Function, { propName: string, def: any, primary: string, alias: string | undefined }[]>} */
418
+ /** @type {WeakMap<Function, { epoch: number, merged: Record<string, any>, built: { propName: string, def: any, primary: string, alias: string | undefined }[] }>} */
246
419
  const BACKED_PROPS = new WeakMap();
247
420
 
248
421
  function buildAttributeBackedProps(Cls) {
249
- const props = (Cls && Cls.properties) || {};
422
+ const props = declaredProperties(Cls);
250
423
  const out = [];
251
- for (const [k, decl] of Object.entries(props)) {
252
- const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
253
- if (!isAttributeBacked(d)) continue;
254
- const primary = d.attribute || hyphenate(k);
255
- const lower = k.toLowerCase();
256
- const alias = !d.attribute && lower !== primary ? lower : undefined;
424
+ // Symbol keys too: one reaches an attribute through its `attribute` option.
425
+ for (const k of Reflect.ownKeys(props)) {
426
+ const d = normalizeDecl(props[k]);
427
+ const primary = attributeNameFor(k, d);
428
+ if (primary === undefined) continue;
429
+ const kebab = typeof k === 'string' ? hyphenate(k) : primary;
430
+ const alias = typeof d.attribute !== 'string' && kebab !== primary ? kebab : undefined;
257
431
  out.push({ propName: k, def: d, primary, alias });
258
432
  }
259
433
  return out;
@@ -10,18 +10,6 @@ import { nothing, noChange } from './sentinels.js';
10
10
  * renderer (`render-server.js`, two sites) read it instead of hardcoding
11
11
  * the prefix characters inline.
12
12
  *
13
- * It is also the anchor for the elision drift guard. The analyser
14
- * (`packages/server/src/component-elision.js`) classifies every prefix as
15
- * either a client-behaviour ship signal (it drops at SSR and implies the
16
- * component does client work, so a component using it must ship) or an
17
- * SSR-safe round-trip (it survives into the served HTML, so it is not a
18
- * ship signal). The guard test
19
- * (`packages/server/test/elision/sigil-coverage.test.js`) asserts that
20
- * classification covers EXACTLY these keys, so a new prefix cannot be
21
- * added here without the analyser being taught which kind it is. That
22
- * closes the one gap the prototype-introspection guard
23
- * (`lifecycle-coverage.test.js`) cannot reach, because a sigil is syntax,
24
- * not a prototype method or a named export.
25
13
  *
26
14
  * @type {Readonly<Record<string, 'event' | 'prop' | 'bool'>>}
27
15
  */