@webjsdev/core 0.7.21 → 0.7.22

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.
package/index-browser.js CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  export { html, isTemplate, MARKER } from './src/html.js';
26
26
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
27
- export { WebComponent } from './src/component.js';
27
+ export { WebComponent, prop } from './src/component.js';
28
28
  export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
29
29
  export { render } from './src/render-client.js';
30
30
  export { escapeText, escapeAttr } from './src/escape.js';
package/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  export { html, isTemplate, MARKER } from './src/html.js';
10
10
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
11
- export { WebComponent } from './src/component.js';
11
+ export { WebComponent, prop } from './src/component.js';
12
12
  export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
13
13
  export { renderToString, renderToStream } from './src/render-server.js';
14
14
  export { render } from './src/render-client.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.21",
3
+ "version": "0.7.22",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -22,7 +22,7 @@ export type PropertyConstructor<T = unknown> =
22
22
  | (new (...args: any[]) => T)
23
23
  | ((v: any) => T);
24
24
 
25
- /** Runtime-level property declaration: matches `static properties = { … }`. */
25
+ /** Runtime-level property declaration: one entry in `WebComponent({ … })`. */
26
26
  export interface PropertyDeclaration<T = unknown> {
27
27
  /** Constructor used for string → value coercion when the attribute changes. */
28
28
  type?: PropertyConstructor<T>;
@@ -52,27 +52,30 @@ export interface ReactiveController {
52
52
  /**
53
53
  * Base class for interactive web components.
54
54
  *
55
- * Declare runtime behaviour via `static properties = { … }` and type
56
- * each field with a sibling `declare foo: Foo`:
55
+ * Declare reactive properties via the `extends WebComponent({ … })`
56
+ * factory. It installs the reactive accessor AND types each field for
57
+ * you (no `declare`, no `static properties`):
57
58
  *
58
- * class StudentCard extends WebComponent {
59
- * static properties = { student: { type: Object } };
60
- * declare student: Student;
59
+ * class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
61
60
  * render() { return html`<p>${this.student.name}</p>`; }
62
61
  * }
63
62
  * StudentCard.register('student-card');
64
63
  *
65
- * The `declare` field has no runtime cost: it tells TypeScript the
66
- * field's type without emitting a class-field initializer that would
67
- * clobber the reactive accessor the framework installs via
68
- * `Object.defineProperty` in the constructor.
64
+ * A bare constructor type covers the common case (`WebComponent({ count:
65
+ * Number })` gives `this.count: number`); use the `prop()` helper for
66
+ * options (`prop(Number, { reflect: true })`) or to narrow the type
67
+ * (`prop<Student>(Object)`). Set defaults via the `default` option or in
68
+ * the constructor, never a class-field initializer (it runs after super()
69
+ * and clobbers the reactive accessor). A hand-written `static properties`
70
+ * in a class body is no longer supported and throws at construction.
69
71
  *
70
72
  * See the Editor Setup doc for tooling that gives attribute/tag
71
73
  * intelligence inside `html\`…\`` templates.
72
74
  */
73
- export abstract class WebComponent extends HTMLElement {
75
+ abstract class WebComponentBase extends HTMLElement {
74
76
  static shadow: boolean;
75
77
  static hydrate: 'visible' | undefined;
78
+ /** @internal Populated by the `WebComponent({ … })` factory, not by hand. */
76
79
  static properties: Record<string, PropertyDeclaration>;
77
80
  static styles: CSSResult | CSSResult[] | null;
78
81
  static lazy?: boolean;
@@ -84,8 +87,8 @@ export abstract class WebComponent extends HTMLElement {
84
87
  * Schedule a re-render. Optionally record a property change so lifecycle
85
88
  * hooks can branch on what changed via `changedProperties`.
86
89
  *
87
- * For instance-local state, prefer a reactive property (`static
88
- * properties = { foo: { state: true } }`) or a signal from
90
+ * For instance-local state, prefer a reactive property (`WebComponent({
91
+ * foo: prop({ state: true }) })`) or a signal from
89
92
  * `@webjsdev/core`'s `signal()`. The framework's built-in
90
93
  * SignalWatcher wires every signal read in `render()` to a re-render
91
94
  * automatically; explicit `requestUpdate()` is rarely needed.
@@ -129,3 +132,58 @@ export abstract class WebComponent extends HTMLElement {
129
132
  disconnectedCallback(): void;
130
133
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
131
134
  }
135
+
136
+ export type WebComponent = WebComponentBase;
137
+
138
+ export type Infer<C> =
139
+ C extends BooleanConstructor ? boolean :
140
+ C extends NumberConstructor ? number :
141
+ C extends StringConstructor ? string :
142
+ C extends new (...a: any[]) => infer T ? T :
143
+ C extends (v: any) => infer T ? T : unknown;
144
+
145
+ export type InferProps<S> = {
146
+ [K in keyof S]: S[K] extends PropertyDeclaration<infer T> ? T : Infer<S[K]>
147
+ };
148
+
149
+ export interface WebComponentConstructor {
150
+ new (): WebComponentBase;
151
+ prototype: WebComponentBase;
152
+
153
+ shadow: boolean;
154
+ hydrate: 'visible' | undefined;
155
+ properties: Record<string, PropertyDeclaration>;
156
+ styles: CSSResult | CSSResult[] | null;
157
+ lazy?: boolean;
158
+ register(tag: string): void;
159
+ readonly observedAttributes: string[];
160
+
161
+ <S extends Record<string, any>>(shape: S): {
162
+ new (): WebComponentBase & InferProps<S>;
163
+ prototype: WebComponentBase & InferProps<S>;
164
+
165
+ shadow: boolean;
166
+ hydrate: 'visible' | undefined;
167
+ properties: Record<string, PropertyDeclaration>;
168
+ styles: CSSResult | CSSResult[] | null;
169
+ lazy?: boolean;
170
+ register(tag: string): void;
171
+ readonly observedAttributes: string[];
172
+ };
173
+ }
174
+
175
+ export declare const WebComponent: WebComponentConstructor;
176
+
177
+ export declare function prop<C extends PropertyConstructor<any>>(
178
+ type: C,
179
+ opts?: Omit<PropertyDeclaration<Infer<C>>, 'type'>
180
+ ): PropertyDeclaration<Infer<C>>;
181
+
182
+ export declare function prop<T>(
183
+ type: PropertyConstructor<any>,
184
+ opts?: Omit<PropertyDeclaration<T>, 'type'>
185
+ ): PropertyDeclaration<T>;
186
+
187
+ export declare function prop<T = string>(
188
+ opts?: PropertyDeclaration<T>
189
+ ): PropertyDeclaration<T>;
package/src/component.js CHANGED
@@ -400,7 +400,18 @@ for (const idl of ARIA_IDL_PROPS) {
400
400
  // Base class choice: real HTMLElement on the browser, the shim on the server.
401
401
  const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
402
402
 
403
- export class WebComponent extends Base {
403
+ /**
404
+ * Marker stamped on the anonymous subclass the `WebComponent({...})` factory
405
+ * produces. It lets `_assertFactoryProperties` tell the framework's own
406
+ * factory-generated `static properties` (allowed) apart from a `static
407
+ * properties` a user wrote by hand in a class body (no longer allowed).
408
+ */
409
+ const FACTORY_PROPS = Symbol('webjs.factoryProps');
410
+
411
+ // Per-class memo so the constructor-time enforcement walk runs once per class.
412
+ const _propsChecked = new WeakSet();
413
+
414
+ class WebComponentBase extends Base {
404
415
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
405
416
  static shadow = false;
406
417
 
@@ -531,10 +542,45 @@ export class WebComponent extends Base {
531
542
  */
532
543
  this._isUpdating = false;
533
544
 
545
+ // Enforce the declare-free factory DX: a hand-written `static properties`
546
+ // in a class body is a hard error (use `extends WebComponent({ … })`).
547
+ this._assertFactoryProperties();
548
+
534
549
  // Install reactive property accessors for `static properties` declarations.
535
550
  this._initializeProperties();
536
551
  }
537
552
 
553
+ /**
554
+ * Throw if a class in this instance's constructor chain declares its own
555
+ * `static properties`. Reactive properties must be declared via the
556
+ * `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS}
557
+ * on the subclass it generates; a `static properties` written by hand in a
558
+ * class body carries no such marker and is rejected here (issue #598).
559
+ *
560
+ * The walk stops at {@link WebComponentBase} (whose `static properties = {}`
561
+ * default is internal) and is memoized per class so it runs once.
562
+ * @private
563
+ */
564
+ _assertFactoryProperties() {
565
+ const Ctor = /** @type {any} */ (this.constructor);
566
+ if (_propsChecked.has(Ctor)) return;
567
+ let C = Ctor;
568
+ while (C && C !== WebComponentBase) {
569
+ if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) {
570
+ const name = C.name || 'a component';
571
+ throw new Error(
572
+ `${name}: \`static properties\` is no longer supported. Declare reactive ` +
573
+ `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
574
+ `count: Number })\`. Use the \`prop()\` helper for options ` +
575
+ `(\`prop(Number, { reflect: true })\`) and set defaults via the \`default\` ` +
576
+ `option or in the constructor. See https://docs.webjs.com/docs/components.`,
577
+ );
578
+ }
579
+ C = Object.getPrototypeOf(C);
580
+ }
581
+ _propsChecked.add(Ctor);
582
+ }
583
+
538
584
  /**
539
585
  * For every key in `static properties`, create a getter/setter pair on
540
586
  * the instance that coerces values, runs `hasChanged`, schedules updates,
@@ -893,8 +939,13 @@ export class WebComponent extends Base {
893
939
 
894
940
  const Ctor = /** @type any */ (this.constructor);
895
941
  const propName = camelCase(name);
896
- const def = Ctor.properties && (Ctor.properties[propName] || Ctor.properties[name]);
897
- if (!def) return;
942
+ const raw = Ctor.properties && (Ctor.properties[propName] || Ctor.properties[name]);
943
+ if (!raw) return;
944
+ // A declaration is either a full descriptor (`{ type: Number, … }`) or the
945
+ // bare-constructor shorthand the factory accepts (`count: Number`), in which
946
+ // case the value IS the type. Normalise so type-based coercion fires either
947
+ // way (matches `_initializeProperties`).
948
+ const def = typeof raw === 'object' ? raw : { type: raw };
898
949
 
899
950
  let v;
900
951
  if (def.converter && def.converter.fromAttribute) {
@@ -1553,3 +1604,41 @@ function hyphenate(s) {
1553
1604
  function camelCase(s) {
1554
1605
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1555
1606
  }
1607
+
1608
+ /**
1609
+ * Dual-role WebComponent class and factory.
1610
+ * Can be extended directly: `class X extends WebComponent`
1611
+ * Or called as a class factory: `class X extends WebComponent({ count: Number })`
1612
+ *
1613
+ * @param {Record<string, any>} [properties]
1614
+ * @returns {any}
1615
+ */
1616
+ export function WebComponent(properties) {
1617
+ if (new.target) {
1618
+ return Reflect.construct(WebComponentBase, arguments, new.target);
1619
+ } else {
1620
+ return class extends WebComponentBase {
1621
+ static properties = properties;
1622
+ static [FACTORY_PROPS] = true;
1623
+ };
1624
+ }
1625
+ }
1626
+
1627
+ // Ensure static inheritance and instanceof checks work
1628
+ Object.setPrototypeOf(WebComponent, WebComponentBase);
1629
+ WebComponent.prototype = WebComponentBase.prototype;
1630
+
1631
+ /**
1632
+ * Helper to define properties with custom options.
1633
+ *
1634
+ * @param {any} [type]
1635
+ * @param {any} [opts]
1636
+ * @returns {any}
1637
+ */
1638
+ export function prop(type, opts = {}) {
1639
+ if (type && typeof type === 'object' && !('call' in type)) {
1640
+ opts = type;
1641
+ type = undefined;
1642
+ }
1643
+ return { ...(type ? { type } : {}), ...opts };
1644
+ }
@@ -1052,12 +1052,16 @@ function appendReflectedAttrs(opening, instance, presentAttrNames) {
1052
1052
  function applyAttrsToInstance(instance, attrs, Cls) {
1053
1053
  const props = Cls.properties || {};
1054
1054
  for (const [key, raw] of Object.entries(attrs)) {
1055
- const def = props[key] || props[camelCase(key)];
1055
+ const rawDef = props[key] || props[camelCase(key)];
1056
1056
  const propName = props[key] ? key : camelCase(key);
1057
- if (!def) {
1057
+ if (!rawDef) {
1058
1058
  instance[propName] = raw;
1059
1059
  continue;
1060
1060
  }
1061
+ // The factory accepts a bare constructor shorthand (`{ expanded: Boolean }`)
1062
+ // alongside the long form (`{ expanded: { type: Boolean } }`); normalize so
1063
+ // the type-based coercion below sees a `{ type }` object either way.
1064
+ const def = typeof rawDef === 'object' ? rawDef : { type: rawDef };
1061
1065
  if (def.type === Number) instance[propName] = Number(raw);
1062
1066
  else if (def.type === Boolean) instance[propName] = raw !== 'false';
1063
1067
  else if (def.type === Object || def.type === Array) {
@@ -220,6 +220,7 @@ export function disableClientRouter() {
220
220
  document.removeEventListener('pointerout', onPrefetchOut, true);
221
221
  document.removeEventListener('webjs:navigate', refreshPrefetchObservers);
222
222
  clearPrefetchHover();
223
+ clearPrefetchViewTimers();
223
224
  if (prefetchViewObserver) { prefetchViewObserver.disconnect(); prefetchViewObserver = null; }
224
225
  if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
225
226
  history.scrollRestoration = prevScrollRestoration;
@@ -960,12 +961,16 @@ function buildHaveHeader() {
960
961
  * warms a dedicated cache speculatively so the click reads it instantly.
961
962
  *
962
963
  * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*,
963
- * like SvelteKit / Astro), defaulting to `intent` so the common case is
964
- * fast without per-link opt-in, the way Next / Nuxt / SvelteKit ship
965
- * auto-prefetch. Value vocabulary borrows Next's true/false/auto aliases:
966
- * - intent (default) : hover / focus / touch, after a short dwell
964
+ * like SvelteKit / Astro). The default is DEVICE-ADAPTIVE so the common case
965
+ * is fast on every device without per-link opt-in: `intent` on a hover-capable
966
+ * pointer (a real head-start before the click), `viewport` on touch (no hover
967
+ * exists, and `touchstart` fires too close to the tap to front-run it). Value
968
+ * vocabulary borrows Next's true/false/auto aliases:
969
+ * - absent (default) : intent on pointer, viewport on touch (adaptive)
970
+ * - intent : hover / focus / touch, after a short dwell
967
971
  * - true / render : eager, as soon as a document scan sees it
968
- * - auto / viewport : on viewport entry (IntersectionObserver, 0.5)
972
+ * - auto / viewport : on viewport entry (IntersectionObserver, 0.5),
973
+ * after a dwell so a fast scroll-through skips it
969
974
  * - false / none : never (also data-no-prefetch / rel="external")
970
975
  *
971
976
  * Why a separate cache, not snapshotCache: snapshotCache is keyed to the
@@ -976,7 +981,10 @@ function buildHaveHeader() {
976
981
  * prefetchTake() before falling back to the network.
977
982
  *
978
983
  * Only same-origin in-app links are prefetched (the same eligibility as
979
- * a click), and never under Save-Data / prefers-reduced-data. There is no
984
+ * a click), and never under Save-Data / prefers-reduced-data / a 2g link,
985
+ * never past a small concurrency cap, and never twice (deduped + cached). The
986
+ * viewport path additionally waits a dwell and cancels on scroll-out, so a
987
+ * fast scroll through a long list does not flood the network tab. There is no
980
988
  * logout-style heuristic: prefetch issues a real GET, so as everywhere in
981
989
  * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a
982
990
  * POST or a `<form>`, and `data-no-prefetch` / `rel="external"` opt out.
@@ -996,6 +1004,13 @@ const PREFETCH_CONCURRENCY = 3;
996
1004
  const PREFETCH_QUEUE_CAP = 24;
997
1005
  /** Hover dwell before a prefetch fires (ms): filter drive-by pointer moves. Matches Remix's intent timeout. */
998
1006
  const PREFETCH_HOVER_DELAY = 100;
1007
+ /**
1008
+ * Viewport dwell before a prefetch fires (ms): a link must SETTLE on-screen,
1009
+ * not merely flash past during a scroll. A fast scroll-through clears the
1010
+ * timer on exit, so flicked-past links never fetch. Astro uses 300ms for the
1011
+ * same purpose; we sit a touch lower so a deliberate stop still feels instant.
1012
+ */
1013
+ const PREFETCH_VIEWPORT_DELAY = 250;
999
1014
 
1000
1015
  /** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */
1001
1016
  /** @type {Map<string, PrefetchEntry>} */
@@ -1011,18 +1026,28 @@ let prefetchHoverTimer = null;
1011
1026
  let prefetchHoverAnchor = null;
1012
1027
  /** IntersectionObserver for data-prefetch="viewport" anchors, or null. */
1013
1028
  let prefetchViewObserver = null;
1029
+ /** Per-anchor viewport-dwell timers, so a scroll-out can cancel before firing. */
1030
+ let prefetchViewTimers = new WeakMap();
1031
+ /** Live viewport-dwell timer ids, for bulk teardown on disable. */
1032
+ const prefetchViewPending = new Set();
1014
1033
 
1015
1034
  /**
1016
- * True when the user or platform has asked us to conserve data. Both the
1017
- * Save-Data client hint and the prefers-reduced-data media query disable
1018
- * speculative fetching. Guarded for non-browser / partial DOM.
1035
+ * True when the user or platform has asked us to conserve data, OR the
1036
+ * connection is too slow to spend bytes speculatively. The Save-Data client
1037
+ * hint, the prefers-reduced-data media query, and a 2g `effectiveType` all
1038
+ * disable speculative fetching, the same gate Astro / Nuxt apply. Guarded for
1039
+ * non-browser / partial DOM.
1019
1040
  *
1020
1041
  * @returns {boolean}
1021
1042
  */
1022
1043
  function prefetchSaysSaveData() {
1023
1044
  try {
1024
1045
  const c = typeof navigator !== 'undefined' ? /** @type any */ (navigator).connection : null;
1025
- if (c && c.saveData === true) return true;
1046
+ if (c) {
1047
+ if (c.saveData === true) return true;
1048
+ // effectiveType is 'slow-2g' | '2g' | '3g' | '4g'; skip the 2g tiers.
1049
+ if (typeof c.effectiveType === 'string' && /2g$/.test(c.effectiveType)) return true;
1050
+ }
1026
1051
  if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-data: reduce)').matches) {
1027
1052
  return true;
1028
1053
  }
@@ -1030,6 +1055,27 @@ function prefetchSaysSaveData() {
1030
1055
  return false;
1031
1056
  }
1032
1057
 
1058
+ /**
1059
+ * Whether the device drives a hover-capable fine pointer (a mouse or
1060
+ * trackpad), as opposed to touch. This picks the ADAPTIVE prefetch default:
1061
+ * `intent` (hover / focus) on a pointer device, `viewport` on touch, since a
1062
+ * touch device has no hover and `touchstart` fires too close to the tap to
1063
+ * front-run it. Detected with `matchMedia('(hover: hover) and (pointer: fine)')`
1064
+ * rather than a user-agent sniff. When `matchMedia` is unavailable we assume a
1065
+ * pointer (the historical default), so a non-browser / partial-DOM environment
1066
+ * keeps the `intent` behaviour and never silently switches to viewport.
1067
+ *
1068
+ * @returns {boolean}
1069
+ */
1070
+ function prefetchHasHoverPointer() {
1071
+ try {
1072
+ if (typeof matchMedia === 'function') {
1073
+ return matchMedia('(hover: hover) and (pointer: fine)').matches;
1074
+ }
1075
+ } catch { /* ignore */ }
1076
+ return true;
1077
+ }
1078
+
1033
1079
  /**
1034
1080
  * Lowercased whitespace-separated rel tokens of an anchor.
1035
1081
  * @param {Element} anchor
@@ -1091,12 +1137,21 @@ function prefetchSuppressed(anchor) {
1091
1137
  * absent or unrecognised.
1092
1138
  *
1093
1139
  * Value mapping (case-insensitive):
1094
- * - absent / unknown : `intent` (the default)
1140
+ * - absent / unknown : the DEVICE-ADAPTIVE default (intent on a pointer,
1141
+ * viewport on touch); an explicit value always wins
1095
1142
  * - `intent` : hover / focus / touch, after a short dwell
1096
1143
  * - `true` / `render` : eager, as soon as a document scan sees the link
1097
- * - `auto` / `viewport`: on viewport entry (IntersectionObserver)
1144
+ * - `auto` / `viewport`: on viewport entry (IntersectionObserver), after a dwell
1098
1145
  * - `false` / `none` : never (also via data-no-prefetch / rel="external")
1099
1146
  *
1147
+ * The default is adaptive (not a single `intent`) because `intent` does not
1148
+ * help on mobile: a touch device has no hover, and `touchstart` fires at tap
1149
+ * time, so the prefetch races the navigation. On touch we default to
1150
+ * `viewport` (warm links as they settle on-screen) and keep `touchstart` as an
1151
+ * extra warm for the tapped link; on a pointer device `intent` stays the
1152
+ * default (precise, cheap, a real head-start before the click). A per-link
1153
+ * `data-prefetch` always overrides the adaptive default.
1154
+ *
1100
1155
  * Returns `none` for suppressed anchors so callers have a single check.
1101
1156
  *
1102
1157
  * @param {Element} anchor
@@ -1118,8 +1173,8 @@ function prefetchMode(anchor) {
1118
1173
  case 'intent':
1119
1174
  return 'intent';
1120
1175
  default:
1121
- // Unset or unrecognised value: the fast default.
1122
- return 'intent';
1176
+ // Unset or unrecognised value: the device-adaptive default.
1177
+ return prefetchHasHoverPointer() ? 'intent' : 'viewport';
1123
1178
  }
1124
1179
  }
1125
1180
 
@@ -1134,6 +1189,9 @@ function prefetchMode(anchor) {
1134
1189
  * @param {string} href
1135
1190
  */
1136
1191
  function prefetch(href) {
1192
+ // Never speculate once the router is torn down: a leftover hover / queue /
1193
+ // dwell timer that fires after disableClientRouter must not issue a fetch.
1194
+ if (!enabled) return;
1137
1195
  if (typeof fetch !== 'function') return;
1138
1196
  if (prefetchSaysSaveData()) return;
1139
1197
  const key = cacheKey(href);
@@ -1245,12 +1303,19 @@ function onPrefetchIntent(e) {
1245
1303
  if (!enabled) return;
1246
1304
  const anchor = closestAnchor(/** @type any */ (e.target));
1247
1305
  if (!anchor) return;
1248
- // Only `intent` links prefetch on hover/focus/touch. `render` and
1249
- // `viewport` links are handled by the document scan / observer, and
1250
- // `none` is suppressed.
1251
- if (prefetchMode(anchor) !== 'intent') return;
1306
+ const mode = prefetchMode(anchor);
1307
+ // `none` is suppressed; `render` already prefetched on the document scan.
1308
+ if (mode === 'none' || mode === 'render') return;
1252
1309
  const href = eligibleAnchorHref(anchor);
1253
1310
  if (!href) return;
1311
+ // touchstart IS the tap: warm the tapped link immediately, for both intent
1312
+ // and viewport modes (a single request for a link about to be navigated, the
1313
+ // small mobile win the viewport default cannot give for the link just tapped).
1314
+ // No dwell, since the tap is the intent.
1315
+ if (e.type === 'touchstart') { prefetch(href); return; }
1316
+ // hover / focus only warm `intent` links; `viewport` links are the
1317
+ // observer's job (warmed on a dwell, not on a stray hover).
1318
+ if (mode !== 'intent') return;
1254
1319
  // pointerover/focusin bubble, so re-entering a child of the same anchor
1255
1320
  // would re-arm; collapse to one timer per anchor.
1256
1321
  if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return;
@@ -1274,6 +1339,13 @@ function clearPrefetchHover() {
1274
1339
  prefetchHoverAnchor = null;
1275
1340
  }
1276
1341
 
1342
+ /** Cancel every pending viewport-dwell timer and reset the per-anchor map. */
1343
+ function clearPrefetchViewTimers() {
1344
+ for (const timer of prefetchViewPending) clearTimeout(timer);
1345
+ prefetchViewPending.clear();
1346
+ prefetchViewTimers = new WeakMap();
1347
+ }
1348
+
1277
1349
  /**
1278
1350
  * Nearest enclosing <a>, crossing shadow boundaries, from an event
1279
1351
  * target. composedPath is click-only, so walk getRootNode().host here.
@@ -1314,15 +1386,40 @@ function refreshPrefetchObservers() {
1314
1386
  if (!prefetchViewObserver) {
1315
1387
  prefetchViewObserver = new IntersectionObserver((entries) => {
1316
1388
  for (const entry of entries) {
1317
- if (!entry.isIntersecting) continue;
1318
1389
  const anchor = /** @type {Element} */ (entry.target);
1319
- prefetchViewObserver.unobserve(anchor);
1320
- const href = eligibleAnchorHref(anchor);
1321
- if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1390
+ if (entry.isIntersecting) {
1391
+ // Arm a dwell timer; the link must STAY on-screen to warm. One
1392
+ // timer per anchor, so re-entry while pending does not stack.
1393
+ if (prefetchViewTimers.has(anchor)) continue;
1394
+ const timer = setTimeout(() => {
1395
+ prefetchViewPending.delete(timer);
1396
+ prefetchViewTimers.delete(anchor);
1397
+ prefetchViewObserver.unobserve(anchor);
1398
+ const href = eligibleAnchorHref(anchor);
1399
+ if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1400
+ }, PREFETCH_VIEWPORT_DELAY);
1401
+ prefetchViewTimers.set(anchor, timer);
1402
+ prefetchViewPending.add(timer);
1403
+ } else {
1404
+ // Scrolled out before the dwell elapsed: cancel, so a fast
1405
+ // scroll-through never spends a request.
1406
+ const timer = prefetchViewTimers.get(anchor);
1407
+ if (timer) {
1408
+ clearTimeout(timer);
1409
+ prefetchViewPending.delete(timer);
1410
+ prefetchViewTimers.delete(anchor);
1411
+ }
1412
+ }
1322
1413
  }
1323
1414
  }, { threshold: 0.5 });
1324
1415
  } else {
1416
+ // Re-scan: drop the old observation set AND cancel any pending dwell
1417
+ // timers, so a timer armed for an anchor the soft-nav swap removed cannot
1418
+ // fire a prefetch for a stale URL (its exit callback never comes once it
1419
+ // is gone). Anchors still on-screen re-arm when observe() below redelivers
1420
+ // their current intersection state.
1325
1421
  prefetchViewObserver.disconnect();
1422
+ clearPrefetchViewTimers();
1326
1423
  }
1327
1424
  }
1328
1425
  for (const anchor of document.querySelectorAll('a[href]')) {
@@ -3070,6 +3167,7 @@ export {
3070
3167
  regraftPermanentInSlice as _regraftPermanentInSlice,
3071
3168
  prefetchSuppressed as _prefetchSuppressed,
3072
3169
  prefetchMode as _prefetchMode,
3170
+ prefetchHasHoverPointer as _prefetchHasHoverPointer,
3073
3171
  prefetch as _prefetch,
3074
3172
  prefetchTake as _prefetchTake,
3075
3173
  prefetchSaysSaveData as _prefetchSaysSaveData,
@@ -3090,6 +3188,7 @@ export function _resetPrefetch() {
3090
3188
  prefetchQueue.length = 0;
3091
3189
  prefetchQueued.clear();
3092
3190
  clearPrefetchHover();
3191
+ clearPrefetchViewTimers();
3093
3192
  }
3094
3193
 
3095
3194
  /** Test-only: read the monotonic navigation-token counter. */
package/src/task.js CHANGED
@@ -29,9 +29,7 @@
29
29
  * import { WebComponent, html } from '@webjsdev/core';
30
30
  * import { Task } from '@webjsdev/core/task';
31
31
  *
32
- * class UserSearch extends WebComponent {
33
- * static properties = { query: { type: String } };
34
- *
32
+ * class UserSearch extends WebComponent({ query: String }) {
35
33
  * _search = new Task(this, {
36
34
  * task: async (query, { signal }) => {
37
35
  * const res = await fetch(`/api/users?q=${query}`, { signal });
@@ -80,6 +80,32 @@ export interface WebjsRedirectRule {
80
80
  /** The trailing-slash canonicalization policy in `webjs.trailingSlash`. */
81
81
  export type WebjsTrailingSlash = 'never' | 'always' | 'ignore';
82
82
 
83
+ /** Dev task orchestration in `webjs.dev` (#550). Read by the CLI, not the server. */
84
+ export interface WebjsDevTasks {
85
+ /**
86
+ * One-shot commands run sequentially to completion BEFORE the dev server
87
+ * boots (the old `predev` hook: `webjs db migrate`, a registry copy). A
88
+ * non-zero exit aborts the boot.
89
+ */
90
+ before?: string[];
91
+ /**
92
+ * Long-lived commands run as child processes ALONGSIDE the dev server (the
93
+ * old `concurrently` watchers: the Tailwind CLI `--watch`). Spawned once in
94
+ * the parent and torn down on exit, so a watcher cannot leak past the server.
95
+ */
96
+ parallel?: string[];
97
+ }
98
+
99
+ /** Start task orchestration in `webjs.start` (#550). Read by the CLI, not the server. */
100
+ export interface WebjsStartTasks {
101
+ /**
102
+ * One-shot commands run sequentially to completion BEFORE the prod server
103
+ * boots (the old `prestart` hook: `webjs db migrate`). A non-zero exit
104
+ * aborts the boot.
105
+ */
106
+ before?: string[];
107
+ }
108
+
83
109
  /** The object form of `webjs.csp` (the non-boolean shape). */
84
110
  export interface WebjsCspConfig {
85
111
  /**
@@ -118,6 +144,14 @@ export interface WebjsConfig {
118
144
  */
119
145
  seed?: boolean;
120
146
 
147
+ /**
148
+ * Dev/start task orchestration (#550). `webjs dev` / `webjs start` run these
149
+ * so a bare CLI invocation matches `npm run dev` / `start`. Read by the CLI
150
+ * (`packages/cli/lib/app-tasks.js`), NOT the server readers.
151
+ */
152
+ dev?: WebjsDevTasks;
153
+ start?: WebjsStartTasks;
154
+
121
155
  /** Per-path response-header rules, shaped like Next's. */
122
156
  headers?: WebjsHeaderRule[];
123
157