@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,3 +1,5 @@
1
+ import { isAttributeMap, attributeMapText } from '../attribute-directives.js';
2
+ import { nothing, noChange } from '../sentinels.js';
1
3
  import { isTemplate } from '../html.js';
2
4
  import { attrHoleValue } from '../binding-prefixes.js';
3
5
  import { escapeText, escapeAttr } from '../escape.js';
@@ -10,6 +12,8 @@ import {
10
12
  isSubmitterReflectedProp, FORM_ACTION_FIELD,
11
13
  } from '../form-action.js';
12
14
  import { isRepeat } from '../repeat.js';
15
+ import { isDirectiveResult, PartType } from '../directive.js';
16
+ import { isSignal } from '../signal.js';
13
17
  import { isSuspense } from '../suspense.js';
14
18
  import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from '../directives.js';
15
19
  import { stringify } from '../serialize.js';
@@ -81,6 +85,8 @@ export class Walker {
81
85
  this.defer = 0;
82
86
  /** @type {any} */
83
87
  this.tagState = null;
88
+ /** How many component templates (render / renderError) are being walked. */
89
+ this.componentDepth = 0;
84
90
  }
85
91
 
86
92
  // -------------------------------------------------------------------------
@@ -163,7 +169,11 @@ export class Walker {
163
169
  * @param {unknown} value
164
170
  */
165
171
  async value(value) {
166
- if (value == null || value === false || value === true) return;
172
+ if (isDirectiveResult(value)) value = resolveDirective(value, CHILD_PART);
173
+ // A bare `${signal}` renders its current value (the client binds the hole
174
+ // to the signal so it updates in place, as `watch()` does).
175
+ if (isSignal(value)) value = /** @type any */ (value).get();
176
+ if (value == null || value === nothing || value === noChange) return;
167
177
  if (typeof (/** @type any */ (value).then) === 'function') {
168
178
  return this.value(await value);
169
179
  }
@@ -173,6 +183,14 @@ export class Walker {
173
183
  for (const v of value) await this.value(v);
174
184
  return;
175
185
  }
186
+ // Any other iterable renders like an array, as in lit: a generator from
187
+ // `map()` / `join()` / `range()`, a Set, a Map's values. A string is
188
+ // iterable too and was handled above, and every directive marker is a
189
+ // plain object with no iterator, so none of them reaches this branch.
190
+ if (typeof value === 'object' && typeof (/** @type any */ (value))[Symbol.iterator] === 'function') {
191
+ for (const v of /** @type {Iterable<unknown>} */ (value)) await this.value(v);
192
+ return;
193
+ }
176
194
  // An unsafeHTML value is raw markup. With expansion on, the components it
177
195
  // carries render too (the output rescan used to find them), through the
178
196
  // same tokenizer the template went through, so a tag written inside one
@@ -242,6 +260,12 @@ export class Walker {
242
260
 
243
261
  /** @param {import('../html.js').TemplateResult} tr */
244
262
  async template(tr) {
263
+ // Only a tagged template's strings carry `raw`, so a result that arrived as
264
+ // data (JSON with the `_$webjs` brand) is refused rather than rendered as
265
+ // markup, as the client renderer and lit's `trustFromTemplateString` do.
266
+ if (!Array.isArray(tr.strings) || !Object.prototype.hasOwnProperty.call(tr.strings, 'raw')) {
267
+ throw new Error("[webjs] refusing to render a template result whose strings are not a tagged template's (a TemplateResult built from data, not by html``).");
268
+ }
245
269
  await this.ops(compile(tr.strings), tr.values);
246
270
  this.flush();
247
271
  }
@@ -300,8 +324,46 @@ export class Walker {
300
324
  await this.attr(op, val);
301
325
  break;
302
326
  case VALUE:
327
+ if (op.quote) {
328
+ // Resolve the complete attribute before emitting any of it so a
329
+ // sentinel in any expression can omit the entire attribute.
330
+ this.buf = this.buf.slice(0, -op.prefix.length);
331
+ let attribute = op.prefix;
332
+ let omitted = false;
333
+ let piece = val;
334
+ for (let j = i; j < ops.length; j++) {
335
+ const next = ops[j];
336
+ if (next.t === HOLE) {
337
+ if (j !== i) {
338
+ piece = values[vi++];
339
+ if (piece && typeof piece.then === 'function') piece = await piece;
340
+ if (isLive(piece)) piece = piece.value;
341
+ }
342
+ piece = await attrValueOf(piece);
343
+ if (isDirectiveResult(piece)) piece = resolveDirective(piece, { type: PartType.ATTRIBUTE, name: op.name, tagName: op.tag?.toUpperCase() });
344
+ if (isSignal(piece) || isWatch(piece)) piece = signalValue(piece);
345
+ if (isAttributeMap(piece)) piece = attributeMapText(piece, op.name);
346
+ assertNotFunctionActionAttr(piece, op.name, op.tag);
347
+ if (piece === nothing) omitted = true;
348
+ attribute += piece === nothing || piece === noChange ? '' : escapeAttr(String(piece ?? ''));
349
+ } else if (next.t === TEXT) {
350
+ const close = next.s.indexOf(op.quote);
351
+ if (close >= 0) {
352
+ if (!omitted) this.buf += attribute + next.s.slice(0, close + 1);
353
+ this.buf += next.s.slice(close + 1);
354
+ i = j;
355
+ break;
356
+ }
357
+ attribute += next.s;
358
+ }
359
+ }
360
+ break;
361
+ }
303
362
  // A hole inside a quoted/unquoted value, the `action="${fn}"` and
304
363
  // mixed `action="/x/${fn}"` shapes (#1154).
364
+ val = await attrValueOf(val);
365
+ if (isDirectiveResult(val)) val = resolveDirective(val, { type: PartType.ATTRIBUTE, name: op.name, tagName: op.tag?.toUpperCase() });
366
+ if (isSignal(val) || isWatch(val)) val = signalValue(val);
305
367
  assertNotFunctionActionAttr(val, op.name, op.tag);
306
368
  this.buf += escapeAttr(String(val ?? ''));
307
369
  break;
@@ -317,6 +379,8 @@ export class Walker {
317
379
  // -------------------------------------------------------------------------
318
380
  // Start tags
319
381
  // -------------------------------------------------------------------------
382
+ // (attrValueOf, the attribute-position unwrap of the value-carrying
383
+ // built-in directives, is a module function below.)
320
384
 
321
385
  /** @param {string} tag */
322
386
  open(tag) {
@@ -366,11 +430,32 @@ export class Walker {
366
430
  * @param {unknown} val
367
431
  */
368
432
  async attr(op, val) {
433
+ if (op.kind !== 'event') val = await attrValueOf(val);
434
+ // A custom directive renders its `render()` result at SSR (lit's server
435
+ // rule: `update()` needs a DOM). An event part is client-only, so its
436
+ // directive never runs here.
437
+ if (isDirectiveResult(val) && op.kind !== 'event') {
438
+ const type = op.kind === 'prop' ? PartType.PROPERTY : op.kind === 'bool' ? PartType.BOOLEAN_ATTRIBUTE : PartType.ATTRIBUTE;
439
+ val = resolveDirective(val, { type, name: op.name, tagName: op.tag?.toUpperCase() });
440
+ }
441
+ // A bare signal in an attribute or boolean hole renders its current value;
442
+ // a `.prop` hole hands the signal object itself to the property, as lit's
443
+ // signals package does.
444
+ if ((isSignal(val) || isWatch(val)) && (op.kind === 'plain' || op.kind === 'bool')) val = signalValue(val);
445
+ if (isAttributeMap(val)) {
446
+ if (op.kind !== 'plain') throw new Error('classMap/styleMap require an attribute binding.');
447
+ val = attributeMapText(val, op.name);
448
+ }
449
+ if (val === noChange) return;
450
+ if (val === nothing && op.kind === 'prop') val = undefined;
369
451
  const ts = this.tagState;
370
452
  const name = /** @type string */ (op.name);
371
453
  const tag = /** @type string */ (op.tag);
372
454
  if (op.kind === 'event') {
373
- // An event listener is client-only behaviour, dropped at SSR.
455
+ // An event listener is client-only behaviour, dropped at SSR. Outside
456
+ // any component template it can never run either: a page or layout
457
+ // never hydrates. Dev makes that a render error naming the fix.
458
+ if (this.dev && this.componentDepth === 0) throw new Error(pageBindingError(true, name, tag));
374
459
  return;
375
460
  }
376
461
  if (op.kind === 'prop') {
@@ -384,6 +469,9 @@ export class Walker {
384
469
  return;
385
470
  }
386
471
  if (!tag.includes('-')) {
472
+ if (this.dev && this.componentDepth === 0 && !isReflectedFormProp(name, tag)) {
473
+ throw new Error(pageBindingError(false, name, tag));
474
+ }
387
475
  // A native element's `.prop` is dropped at SSR. It still refuses a
388
476
  // function where the property is a REFLECTED IDL attribute (`.action`
389
477
  // on a form, `.formAction` on a button or input), and records the
@@ -412,7 +500,7 @@ export class Walker {
412
500
  // `?action=${fn}` is meaningless in every case; refusing it keeps the
413
501
  // rule true for every sigil rather than only the quoted ones.
414
502
  assertNotFunctionActionAttr(val, name, tag);
415
- if (val) this.buf += `${name}=""`;
503
+ if (val && val !== nothing) this.buf += `${name}=""`;
416
504
  return;
417
505
  }
418
506
  // A plain `name=${value}` hole.
@@ -424,7 +512,7 @@ export class Walker {
424
512
  // page's own url; the identity is remembered for the `>`. Trailing
425
513
  // whitespace goes with the attribute, since every injected attribute
426
514
  // carries its own leading space.
427
- if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag);
515
+ if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
428
516
  this.buf = this.buf.replace(/\s+$/, '');
429
517
  } else {
430
518
  // #1207, the submitter binding. The identity replaces the
@@ -435,7 +523,7 @@ export class Walker {
435
523
  if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', tag, false);
436
524
  if (attrs.has('value')) assertSubmitterHasNoValue(tag);
437
525
  if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
438
- const subId = assertIdentifiableAction(await resolveFormActionId(val), tag);
526
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
439
527
  if (ts) ts.submitterTag = tag;
440
528
  this.buf += `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
441
529
  }
@@ -686,6 +774,37 @@ export class Walker {
686
774
 
687
775
  const EMPTY = Object.freeze([]);
688
776
 
777
+ /**
778
+ * The current value of a bare signal or a `watch(signal)` marker in an
779
+ * attribute-shaped hole (SSR reads it once; the client binds the hole).
780
+ * @param {any} v
781
+ */
782
+ function signalValue(v) {
783
+ return isWatch(v) ? v.signal.get() : v.get();
784
+ }
785
+
786
+ /** The part info every child-position directive is constructed with. */
787
+ const CHILD_PART = Object.freeze({ type: PartType.CHILD });
788
+
789
+ /**
790
+ * Resolve a custom directive result (`directive(C)(...values)`) to the value
791
+ * it renders, lit's server rule: construct the class with the part info and
792
+ * call `render(...values)` (never `update()`, which needs a DOM). A directive
793
+ * that returns another directive resolves in turn. A fresh instance per
794
+ * render is right here, since an SSR render is one shot.
795
+ * @param {any} value
796
+ * @param {{ type: number, name?: string, tagName?: string }} partInfo
797
+ * @returns {unknown}
798
+ */
799
+ function resolveDirective(value, partInfo) {
800
+ for (let depth = 0; isDirectiveResult(value); depth++) {
801
+ if (depth > 100) throw new Error('[webjs] a custom directive kept returning directives (more than 100 levels deep).');
802
+ const instance = new value.ctor(partInfo);
803
+ value = instance.render(...value.values);
804
+ }
805
+ return value;
806
+ }
807
+
689
808
  /**
690
809
  * #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped at
691
810
  * SSR but applied for real in the browser, where all three are reflected IDL
@@ -756,3 +875,56 @@ export async function streamValue(value, ctx, controller) {
756
875
  await w.value(value);
757
876
  w.flush();
758
877
  }
878
+
879
+ /**
880
+ * A form-shape property the dedicated form guards judge (and refuse with a
881
+ * more specific message), so the page-binding error leaves it to them.
882
+ * @param {string} name
883
+ * @param {string} tag
884
+ */
885
+ function isReflectedFormProp(name, tag) {
886
+ return /^(?:action|method|enctype|encoding|formaction|formmethod|formenctype)$/i.test(name)
887
+ && /^(?:form|button|input)$/.test(tag);
888
+ }
889
+
890
+ /**
891
+ * The dev error for an `@event` or native `.prop` binding in a template no
892
+ * component renders (a page or layout, which never hydrates).
893
+ * @param {boolean} isEvent an `@event` listener (else a `.prop` binding)
894
+ * @param {string} name
895
+ * @param {string} tag
896
+ */
897
+ function pageBindingError(isEvent, name, tag) {
898
+ const what = isEvent ? `an \`@${name}\` event listener` : `a \`.${name}\` property binding`;
899
+ return `webjs: ${what} on <${tag}> in a page or layout template. Pages and layouts render only on the server and never hydrate, so it is dropped and never runs. `
900
+ + `Move the interactive markup into a WebComponent and render its tag from the page (its render() hydrates in the browser)`
901
+ + (!isEvent ? ', or write a plain attribute (\`' + name.toLowerCase() + '=${value}\`) when the value only needs to reach the HTML' : '')
902
+ + '. This check runs in dev only. See https://webjs.dev/docs/troubleshooting#binding-in-page-template';
903
+ }
904
+
905
+ /**
906
+ * The value a value-carrying built-in directive stands for in an ATTRIBUTE,
907
+ * property or boolean position, as in Lit (#1686): `guard` renders its
908
+ * callback's result, `until` its first non-promise argument (else the first
909
+ * promise to settle, a rejection reading as no value), `keyed` and `cache`
910
+ * their value. A child position unwraps these in `value()`; an attribute hole
911
+ * used to stringify the directive object to "[object Object]".
912
+ * @param {unknown} v
913
+ * @returns {Promise<unknown>}
914
+ */
915
+ async function attrValueOf(v) {
916
+ for (let depth = 0; depth < 8; depth++) {
917
+ /** @type any */
918
+ const d = v;
919
+ if (isGuard(d)) v = d.fn();
920
+ else if (isKeyed(d) || isCache(d)) v = d.value;
921
+ else if (isLive(d)) v = d.value;
922
+ else if (isUntil(d)) {
923
+ const sync = d.args.find((a) => !a || typeof a.then !== 'function');
924
+ if (sync !== undefined || d.args.some((a) => !a || typeof a.then !== 'function')) v = sync;
925
+ else if (d.args.length) v = await Promise.race(d.args.map((p) => Promise.resolve(p).catch(() => undefined)));
926
+ else v = undefined;
927
+ } else return v;
928
+ }
929
+ return v;
930
+ }
@@ -12,3 +12,6 @@ export function renderToStream(
12
12
  value: unknown,
13
13
  opts?: { ssr?: boolean; suspenseCtx?: SuspenseContext },
14
14
  ): ReadableStream;
15
+ /** A React-ism in an html template's static strings: `className=` / `htmlFor=` or a capitalised tag. */
16
+ export function findJsxIsm(strings: readonly string[]): { kind: 'attr' | 'tag'; name: string } | null;
17
+ export function jsxIsmMessage(ism: { kind: 'attr' | 'tag'; name: string }): string;
@@ -4,3 +4,5 @@
4
4
  // backs the published `@webjsdev/core/server` subpath, so re-exporting it would
5
5
  // widen the package's public API for a helper no consumer asked for.
6
6
  export { renderToString, renderToStream } from './render-server/stream.js';
7
+ // The JSX-in-template scan, shared with `webjs check` (`no-jsx-attributes`).
8
+ export { findJsxIsm, jsxIsmMessage } from './jsx-guard.js';
package/src/repeat.d.ts CHANGED
@@ -3,13 +3,18 @@
3
3
  // exported, so it cannot be named, and no consumer constructs one by hand.
4
4
  export interface RepeatDirective<T> {
5
5
  items: T[];
6
- keyFn: (item: T, i: number) => string | number;
6
+ keyFn: (item: T, i: number) => unknown;
7
7
  templateFn: (item: T, i: number) => unknown;
8
8
  }
9
9
 
10
10
  export function repeat<T>(
11
11
  items: Iterable<T>,
12
- keyFn: (item: T, i: number) => string | number,
12
+ keyFn: (item: T, i: number) => unknown,
13
+ templateFn: (item: T, i: number) => unknown,
14
+ ): RepeatDirective<T>;
15
+ /** lit's two-argument form: no key function, so each item is keyed by its index. */
16
+ export function repeat<T>(
17
+ items: Iterable<T>,
13
18
  templateFn: (item: T, i: number) => unknown,
14
19
  ): RepeatDirective<T>;
15
20
  export function isRepeat(x: unknown): x is RepeatDirective<unknown>;
package/src/repeat.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * html`<ul>${repeat(items, (it) => it.id, (it) => html`<li>${it.title}</li>`)}</ul>`;
8
8
  * ```
9
9
  *
10
- * Without `repeat()`, an array re-render rebuilds every child (losing focus,
11
- * selection, and scroll). With `repeat()`, the client renderer maintains a
10
+ * Without `repeat()`, an array updates positionally: the DOM for item N is
11
+ * reused for whatever is now at index N, so focus, selection and component
12
+ * state follow the position, not the item. With `repeat()`, the client
13
+ * renderer maintains a
12
14
  * `Map<key, TemplateInstance>`: matched items update in place, new items
13
15
  * mount fresh, removed items unmount, and the surviving DOM nodes are moved
14
16
  * (not recreated) when the order changes.
@@ -16,19 +18,31 @@
16
18
  * Keys must be stable and unique within the call. Bad keys (array indices)
17
19
  * defeat the purpose; prefer a stable id from the data.
18
20
  *
21
+ * The two-argument form, `repeat(items, (it) => html`...`)`, is lit's too: with
22
+ * no key function each item is keyed by its INDEX, which makes it a plain
23
+ * positional list (no DOM moves on reorder). Pass a key function whenever item
24
+ * identity has to follow the data.
25
+ *
19
26
  * On the server this marker is just iterated: order matters, keys don't.
20
27
  */
21
28
 
22
29
  const REPEAT = Symbol.for('webjs.repeat');
23
30
 
31
+ /** The key of an item `repeat(items, template)` was given no key function for. */
32
+ const indexKey = (/** @type {unknown} */ _item, /** @type {number} */ i) => i;
33
+
24
34
  /**
25
35
  * @template T
26
36
  * @param {Iterable<T>} items
27
- * @param {(item: T, i: number) => string | number} keyFn
28
- * @param {(item: T, i: number) => unknown} templateFn
37
+ * @param {((item: T, i: number) => unknown)} keyFnOrTemplate the key function,
38
+ * or, when `templateFn` is omitted, the template function (lit's two-argument form)
39
+ * @param {(item: T, i: number) => unknown} [templateFn]
29
40
  */
30
- export function repeat(items, keyFn, templateFn) {
31
- return { [REPEAT]: true, items: [...items], keyFn, templateFn };
41
+ export function repeat(items, keyFnOrTemplate, templateFn) {
42
+ if (templateFn === undefined) {
43
+ return { [REPEAT]: true, items: [...items], keyFn: indexKey, templateFn: keyFnOrTemplate };
44
+ }
45
+ return { [REPEAT]: true, items: [...items], keyFn: keyFnOrTemplate, templateFn };
32
46
  }
33
47
 
34
48
  /** @param {unknown} x */
@@ -37,8 +37,10 @@ export function closestAnchor(target) {
37
37
  const a = el.closest && el.closest('a');
38
38
  if (a instanceof HTMLAnchorElement) return a;
39
39
  }
40
+ // Only a real ShadowRoot has a host to cross to (a detached subtree's
41
+ // root can be an <a>, whose `host` is a URL string).
40
42
  const root = node.getRootNode ? node.getRootNode() : null;
41
- node = root && /** @type any */ (root).host ? /** @type any */ (root).host : null;
43
+ node = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null;
42
44
  }
43
45
  return null;
44
46
  }
@@ -159,6 +159,20 @@ export function collectBoundaries(root) {
159
159
  * live: { routeKey: string, start: Comment, end: Comment },
160
160
  * incoming: { routeKey: string, start: Comment, end: Comment } } | null}
161
161
  */
162
+ /**
163
+ * Does segment `inner` sit at or below segment `outer`. Boundary segments are
164
+ * nested path prefixes, so `/ui/docs` is within `/ui` and within `/`, and
165
+ * `/uix` is within neither `/ui` nor `/ui/docs`. The one statement of the
166
+ * nesting rule: the swap plan below and the stylesheet ownership in
167
+ * owned-styles.js both use it, so they cannot disagree about which range a
168
+ * segment belongs to.
169
+ * @param {string} inner
170
+ * @param {string} outer
171
+ */
172
+ export function segmentWithin(inner, outer) {
173
+ return inner === outer || inner.startsWith(outer === '/' ? '/' : outer + '/');
174
+ }
175
+
162
176
  export function planBoundarySwap(here, there) {
163
177
  // Shared segments, shallowest first (a nested path prefix is shorter).
164
178
  const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
@@ -169,7 +183,7 @@ export function planBoundarySwap(here, there) {
169
183
  let parent = null;
170
184
  for (const p of shared) {
171
185
  if (p === seg) break;
172
- if (seg.startsWith(p === '/' ? p : p + '/')) parent = p;
186
+ if (seg !== p && segmentWithin(seg, p)) parent = p;
173
187
  }
174
188
  if (!parent) return null; // no anchored parent: degrade
175
189
  return { mode: 'replace', segment: parent, live: here.get(parent), incoming: there.get(parent) };
@@ -204,3 +204,33 @@ export function _isNonHtmlPath(pathname) {
204
204
  * alone rather than reverted.
205
205
  */
206
206
  export const WRITE_BACK_ARMED_MS = 250;
207
+
208
+ /**
209
+ * Marks the router's temporary stylesheet copies (`stylesheets.js`). Here,
210
+ * in the leaf module, so `owned-styles.js` can skip those copies without
211
+ * importing `stylesheets.js`, which imports `head-merge.js`, which imports
212
+ * `owned-styles.js`.
213
+ */
214
+ export const PRELOAD_ATTR = 'data-webjs-preload-style';
215
+
216
+ /**
217
+ * Does `el`'s `rel` name a stylesheet that applies (`stylesheet`, not
218
+ * `alternate`). The one test both the stylesheet loader (`stylesheets.js`) and
219
+ * the owned-sheet merge (`owned-styles.js`) use, so the two cannot disagree.
220
+ * @param {Element} el
221
+ */
222
+ export function relIsStylesheet(el) {
223
+ const rel = relTokens(el);
224
+ return rel.includes('stylesheet') && !rel.includes('alternate');
225
+ }
226
+
227
+ /**
228
+ * The lowercased tokens of `el`'s `rel`, the one parse every stylesheet test
229
+ * in the router builds on (`relIsStylesheet` here, and the head merge's
230
+ * never-remove rule, which counts an alternate stylesheet too).
231
+ * @param {Element} el
232
+ * @returns {string[]}
233
+ */
234
+ export function relTokens(el) {
235
+ return (el.getAttribute('rel') || '').toLowerCase().split(/\s+/);
236
+ }
@@ -224,9 +224,25 @@ export function diffElementInPlace(dst, src) {
224
224
  }
225
225
  // Update attributes from src; remove ones not in src.
226
226
  const srcAttrs = new Set();
227
+ // A server-passed `.prop` binding on a reused, upgraded component (#1686):
228
+ // its `data-webjs-prop-*` attribute was read once at upgrade and stripped,
229
+ // so writing the new value as an attribute changed nothing and the property
230
+ // stayed stale after a same-page update. A binding whose value changed is
231
+ // handed to the component to decode; an unchanged one is left alone, so a
232
+ // value the component changed locally survives, as in Lit.
233
+ const hydrated = isHydratedComponent(dst) && typeof (/** @type any */ (dst))._hydratePropAttrs === 'function';
234
+ let propsChanged = false;
227
235
  for (const attr of src.attributes) {
228
236
  srcAttrs.add(attr.name);
229
237
  if (LIVE_ATTRS.has(attr.name)) continue;
238
+ if (hydrated && attr.name.startsWith('data-webjs-prop-')) {
239
+ const seen = /** @type any */ (dst).__webjsPropAttrValues;
240
+ if (!seen || seen.get(attr.name) !== attr.value) {
241
+ dst.setAttribute(attr.name, attr.value);
242
+ propsChanged = true;
243
+ }
244
+ continue;
245
+ }
230
246
  // The serialized-restore stamp is a message to a NOT-YET-UPGRADED
231
247
  // element's connectedCallback; copying it onto a live reused host would
232
248
  // leave a consume-once marker lingering in the live DOM forever. Note
@@ -243,6 +259,7 @@ export function diffElementInPlace(dst, src) {
243
259
  if (LIVE_ATTRS.has(attr.name)) continue;
244
260
  if (!srcAttrs.has(attr.name)) dst.removeAttribute(attr.name);
245
261
  }
262
+ if (propsChanged) /** @type any */ (dst)._hydratePropAttrs();
246
263
  // For form-control-like elements, preserve live IDL state.
247
264
  // (`value`, `checked`, `open`, etc.: see LIVE_ATTRS below for full list.)
248
265
  // The attribute version is skipped above; we deliberately do nothing