@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/README.md +1 -2
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +10 -10
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +4 -4
- package/dist/webjs-core.js.map +2 -2
- package/index-browser.js +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component.d.ts +71 -13
- package/src/component.js +92 -3
- package/src/render-server.js +6 -2
- package/src/router-client.js +121 -22
- package/src/task.js +1 -3
- package/src/webjs-config.d.ts +34 -0
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
package/src/component.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
56
|
-
*
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* `Object.
|
|
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
|
-
|
|
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 (`
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
|
897
|
-
if (!
|
|
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
|
+
}
|
package/src/render-server.js
CHANGED
|
@@ -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
|
|
1055
|
+
const rawDef = props[key] || props[camelCase(key)];
|
|
1056
1056
|
const propName = props[key] ? key : camelCase(key);
|
|
1057
|
-
if (!
|
|
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) {
|
package/src/router-client.js
CHANGED
|
@@ -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)
|
|
964
|
-
* fast without per-link opt-in
|
|
965
|
-
*
|
|
966
|
-
*
|
|
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
|
|
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
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
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
|
|
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 :
|
|
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
|
|
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
|
-
|
|
1249
|
-
// `
|
|
1250
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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 });
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -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
|
|