@webjsdev/core 0.8.0 → 0.9.1

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 (85) 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 +53 -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.d.ts +18 -0
  16. package/src/asset-url.js +12 -13
  17. package/src/async-directive.d.ts +18 -0
  18. package/src/async-directive.js +88 -0
  19. package/src/attribute-reader.js +197 -23
  20. package/src/binding-prefixes.js +0 -12
  21. package/src/component/lifecycle.js +760 -209
  22. package/src/component/reactive.js +2 -10
  23. package/src/component/server-element.js +23 -1
  24. package/src/component.d.ts +151 -41
  25. package/src/context.d.ts +94 -18
  26. package/src/context.js +341 -217
  27. package/src/converters.d.ts +7 -0
  28. package/src/converters.js +38 -0
  29. package/src/csp-nonce.d.ts +18 -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 +38 -1
  35. package/src/directives.js +158 -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 +4 -0
  40. package/src/html.js +35 -3
  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 +8 -3
  51. package/src/render-client/parts.js +745 -31
  52. package/src/render-client/reconciler.js +56 -11
  53. package/src/render-client/template-compiler.js +139 -44
  54. package/src/render-client.d.ts +5 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +34 -0
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +61 -8
  59. package/src/render-server/served-attrs.js +1 -2
  60. package/src/render-server/walk.js +135 -3
  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/dom-differ.js +17 -0
  66. package/src/router-client/fetch-apply.js +86 -5
  67. package/src/router-client/navigator.js +45 -9
  68. package/src/router-client/prefetch.js +3 -4
  69. package/src/router-client/root-attrs.js +43 -0
  70. package/src/router-client/stream.js +7 -0
  71. package/src/router-client/swap.js +4 -1
  72. package/src/routes.d.ts +75 -18
  73. package/src/serialize.d.ts +2 -0
  74. package/src/serialize.js +35 -4
  75. package/src/signal.d.ts +7 -0
  76. package/src/signal.js +26 -6
  77. package/src/slot/polyfills.js +3 -3
  78. package/src/slot.js +4 -8
  79. package/src/static-html.d.ts +22 -0
  80. package/src/static-html.js +144 -0
  81. package/src/task.d.ts +63 -20
  82. package/src/task.js +326 -293
  83. package/src/testing.d.ts +42 -0
  84. package/src/webjs-config.d.ts +1 -8
  85. package/src/websocket-client.d.ts +24 -0
@@ -1,7 +1,7 @@
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
6
  applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
7
7
  withHostMarker,
@@ -45,6 +45,7 @@ const NON_BLANK = /\S/;
45
45
  export function beginComponent(w, tag, Cls, opening, attrs) {
46
46
  if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
47
47
  const isShadow = Cls.shadow === true;
48
+ if (!isShadow) reportLightDomStyles(w, tag, Cls);
48
49
  const frame = {
49
50
  kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
50
51
  pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
@@ -142,7 +143,7 @@ export async function finishComponent(w, frame, children) {
142
143
  // Native <slot> stays as-is in the DSD template: the browser projects
143
144
  // the host's light children natively. The authored children stay in
144
145
  // place after the template, and the source end tag is kept.
145
- 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}`;
146
147
  }
147
148
  if (!NON_BLANK.test(inner)) {
148
149
  // An empty rendered template (a decorator component with no render(),
@@ -169,10 +170,12 @@ export async function finishComponent(w, frame, children) {
169
170
  async function renderOwnTemplate(w, frame, children) {
170
171
  const owner = w.owner;
171
172
  w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
173
+ w.componentDepth = (w.componentDepth || 0) + 1;
172
174
  try {
173
175
  return await w.capture(() => w.value(frame.tpl));
174
176
  } finally {
175
177
  w.owner = owner;
178
+ w.componentDepth -= 1;
176
179
  }
177
180
  }
178
181
 
@@ -204,12 +207,54 @@ function partitionChildren(children, segments) {
204
207
  return groups;
205
208
  }
206
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
+
207
230
  function styleString(Cls) {
208
- const rawStyles = Cls.styles;
209
- const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
210
- 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));
234
+ }
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));
211
253
  }
212
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
+
213
258
  /**
214
259
  * Per-component error isolation (#469). The element renders a
215
260
  * component-scoped error state in its place (renderError() customises it;
@@ -230,20 +275,28 @@ async function errorState(w, frame) {
230
275
  } else {
231
276
  console.error(`[webjs] SSR failed for <${tag}>:`, e);
232
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
+ }
233
285
  const err = e instanceof Error ? e : new Error(String(e));
234
286
  let errorInner = '';
235
287
  try {
236
288
  let errTpl;
237
289
  if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
238
290
  if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
239
- 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; }
240
293
  } catch (renderErrorThrew) {
241
294
  console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
242
295
  errorInner = '';
243
296
  }
244
297
  let opening = frame.opening;
245
298
  if (!isShadow) opening = withHostMarker(opening);
246
- if (isShadow) return `${opening}<template shadowrootmode="open">${styleString(Cls)}${errorInner}</template>`;
299
+ if (isShadow) return `${opening}${dsdOpenTag(Cls)}${styleString(Cls)}${errorInner}</template>`;
247
300
  return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
248
301
  }
249
302
 
@@ -16,8 +16,7 @@ import { appendReflectedAttrs } from './attrs.js';
16
16
  * - one the element REMOVED is dropped from the tag.
17
17
  * An attribute the element left alone keeps its source text BYTE FOR BYTE
18
18
  * (its quoting, its entity spelling, its position), so a page whose
19
- * components touch nothing renders exactly as before, which the elision
20
- * on-versus-off differential relies on.
19
+ * components touch nothing renders exactly as before.
21
20
  *
22
21
  * What stays as written, and why each is safe:
23
22
  * - `data-webjs-prop-*`: a parent's `.prop=${value}` binding, consumed into
@@ -12,6 +12,8 @@ import {
12
12
  isSubmitterReflectedProp, FORM_ACTION_FIELD,
13
13
  } from '../form-action.js';
14
14
  import { isRepeat } from '../repeat.js';
15
+ import { isDirectiveResult, PartType } from '../directive.js';
16
+ import { isSignal } from '../signal.js';
15
17
  import { isSuspense } from '../suspense.js';
16
18
  import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from '../directives.js';
17
19
  import { stringify } from '../serialize.js';
@@ -83,6 +85,8 @@ export class Walker {
83
85
  this.defer = 0;
84
86
  /** @type {any} */
85
87
  this.tagState = null;
88
+ /** How many component templates (render / renderError) are being walked. */
89
+ this.componentDepth = 0;
86
90
  }
87
91
 
88
92
  // -------------------------------------------------------------------------
@@ -165,6 +169,10 @@ export class Walker {
165
169
  * @param {unknown} value
166
170
  */
167
171
  async value(value) {
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();
168
176
  if (value == null || value === nothing || value === noChange) return;
169
177
  if (typeof (/** @type any */ (value).then) === 'function') {
170
178
  return this.value(await value);
@@ -175,6 +183,14 @@ export class Walker {
175
183
  for (const v of value) await this.value(v);
176
184
  return;
177
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
+ }
178
194
  // An unsafeHTML value is raw markup. With expansion on, the components it
179
195
  // carries render too (the output rescan used to find them), through the
180
196
  // same tokenizer the template went through, so a tag written inside one
@@ -244,6 +260,12 @@ export class Walker {
244
260
 
245
261
  /** @param {import('../html.js').TemplateResult} tr */
246
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
+ }
247
269
  await this.ops(compile(tr.strings), tr.values);
248
270
  this.flush();
249
271
  }
@@ -317,6 +339,9 @@ export class Walker {
317
339
  if (piece && typeof piece.then === 'function') piece = await piece;
318
340
  if (isLive(piece)) piece = piece.value;
319
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);
320
345
  if (isAttributeMap(piece)) piece = attributeMapText(piece, op.name);
321
346
  assertNotFunctionActionAttr(piece, op.name, op.tag);
322
347
  if (piece === nothing) omitted = true;
@@ -336,6 +361,9 @@ export class Walker {
336
361
  }
337
362
  // A hole inside a quoted/unquoted value, the `action="${fn}"` and
338
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);
339
367
  assertNotFunctionActionAttr(val, op.name, op.tag);
340
368
  this.buf += escapeAttr(String(val ?? ''));
341
369
  break;
@@ -351,6 +379,8 @@ export class Walker {
351
379
  // -------------------------------------------------------------------------
352
380
  // Start tags
353
381
  // -------------------------------------------------------------------------
382
+ // (attrValueOf, the attribute-position unwrap of the value-carrying
383
+ // built-in directives, is a module function below.)
354
384
 
355
385
  /** @param {string} tag */
356
386
  open(tag) {
@@ -400,6 +430,18 @@ export class Walker {
400
430
  * @param {unknown} val
401
431
  */
402
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);
403
445
  if (isAttributeMap(val)) {
404
446
  if (op.kind !== 'plain') throw new Error('classMap/styleMap require an attribute binding.');
405
447
  val = attributeMapText(val, op.name);
@@ -410,7 +452,10 @@ export class Walker {
410
452
  const name = /** @type string */ (op.name);
411
453
  const tag = /** @type string */ (op.tag);
412
454
  if (op.kind === 'event') {
413
- // 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));
414
459
  return;
415
460
  }
416
461
  if (op.kind === 'prop') {
@@ -424,6 +469,9 @@ export class Walker {
424
469
  return;
425
470
  }
426
471
  if (!tag.includes('-')) {
472
+ if (this.dev && this.componentDepth === 0 && !isReflectedFormProp(name, tag)) {
473
+ throw new Error(pageBindingError(false, name, tag));
474
+ }
427
475
  // A native element's `.prop` is dropped at SSR. It still refuses a
428
476
  // function where the property is a REFLECTED IDL attribute (`.action`
429
477
  // on a form, `.formAction` on a button or input), and records the
@@ -464,7 +512,7 @@ export class Walker {
464
512
  // page's own url; the identity is remembered for the `>`. Trailing
465
513
  // whitespace goes with the attribute, since every injected attribute
466
514
  // carries its own leading space.
467
- if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag);
515
+ if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
468
516
  this.buf = this.buf.replace(/\s+$/, '');
469
517
  } else {
470
518
  // #1207, the submitter binding. The identity replaces the
@@ -475,7 +523,7 @@ export class Walker {
475
523
  if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', tag, false);
476
524
  if (attrs.has('value')) assertSubmitterHasNoValue(tag);
477
525
  if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
478
- const subId = assertIdentifiableAction(await resolveFormActionId(val), tag);
526
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
479
527
  if (ts) ts.submitterTag = tag;
480
528
  this.buf += `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
481
529
  }
@@ -726,6 +774,37 @@ export class Walker {
726
774
 
727
775
  const EMPTY = Object.freeze([]);
728
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
+
729
808
  /**
730
809
  * #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped at
731
810
  * SSR but applied for real in the browser, where all three are reflected IDL
@@ -796,3 +875,56 @@ export async function streamValue(value, ctx, controller) {
796
875
  await w.value(value);
797
876
  w.flush();
798
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 */
@@ -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
@@ -15,7 +15,8 @@ import { restoreOptimistic } from './dom-differ.js';
15
15
  import { parseHTML } from './dom-parse.js';
16
16
  import { clearFrameBusy, markFrameBusy } from './frames.js';
17
17
  import { handleNavigationError } from './nav-error.js';
18
- import { navigate } from './navigator.js';
18
+ import { navigate, revalidate } from './navigator.js';
19
+ import { parseRefreshHeader } from '../refresh-header.js';
19
20
  import { prefetchTake } from './prefetch.js';
20
21
  import { currentNavigationToken } from './state.js';
21
22
  import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
@@ -53,7 +54,7 @@ import { _swapCommit, applySwap } from './swap.js';
53
54
  * top on a forward navigation (#1436), from `data-preserve-scroll` on the
54
55
  * link or form, or from `navigate(url, { scroll: false })`. It suppresses only
55
56
  * the scroll-to-top writes, never the hash-anchor scroll.
56
- * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
57
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean, devError?: boolean }>}
57
58
  * The fetch outcome, so a caller (the form-submission busy/event lifecycle)
58
59
  * can report whether the submission settled as a success, an error, or an
59
60
  * abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
@@ -72,7 +73,62 @@ import { _swapCommit, applySwap } from './swap.js';
72
73
  * degradation to a hard navigation from an importmap mismatch or a poisoned
73
74
  * boundary scan). A caller deciding whether to fall back to a full page load
74
75
  * wants `applied`; one reporting the submission's success wants `ok`.
76
+ *
77
+ * `devError` is set for a `refresh` answered with `X-Webjs-Dev-Error: 1`
78
+ * (#1689): the dev render failed, nothing was applied, and the page on
79
+ * screen is meant to stay as it is under the dev overlay.
80
+ */
81
+ /**
82
+ * Whether two urls name the same document (origin, path and query; the hash
83
+ * is a position within it).
84
+ * @param {string} a @param {string} b
85
+ */
86
+ function sameDocumentUrl(a, b) {
87
+ try {
88
+ const x = new URL(a, location.href);
89
+ const y = new URL(b, location.href);
90
+ return x.origin === y.origin && x.pathname === y.pathname && x.search === y.search;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The focused element and its text selection, for a same-page update.
98
+ * @returns {{ el: Element, start: number | null, end: number | null } | null}
75
99
  */
100
+ function captureFocus() {
101
+ const el = document.activeElement;
102
+ if (!el || el === document.body || el === document.documentElement) return null;
103
+ /** @type any */
104
+ const input = el;
105
+ let start = null;
106
+ let end = null;
107
+ try { start = input.selectionStart ?? null; end = input.selectionEnd ?? null; } catch { /* not a text control */ }
108
+ return { el, start, end };
109
+ }
110
+
111
+ /**
112
+ * Give focus back to the element that had it, when the morph kept the node.
113
+ * @param {{ el: Element, start: number | null, end: number | null }} state
114
+ */
115
+ function restoreFocus(state) {
116
+ const { el, start, end } = state;
117
+ if (!el.isConnected || document.activeElement === el) return;
118
+ /** @type any */
119
+ const target = el;
120
+ if (typeof target.focus !== 'function') return;
121
+ // WebKit scrolls a text control into view on setSelectionRange even after a
122
+ // preventScroll focus, so the window offset is put back afterwards.
123
+ const x = window.scrollX;
124
+ const y = window.scrollY;
125
+ target.focus({ preventScroll: true });
126
+ if (start !== null && end !== null) {
127
+ try { target.setSelectionRange(start, end); } catch { /* not a text control */ }
128
+ }
129
+ if (window.scrollX !== x || window.scrollY !== y) window.scrollTo({ left: x, top: y, behavior: 'instant' });
130
+ }
131
+
76
132
  export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
77
133
  method = method || 'GET';
78
134
  const refresh = (opts && opts.refresh) || undefined;
@@ -176,11 +232,27 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
176
232
  const resp = await fetch(href, init);
177
233
  respStatus = resp.status;
178
234
  respOk = resp.ok;
235
+ // A dev render that reported an error (#1689). The server marks it, and
236
+ // the dev overlay is already up describing it. A refresh leaves the page
237
+ // that last rendered on screen under the overlay, as Next does, so the
238
+ // edit that fixes the error refreshes it in place; applying the error page
239
+ // would share no boundary with it and hard-navigate, and so would the fix.
240
+ // Only `webjs dev` sends the header.
241
+ if (refresh && resp.headers.get('x-webjs-dev-error') === '1') {
242
+ try { await resp.body?.cancel(); } catch (_) { /* already consumed */ }
243
+ return { ok: false, status: respStatus, aborted: false, applied: false, devError: true };
244
+ }
179
245
  // `fetch` follows a 303 transparently and its headers are then unreadable,
180
246
  // so this lands on a non-redirect response: the 422 failure re-render, or
181
247
  // any form response that answers in place.
182
248
  const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
183
249
  if (invalidated.length) markStale(invalidated);
250
+ // `revalidatePath()` in a form action (a 422 re-render, or any response
251
+ // answered in place): drop the router's cached copies of those paths.
252
+ for (const e of parseRefreshHeader(resp.headers.get('x-webjs-refresh'))) {
253
+ if (e.layout || e.path.includes('[')) revalidate();
254
+ else revalidate(e.path);
255
+ }
184
256
  const ctype = resp.headers.get('content-type') || '';
185
257
  const isHTML = /^text\/html\b/i.test(ctype);
186
258
  const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
@@ -342,14 +414,22 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
342
414
  // (see the `applied` comment below). Keeping the tail call preserves them byte
343
415
  // for byte, so this commit changes the ORDER on committing swaps and nothing
344
416
  // else.
417
+ // A write that lands back on the page it was submitted from (a validation
418
+ // re-render, or a PRG redirect to the same url) updates that page in place,
419
+ // the way a Next.js server action does after revalidatePath (#1686): the
420
+ // history entry is replaced rather than duplicated, the scroll position is
421
+ // kept, and the focused control keeps focus when it survives the morph.
422
+ const samePage = mutation && !refresh && sameDocumentUrl(finalUrl, location.href);
345
423
  let historyRecorded = false;
346
424
  const recordHistoryNow = recordHistory
347
425
  ? () => {
348
426
  if (historyRecorded) return;
349
427
  historyRecorded = true;
350
- history.pushState(null, '', finalUrl);
428
+ if (samePage) history.replaceState(history.state, '', finalUrl);
429
+ else history.pushState(null, '', finalUrl);
351
430
  }
352
431
  : null;
432
+ const focusState = samePage ? captureFocus() : null;
353
433
 
354
434
  const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
355
435
  // The temporary stylesheet copies are released once the swap has COMMITTED:
@@ -357,6 +437,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
357
437
  // lands a frame later. A copy with no twin in the result (a discarded or
358
438
  // uncommitted swap) goes as soon as the commit settles.
359
439
  if (sheets) _swapCommit.then(() => releaseIncomingStylesheets(sheets), () => releaseIncomingStylesheets(sheets));
440
+ if (focusState) _swapCommit.then(() => { if (myToken === currentNavigationToken) restoreFocus(focusState); }, () => {});
360
441
  // `'none'` means applySwap returned WITHOUT committing anything: the frame the
361
442
  // response was for is missing, or it degraded to a hard navigation (an
362
443
  // importmap/build mismatch, a poisoned boundary scan). The page is not left
@@ -427,7 +508,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
427
508
  // `#section` link is exactly where an app's `scroll-behavior: smooth`
428
509
  // is wanted, and native browsers animate it too.
429
510
  target.scrollIntoView();
430
- } else if (!preserveScroll) {
511
+ } else if (!preserveScroll && !samePage) {
431
512
  // Scroll-to-top on a forward nav. behavior:'instant' so an app-level
432
513
  // `scroll-behavior: smooth` does not animate it (match native nav).
433
514
  warnIfSmoothScrollOnHtml();
@@ -454,7 +535,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
454
535
  }
455
536
 
456
537
  document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
457
- return { ok: respOk, status: respStatus, aborted: false, applied };
538
+ return { ok: respOk, status: respStatus, aborted: false, applied, samePage };
458
539
  } finally {
459
540
  // Clear the frame's busy state on every exit path (the early returns
460
541
  // above all unwind through here). No-op when this was not a frame nav.