@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
@@ -1,5 +1,6 @@
1
1
  import { BINDING_PREFIXES } from '../binding-prefixes.js';
2
2
  import { isVoidElement, isRawtextTag, isTextOnlyTag } from './html-scan.js';
3
+ import { assertNoJsxIsms } from '../jsx-guard.js';
3
4
 
4
5
  /**
5
6
  * The SSR template compiler (#1646): one tokenizer pass per template, run
@@ -74,6 +75,8 @@ export function _compileCount() { return compiled; }
74
75
  export function compile(strings) {
75
76
  let ops = cache.get(strings);
76
77
  if (ops === undefined) {
78
+ // `className=`, `htmlFor=`, `<Link>`: JSX that HTML silently ignores.
79
+ assertNoJsxIsms(strings);
77
80
  ops = tokenize(strings);
78
81
  cache.set(strings, ops);
79
82
  }
@@ -115,6 +118,9 @@ function tokenize(strings) {
115
118
  let attrName = '';
116
119
  let attrStart = 0; // index into `chunk` of the attribute's first character
117
120
  let attrQuote = '';
121
+ // An unquoted value holding a hole, written quoted (`a=x${v}` -> `a="x..."`),
122
+ // so a value with a space or `>` in it cannot add an attribute or close the tag.
123
+ let synthQuote = false;
118
124
  let commentDashes = 0;
119
125
  let currentTag = '';
120
126
  /** @type {Op | null} */
@@ -181,8 +187,10 @@ function tokenize(strings) {
181
187
  state = 'text';
182
188
  };
183
189
 
190
+ let bindingQuote = '';
184
191
  for (let i = 0; i < strings.length; i++) {
185
- const s = strings[i];
192
+ const s = bindingQuote ? strings[i].slice(1) : strings[i];
193
+ bindingQuote = '';
186
194
  for (let j = 0; j < s.length; j++) {
187
195
  const c = s[j];
188
196
  switch (state) {
@@ -321,6 +329,18 @@ function tokenize(strings) {
321
329
  else chunk += c;
322
330
  break;
323
331
  case 'attr-quoted':
332
+ if (synthQuote) {
333
+ // It ends where the unquoted value it stands for ends, and the
334
+ // character that ends it is read again as the tag's own.
335
+ if (WS.test(c) || c === '>' || (c === '/' && s[j + 1] === '>')) {
336
+ chunk += '"';
337
+ synthQuote = false;
338
+ state = 'in-tag';
339
+ attrName = '';
340
+ j--;
341
+ } else chunk += c === '"' ? '&quot;' : c;
342
+ break;
343
+ }
324
344
  chunk += c;
325
345
  if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
326
346
  break;
@@ -366,6 +386,22 @@ function tokenize(strings) {
366
386
  }
367
387
 
368
388
  if (i < strings.length - 1) {
389
+ // An unquoted value with more than the one hole in it (`a=x${v}`,
390
+ // `a=${x}${y}`, `a=${x}/z`) is written quoted, so the walker resolves it
391
+ // whole through the quoted-value path, as the client does.
392
+ if (BINDING_PREFIXES[attrName[0]] === undefined && !synthQuote) {
393
+ const next = strings[i + 1];
394
+ const ends = next !== '' && (WS.test(next[0]) || next[0] === '>' || (next[0] === '/' && next[1] === '>'));
395
+ if (state === 'after-eq' && !ends) {
396
+ chunk += '"';
397
+ state = 'attr-quoted'; attrQuote = '"'; synthQuote = true;
398
+ } else if (state === 'attr-unquoted') {
399
+ const eq = chunk.indexOf('=', attrStart) + 1;
400
+ const gap = /^\s*/.exec(chunk.slice(eq))[0].length;
401
+ chunk = chunk.slice(0, eq + gap) + '"' + chunk.slice(eq + gap).replace(/"/g, '&quot;');
402
+ state = 'attr-quoted'; attrQuote = '"'; synthQuote = true;
403
+ }
404
+ }
369
405
  // A value hole sits here. Its op says where, and the walker decides what.
370
406
  if (state === 'comment' || state === 'comment-start' || state === 'comment-start-dash' || state === 'comment-bang') {
371
407
  flush();
@@ -393,9 +429,21 @@ function tokenize(strings) {
393
429
  });
394
430
  state = 'in-tag';
395
431
  attrName = '';
432
+ } else if (state === 'attr-quoted' && BINDING_PREFIXES[attrName[0]] !== undefined) {
433
+ const lead = chunk.slice(attrStart);
434
+ if (!lead.endsWith('=' + attrQuote) || !strings[i + 1].startsWith(attrQuote)) {
435
+ throw new Error('Event, property and boolean bindings require exactly one expression with no surrounding text.');
436
+ }
437
+ chunk = chunk.slice(0, attrStart);
438
+ flush();
439
+ ops.push({ t: HOLE, k: ATTR, kind: BINDING_PREFIXES[attrName[0]], name: attrName.slice(1), tag: currentTag, lead: lead.slice(0, -1) });
440
+ bindingQuote = attrQuote;
441
+ state = 'in-tag';
442
+ attrName = '';
396
443
  } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
444
+ const prefix = chunk.slice(attrStart);
397
445
  flush();
398
- ops.push({ t: HOLE, k: VALUE, name: attrName, tag: currentTag });
446
+ ops.push({ t: HOLE, k: VALUE, name: attrName, tag: currentTag, quote: state === 'attr-quoted' ? attrQuote : '', prefix });
399
447
  } else {
400
448
  // A hole in a tag name, between attributes, in a close tag or a markup
401
449
  // declaration: nothing is emitted for it, as before.
@@ -36,6 +36,43 @@ export function browserMemberHint(e) {
36
36
  return null;
37
37
  }
38
38
 
39
+ /**
40
+ * Lit decorator names. Bun runs legacy decorators, so a decorated class fails
41
+ * there with a `ReferenceError` naming the undefined decorator; Node and the
42
+ * browser fail the whole module with a bare `SyntaxError` instead.
43
+ */
44
+ const DECORATOR_NAMES = new Set([
45
+ 'customElement', 'property', 'state', 'query', 'queryAll', 'queryAsync',
46
+ 'queryAssignedElements', 'queryAssignedNodes', 'eventOptions', 'provide', 'consume',
47
+ ]);
48
+
49
+ /**
50
+ * If `e` is the recognisable failure of a Lit DECORATOR reaching the runtime
51
+ * (WebJs has no build step and decorators are not erasable TypeScript, so the
52
+ * type stripper passes them through), return the WebJs fix; otherwise null.
53
+ * Node and the browser report a bare `SyntaxError: Invalid or unexpected
54
+ * token` for the `@`, which names neither cause nor fix; Bun reports a
55
+ * `ReferenceError: property is not defined`. The `no-non-erasable-typescript`
56
+ * check rule catches the same thing at edit time with a per-decorator fix.
57
+ * @param {unknown} e
58
+ * @returns {string | null}
59
+ */
60
+ export function decoratorHint(e) {
61
+ const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
62
+ const fix =
63
+ 'WebJs runs no build step, so decorators cannot run. Declare reactive properties in `static properties` ' +
64
+ '(or the `WebComponent({ ... })` factory), use `state: true` for `@state`, call `MyEl.register(tag)` for ' +
65
+ '`@customElement`, and write `@query` as a getter over `this.renderRoot`.';
66
+ if (e instanceof SyntaxError && /Invalid or unexpected token|Unexpected token '@'|Unexpected character '@'|[Dd]ecorators? /.test(msg)) {
67
+ return `A module failed to parse. If it uses a decorator (\`@property\`, \`@customElement\`, ...), that is the cause. ${fix}`;
68
+ }
69
+ const m = /^(\w+) is not defined\b/.exec(msg);
70
+ if (e instanceof ReferenceError && m && DECORATOR_NAMES.has(m[1])) {
71
+ return `\`${m[1]}\` is a Lit decorator, which WebJs does not provide. ${fix}`;
72
+ }
73
+ return null;
74
+ }
75
+
39
76
  // Browser-only names whose absence during SSR produces a recognisable error.
40
77
  // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
41
78
  // these at edit time; this turns the runtime SSR crash into the same guidance.
@@ -1,11 +1,12 @@
1
- import { stylesToString, isCSS } from '../css.js';
1
+ import { stylesToString, finalizeStyles, lightDomStylesMessage } from '../css.js';
2
2
  import { unsafeHTML } from '../directives.js';
3
3
  import { escapeAttr } from '../escape.js';
4
- import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
4
+ import { defaultSSRErrorTemplate, browserMemberHint, isProd } from './errors.js';
5
5
  import {
6
- appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
6
+ applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
7
7
  withHostMarker,
8
8
  } from './attrs.js';
9
+ import { serveInstanceAttrs } from './served-attrs.js';
9
10
 
10
11
  /**
11
12
  * Custom-element expansion inside the op walk (#1646): what `injectDSD`
@@ -44,6 +45,7 @@ const NON_BLANK = /\S/;
44
45
  export function beginComponent(w, tag, Cls, opening, attrs) {
45
46
  if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
46
47
  const isShadow = Cls.shadow === true;
48
+ if (!isShadow) reportLightDomStyles(w, tag, Cls);
47
49
  const frame = {
48
50
  kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
49
51
  pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
@@ -113,10 +115,11 @@ export async function settleRender(frame) {
113
115
  }
114
116
 
115
117
  function afterRender(frame) {
116
- // Surface attributes the component set up to and including render() that
117
- // were not in the source tag (reflected props, an explicit setAttribute,
118
- // a host mutation inside render()). Reading after render() captures all.
119
- frame.opening = appendReflectedAttrs(frame.opening, frame.instance, frame.presentAttrNames);
118
+ // Serve the attributes as the component left them up to and including
119
+ // render(): one it added is appended, one it changed carries its new value,
120
+ // and one it removed is dropped, as lit-ssr's renderAttributes() does (see
121
+ // served-attrs.js). Reading after render() captures all.
122
+ frame.opening = serveInstanceAttrs(frame.opening, frame.instance, frame.presentAttrNames);
120
123
  // Mark LIGHT-DOM hosts for the `@layer webjs-host` display:block default.
121
124
  // Shadow hosts are NOT marked: a document-level rule on the host beats the
122
125
  // shadow tree's own `:host { display }`.
@@ -140,7 +143,7 @@ export async function finishComponent(w, frame, children) {
140
143
  // Native <slot> stays as-is in the DSD template: the browser projects
141
144
  // the host's light children natively. The authored children stay in
142
145
  // place after the template, and the source end tag is kept.
143
- return `${frame.opening}<template shadowrootmode="open">${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
146
+ return `${frame.opening}${dsdOpenTag(Cls)}${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
144
147
  }
145
148
  if (!NON_BLANK.test(inner)) {
146
149
  // An empty rendered template (a decorator component with no render(),
@@ -167,10 +170,12 @@ export async function finishComponent(w, frame, children) {
167
170
  async function renderOwnTemplate(w, frame, children) {
168
171
  const owner = w.owner;
169
172
  w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
173
+ w.componentDepth = (w.componentDepth || 0) + 1;
170
174
  try {
171
175
  return await w.capture(() => w.value(frame.tpl));
172
176
  } finally {
173
177
  w.owner = owner;
178
+ w.componentDepth -= 1;
174
179
  }
175
180
  }
176
181
 
@@ -202,12 +207,54 @@ function partitionChildren(children, segments) {
202
207
  return groups;
203
208
  }
204
209
 
210
+ /**
211
+ * The Declarative Shadow DOM opening tag for a shadow component, carrying
212
+ * Lit's `static shadowRootOptions` as the template attributes the HTML parser
213
+ * maps onto `attachShadow()`: the mode, `delegatesFocus`, `clonable` and
214
+ * `serializable`. The common case (no options) is the constant open tag.
215
+ * @param {any} Cls
216
+ * @returns {string}
217
+ */
218
+ export function dsdOpenTag(Cls) {
219
+ const o = Cls.shadowRootOptions;
220
+ if (!o) return OPEN_DSD;
221
+ const mode = o.mode === 'closed' ? 'closed' : 'open';
222
+ if (mode === 'open' && !o.delegatesFocus && !o.clonable && !o.serializable) return OPEN_DSD;
223
+ return `<template shadowrootmode="${mode}"` +
224
+ (o.delegatesFocus ? ' shadowrootdelegatesfocus' : '') +
225
+ (o.clonable ? ' shadowrootclonable' : '') +
226
+ (o.serializable ? ' shadowrootserializable' : '') + '>';
227
+ }
228
+ const OPEN_DSD = '<template shadowrootmode="open">';
229
+
205
230
  function styleString(Cls) {
206
- const rawStyles = Cls.styles;
207
- const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
208
- return stylesToString(styleList);
231
+ // `elementStyles` is the class's finalized list (Lit's `finalizeStyles`);
232
+ // a class not built on WebComponent has none, so flatten its styles here.
233
+ return stylesToString(Cls.elementStyles ?? finalizeStyles(Cls.styles));
209
234
  }
210
235
 
236
+ /** Light-DOM classes already reported for `static styles`. @type {WeakSet<Function>} */
237
+ const REPORTED_LIGHT_STYLES = new WeakSet();
238
+
239
+ /**
240
+ * A light-DOM component's `static styles` never apply (there is no shadow
241
+ * root to adopt them into). Report it once per class in dev, on the server,
242
+ * so the cause shows up in the terminal as well as the browser console.
243
+ * @param {import('./walk.js').Walker} w
244
+ * @param {string} tag
245
+ * @param {any} Cls
246
+ */
247
+ function reportLightDomStyles(w, tag, Cls) {
248
+ if (!Cls.styles || REPORTED_LIGHT_STYLES.has(Cls)) return;
249
+ const dev = w.dev === undefined ? !isProd() : !!w.dev;
250
+ if (!dev) return;
251
+ REPORTED_LIGHT_STYLES.add(Cls);
252
+ console.error(lightDomStylesMessage(tag));
253
+ }
254
+
255
+ /** Key of the hook the server installs to hear about isolated component failures. */
256
+ const COMPONENT_ERROR_HOOK = Symbol.for('webjs.ssr.componentError');
257
+
211
258
  /**
212
259
  * Per-component error isolation (#469). The element renders a
213
260
  * component-scoped error state in its place (renderError() customises it;
@@ -228,20 +275,28 @@ async function errorState(w, frame) {
228
275
  } else {
229
276
  console.error(`[webjs] SSR failed for <${tag}>:`, e);
230
277
  }
278
+ // The page still renders (the failure is isolated to this element), so the
279
+ // server hears about it only through this hook: @webjsdev/server installs it
280
+ // and routes it to the current request's dev status.
281
+ const report = /** @type {any} */ (globalThis)[COMPONENT_ERROR_HOOK];
282
+ if (typeof report === 'function') {
283
+ try { report(tag, e); } catch { /* a reporting failure must not affect the render */ }
284
+ }
231
285
  const err = e instanceof Error ? e : new Error(String(e));
232
286
  let errorInner = '';
233
287
  try {
234
288
  let errTpl;
235
289
  if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
236
290
  if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
237
- errorInner = await w.capture(() => w.value(errTpl));
291
+ w.componentDepth = (w.componentDepth || 0) + 1;
292
+ try { errorInner = await w.capture(() => w.value(errTpl)); } finally { w.componentDepth -= 1; }
238
293
  } catch (renderErrorThrew) {
239
294
  console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
240
295
  errorInner = '';
241
296
  }
242
297
  let opening = frame.opening;
243
298
  if (!isShadow) opening = withHostMarker(opening);
244
- if (isShadow) return `${opening}<template shadowrootmode="open">${styleString(Cls)}${errorInner}</template>`;
299
+ if (isShadow) return `${opening}${dsdOpenTag(Cls)}${styleString(Cls)}${errorInner}</template>`;
245
300
  return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
246
301
  }
247
302
 
@@ -253,14 +308,39 @@ async function errorState(w, frame) {
253
308
  * The `slot` attribute value of an element's attribute text, or null when
254
309
  * absent. `slot=""` and the reserved alias `default` both mean the default
255
310
  * slot (#1015), matching the client record.
311
+ *
312
+ * The attributes are read one by one, the way the HTML tokenizer reads them,
313
+ * rather than by searching for `slot=`. A search has two false matches: the
314
+ * tail of another name (`data-slot="button"`, which every shadcn-shaped part
315
+ * carries, since `\b` sits between the hyphen and the `s`) and text inside a
316
+ * quoted value (`title="a slot=b"`). Either one routed the child to a named
317
+ * slot the template does not have, and the server dropped it (#1666).
256
318
  * @param {string} attrsRaw
257
319
  * @returns {string | null}
258
320
  */
259
321
  export function extractSlotAttr(attrsRaw) {
260
- const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
261
- if (!m) return null;
262
- const value = m[1] ?? m[2] ?? m[3] ?? '';
263
- return value === '' || value === 'default' ? null : value;
322
+ const found = findAttr(attrsRaw, 'slot');
323
+ if (!found) return null;
324
+ return found.value === '' || found.value === 'default' ? null : found.value;
325
+ }
326
+
327
+ /**
328
+ * Find one attribute by name in an element's attribute text, reading the
329
+ * attributes in order as the HTML tokenizer does, so a name that merely ENDS
330
+ * in `attrName` or a quoted value that mentions it never matches. Returns
331
+ * the value (empty for a bare attribute) and the span of the whole attribute.
332
+ * @param {string} attrsRaw
333
+ * @param {string} attrName lowercase
334
+ * @returns {{ value: string, start: number, end: number } | null}
335
+ */
336
+ function findAttr(attrsRaw, attrName) {
337
+ const attr = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
338
+ let m;
339
+ while ((m = attr.exec(attrsRaw))) {
340
+ if (m[1].toLowerCase() !== attrName) continue;
341
+ return { value: m[2] ?? m[3] ?? m[4] ?? '', start: m.index, end: m.index + m[0].length };
342
+ }
343
+ return null;
264
344
  }
265
345
 
266
346
  /**
@@ -278,9 +358,9 @@ export function finishSlot(frame, fallback) {
278
358
  let name = null;
279
359
  let otherAttrs = '';
280
360
  if (NON_BLANK.test(attrsRaw)) {
281
- const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
282
- name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
283
- otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
361
+ const found = findAttr(attrsRaw, 'name');
362
+ name = found ? found.value : null;
363
+ otherAttrs = (found ? attrsRaw.slice(0, found.start) + attrsRaw.slice(found.end) : attrsRaw).trim();
284
364
  }
285
365
  const slotKey = name === 'default' || name === '' ? null : name;
286
366
  const projected = owner.partition.get(slotKey);
@@ -0,0 +1,78 @@
1
+ import { escapeAttr } from '../escape.js';
2
+ import { decodeAttrEntities } from './text.js';
3
+ import { appendReflectedAttrs } from './attrs.js';
4
+
5
+ /**
6
+ * Serve a custom element's attributes as the element left them after the
7
+ * server pass, the way lit-ssr's `ElementRenderer.renderAttributes()` emits
8
+ * the instance's final attributes rather than the ones the tag was written
9
+ * with. The browser does the same after upgrade: an element that sets, changes
10
+ * or removes an attribute during its first update shows the result, so the
11
+ * served bytes now match the hydrated DOM instead of the source tag.
12
+ *
13
+ * Three cases, per attribute:
14
+ * - one the element added is appended (`appendReflectedAttrs`, unchanged);
15
+ * - one the element CHANGED is served with its new value;
16
+ * - one the element REMOVED is dropped from the tag.
17
+ * An attribute the element left alone keeps its source text BYTE FOR BYTE
18
+ * (its quoting, its entity spelling, its position), so a page whose
19
+ * components touch nothing renders exactly as before.
20
+ *
21
+ * What stays as written, and why each is safe:
22
+ * - `data-webjs-prop-*`: a parent's `.prop=${value}` binding, consumed into
23
+ * the property BEFORE the instance existed (`consumePropAttrs`), so the
24
+ * instance never holds it and reading it back would drop it. It is kept as
25
+ * written, because the browser decodes it again on upgrade.
26
+ * - `data-wj-host`: added AFTER this step by `withHostMarker`. When a
27
+ * re-processed string already carries it (a streamed boundary's children),
28
+ * it was seeded into the instance like any other attribute and is
29
+ * unchanged, so it is kept.
30
+ * - `data-wj-slot-owner`: the renderer stamps it on `<slot>` elements, not on
31
+ * hosts. A host can only carry it in a re-processed string, where it was
32
+ * seeded and is unchanged, so it is kept.
33
+ * - an attribute that came from a template hole: by this point its value is
34
+ * plain text in the opening tag, seeded like a static one, so the same
35
+ * rule applies. Serving the element's value cannot desynchronize the hole:
36
+ * a host inside a component's template is re-created by that component's
37
+ * client render, which writes the hole's value again, and a host written
38
+ * by a page or a layout never hydrates its attributes at all.
39
+ *
40
+ * @param {string} opening the element's opening tag, `<tag attrs>`, ending in `>`
41
+ * @param {any} instance the server-side instance (the element shim, or a real
42
+ * `HTMLElement` when `renderToString` runs in a browser)
43
+ * @param {Set<string>} presentAttrNames lowercased names already in the source tag
44
+ * @returns {string}
45
+ */
46
+ export function serveInstanceAttrs(opening, instance, presentAttrNames) {
47
+ if (!instance || typeof instance.getAttribute !== 'function' || typeof instance.getAttributeNames !== 'function') return opening;
48
+ const nameEnd = opening.search(/[\s>]/);
49
+ const head = opening.slice(0, nameEnd);
50
+ const body = opening.slice(nameEnd, -1);
51
+ // The same attribute grammar `parseAttrs` reads the source with, so every
52
+ // name seeded into the instance is found again here.
53
+ const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
54
+ let out = '';
55
+ let last = 0;
56
+ let changed = false;
57
+ let m;
58
+ while ((m = re.exec(body)) !== null) {
59
+ const raw = m[0];
60
+ const name = m[1];
61
+ if (name.toLowerCase().startsWith('data-webjs-prop-')) continue;
62
+ const written = decodeAttrEntities(m[2] ?? m[3] ?? m[4] ?? '');
63
+ const now = instance.getAttribute(name);
64
+ if (now === written) continue;
65
+ changed = true;
66
+ if (now === null) {
67
+ // Removed: drop it together with the whitespace that separated it.
68
+ let start = m.index;
69
+ while (start > last && /\s/.test(body[start - 1])) start--;
70
+ out += body.slice(last, start);
71
+ } else {
72
+ out += body.slice(last, m.index) + (now === '' ? name : `${name}="${escapeAttr(String(now))}"`);
73
+ }
74
+ last = m.index + raw.length;
75
+ }
76
+ const rewritten = changed ? `${head}${out}${body.slice(last)}>` : opening;
77
+ return appendReflectedAttrs(rewritten, instance, presentAttrNames);
78
+ }