@webjsdev/core 0.7.65 → 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 (92) 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 +22 -21
  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 +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -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-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -39,6 +39,20 @@
39
39
  * JS under a hand-written overlay, so the shared code moves out instead.
40
40
  */
41
41
 
42
+ /**
43
+ * Whether a declared property has an HTML attribute at all. lit's two ways to
44
+ * say no, both honoured: `state: true` (internal state, which lit normalizes
45
+ * to `attribute: false`) and `attribute: false` (a public, property-only
46
+ * input). A property without one is never observed, never read from a source
47
+ * attribute at SSR, and never reflected; a `.prop` binding still reaches it.
48
+ *
49
+ * @param {{ state?: boolean, attribute?: string | false } | null | undefined} decl
50
+ * @returns {boolean}
51
+ */
52
+ export function isAttributeBacked(decl) {
53
+ return !!decl && decl.state !== true && decl.attribute !== false;
54
+ }
55
+
42
56
  /**
43
57
  * Read one attribute string into its declared property value.
44
58
  *
@@ -59,9 +73,13 @@
59
73
  * applied to the JSON and converter branches alone, which left a `String`-typed
60
74
  * prop holding the raw source text at SSR while `getAttribute()` on the client
61
75
  * returned the decoded one.
76
+ * @param {string} [propName] the property being read, named in the Boolean warning
62
77
  * @returns {unknown}
63
78
  */
64
- 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);
65
83
  if (def.converter && def.converter.fromAttribute) {
66
84
  // Deliberately UNGUARDED, and deliberately first (#1340). An author who
67
85
  // supplies a converter owns the whole read, which is the same rule
@@ -95,7 +113,13 @@ export function readAttributeValue(def, value) {
95
113
  return def.converter.fromAttribute(value, def.type);
96
114
  }
97
115
  if (def.type === Number) return value == null ? null : Number(value);
98
- 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
+ }
99
123
  if (def.type === Object || def.type === Array) {
100
124
  // An attribute that is not parseable JSON yields `null` rather than the raw
101
125
  // string (#1253), because a STRING is never a valid value for a property
@@ -110,7 +134,7 @@ export function readAttributeValue(def, value) {
110
134
  // The two readers now see the SAME attribute set, so this fallback is not
111
135
  // the only thing they agree on (#1341). Both resolve a name through
112
136
  // `resolveAttributeProperty` below, which matches the same names
113
- // `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` prop and ignores a name that
137
+ // `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` or `attribute: false` prop and ignores a name that
114
138
  // maps to no attribute-backed property exactly as the observed list does.
115
139
  // The SSR caller lowercases its source name first, because the browser
116
140
  // lowercases while parsing. And both are handed a value with every
@@ -121,16 +145,54 @@ export function readAttributeValue(def, value) {
121
145
  return value;
122
146
  }
123
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
+
124
166
  /** Kebab-case a property name for its default HTML attribute. @param {string} s */
125
167
  function hyphenate(s) {
126
168
  return s.replace(/([A-Z])/g, '-$1').toLowerCase();
127
169
  }
128
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
+
129
191
  /**
130
192
  * Resolve an attribute NAME to the property it feeds, and to that property's
131
193
  * normalised declaration.
132
194
  *
133
- * `state: true` props are skipped, because they are absent from
195
+ * `state: true` and `attribute: false` props are skipped, because they are absent from
134
196
  * `observedAttributes` and so the browser never delivers their attribute to any
135
197
  * reader. lit reaches the same outcome the same way, resolving through the very
136
198
  * map `observedAttributes` is built from (`reactive-element.ts`
@@ -168,14 +230,20 @@ function hyphenate(s) {
168
230
  * which case the caller must IGNORE the attribute entirely (#1341).
169
231
  */
170
232
  export function resolveAttributeProperty(Cls, attrName) {
171
- // Primary names first, so a prop literally named `foobar` keeps `foobar`
172
- // 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;
173
237
  let aliasHit;
174
238
  for (const { propName, def, primary, alias } of attributeBackedProps(Cls)) {
175
- if (primary === attrName) return { propName, def };
176
- 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
+ }
177
245
  }
178
- return aliasHit;
246
+ return primaryHit ?? aliasHit;
179
247
  }
180
248
 
181
249
  /**
@@ -195,13 +263,128 @@ export function observedAttributeNames(Cls) {
195
263
  return [...names];
196
264
  }
197
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
+
198
379
  /**
199
380
  * The attribute-backed props of `Cls` with the names each answers to.
200
381
  *
201
382
  * `primary` is the custom `attribute` option, matched VERBATIM, or else the
202
- * kebab-cased property name. `alias` is the LOWERCASED property name, lit's
203
- * default attribute name, present only when the prop has no custom `attribute`
204
- * 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,
205
388
  * `<quiz-player quizId=${id}>` against `{ quizId: Number }` reached neither
206
389
  * side: the parser lowercases the name to `quizid`, which matched nothing, so
207
390
  * the prop silently stayed undefined on the server AND the client (#1540).
@@ -215,31 +398,36 @@ function attributeBackedProps(Cls) {
215
398
  // Memoized per class (#1646): the SSR walk resolves every source attribute
216
399
  // of every instance through this list, and rebuilding it (one hyphenate per
217
400
  // declared prop) was a measurable share of a component's render. A class's
218
- // declarations never change after it is defined (a `static properties`
219
- // block throws at runtime, and the factory fixes the set), so the first
220
- // 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.
221
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.
222
407
  const hit = BACKED_PROPS.get(Cls);
223
- 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; }
224
411
  const built = buildAttributeBackedProps(Cls);
225
- BACKED_PROPS.set(Cls, built);
412
+ BACKED_PROPS.set(Cls, { epoch, merged, built });
226
413
  return built;
227
414
  }
228
415
  return buildAttributeBackedProps(Cls);
229
416
  }
230
417
 
231
- /** @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 }[] }>} */
232
419
  const BACKED_PROPS = new WeakMap();
233
420
 
234
421
  function buildAttributeBackedProps(Cls) {
235
- const props = (Cls && Cls.properties) || {};
422
+ const props = declaredProperties(Cls);
236
423
  const out = [];
237
- for (const [k, decl] of Object.entries(props)) {
238
- const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
239
- if (d.state) continue;
240
- const primary = d.attribute || hyphenate(k);
241
- const lower = k.toLowerCase();
242
- 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;
243
431
  out.push({ propName: k, def: d, primary, alias });
244
432
  }
245
433
  return out;
@@ -1,3 +1,4 @@
1
+ import { nothing, noChange } from './sentinels.js';
1
2
  /**
2
3
  * The template attribute-binding prefixes the renderers recognise.
3
4
  *
@@ -9,18 +10,6 @@
9
10
  * renderer (`render-server.js`, two sites) read it instead of hardcoding
10
11
  * the prefix characters inline.
11
12
  *
12
- * It is also the anchor for the elision drift guard. The analyser
13
- * (`packages/server/src/component-elision.js`) classifies every prefix as
14
- * either a client-behaviour ship signal (it drops at SSR and implies the
15
- * component does client work, so a component using it must ship) or an
16
- * SSR-safe round-trip (it survives into the served HTML, so it is not a
17
- * ship signal). The guard test
18
- * (`packages/server/test/elision/sigil-coverage.test.js`) asserts that
19
- * classification covers EXACTLY these keys, so a new prefix cannot be
20
- * added here without the analyser being taught which kind it is. That
21
- * closes the one gap the prototype-introspection guard
22
- * (`lifecycle-coverage.test.js`) cannot reach, because a sigil is syntax,
23
- * not a prototype method or a named export.
24
13
  *
25
14
  * @type {Readonly<Record<string, 'event' | 'prop' | 'bool'>>}
26
15
  */
@@ -42,48 +31,16 @@ export function isBindingPrefix(ch) {
42
31
  }
43
32
 
44
33
  /**
45
- * The HTML boolean attributes: present means on, absent means off, and the
46
- * value is meant to be empty. A boolean in a PLAIN hole on one of these renders
47
- * exactly like `?attr` (#1579), so `checked=${isDefault}` cannot serve the
48
- * `checked="false"` that HTML reads as checked. Lowercase, from the HTML spec's
49
- * attribute index.
50
- */
51
- const BOOLEAN_ATTRIBUTES = new Set([
52
- 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls',
53
- 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert',
54
- 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate',
55
- 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected',
56
- 'shadowrootclonable', 'shadowrootdelegatesfocus', 'shadowrootserializable',
57
- ]);
58
-
59
- /**
60
- * What a PLAIN attribute hole (`name=${value}`, unquoted, the whole value)
61
- * writes, or `null` when the attribute is omitted. The one rule both renderers
62
- * follow, so the server never serves an attribute the client then removes
63
- * (#1573, #1579):
64
- *
65
- * - `null` / `undefined` omit the attribute. This is the "omit" value, so a
66
- * conditional attribute is `aria-current=${active ? 'page' : null}`.
67
- * - `false` omits it too, EXCEPT on an `aria-*` attribute, where it writes
68
- * `"false"`. For a tri-state ARIA attribute (`aria-expanded`,
69
- * `aria-pressed`, `aria-checked`) `"false"` and absent mean different
70
- * things, so the value an author passes is the value the user agent gets.
71
- * - `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`,
72
- * and the rest) writes the empty value, exactly as `?attr=${true}` does. Anywhere
73
- * else it is `"true"`.
74
- * - anything else is `String(value)`.
75
- *
76
- * A quoted or mixed value (`title="${x}"`, `class="a ${x}"`) is not a plain
77
- * hole: the author wrote the attribute statically, so it stays and a nullish
78
- * piece reads as empty text.
34
+ * Serialize an ordinary whole-value attribute expression for SSR and client
35
+ * commits. Nullish values become empty strings and booleans stringify.
36
+ * `nothing` omits the attribute. `noChange` omits it on initial SSR; client
37
+ * updates handle that sentinel before serialization to retain the prior value.
79
38
  *
80
- * @param {string} name the attribute name as written
81
- * @param {unknown} value the hole's value (`live()` already unwrapped)
39
+ * @param {string} _name attribute name (kept for the shared call signature)
40
+ * @param {unknown} value the expression value (`live()` already unwrapped)
82
41
  * @returns {string | null}
83
42
  */
84
- export function attrHoleValue(name, value) {
85
- if (value == null) return null;
86
- if (value === false) return /^aria-/i.test(name) ? 'false' : null;
87
- if (value === true && BOOLEAN_ATTRIBUTES.has(String(name).toLowerCase())) return '';
88
- return String(value);
43
+ export function attrHoleValue(_name, value) {
44
+ if (value === nothing || value === noChange) return null;
45
+ return String(value ?? '');
89
46
  }