@webjsdev/core 0.7.65 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +18 -9
- package/index.d.ts +26 -10
- package/index.js +21 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +213 -25
- package/src/binding-prefixes.js +10 -53
- package/src/component/lifecycle.js +921 -252
- package/src/component/reactive.js +7 -14
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +160 -44
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +40 -1
- package/src/directives.js +160 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +8 -0
- package/src/html.js +55 -2
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +250 -0
- package/src/render-client/parts.js +790 -42
- package/src/render-client/reconciler.js +84 -23
- package/src/render-client/template-compiler.js +152 -43
- package/src/render-client.d.ts +6 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +50 -2
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +100 -20
- package/src/render-server/served-attrs.js +78 -0
- package/src/render-server/walk.js +177 -5
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- package/src/router-client/anchors.js +3 -1
- package/src/router-client/boundaries.js +15 -1
- package/src/router-client/constants.js +30 -0
- package/src/router-client/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +107 -5
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +49 -9
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +6 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +53 -10
- package/src/routes.d.ts +75 -18
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- package/src/webjs-config.d.ts +2 -9
package/src/task.js
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* @license
|
|
3
|
+
* Portions derived from Lit (`@lit/task`), Copyright Google LLC, BSD-3-Clause.
|
|
4
|
+
* https://github.com/lit/lit/blob/main/packages/task/LICENSE
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Task: a ReactiveController for async work inside a component, with the same
|
|
9
|
+
* API and the same behaviour as Lit's `@lit/task`, so a component written
|
|
10
|
+
* from Lit memory runs unchanged under the WebJs import path.
|
|
4
11
|
*
|
|
5
12
|
* ## When to use
|
|
6
13
|
*
|
|
7
|
-
* Use Task
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Good fits:
|
|
13
|
-
* - Search-as-you-type / autocomplete
|
|
14
|
-
* - Lazy-loaded data panels that fetch when a tab becomes active
|
|
15
|
-
* - Infinite scroll / pagination within a component
|
|
16
|
-
* - Any component-scoped async operation (geocoding, AI completions, etc.)
|
|
17
|
-
*
|
|
18
|
-
* ## When NOT to use
|
|
19
|
-
*
|
|
20
|
-
* - For **page-level** data loading, use async page functions or server
|
|
21
|
-
* actions instead: they run on the server, stream via Suspense, and
|
|
22
|
-
* produce real HTML for crawlers.
|
|
23
|
-
* - For one-shot fire-and-forget mutations (form submit, delete), a plain
|
|
24
|
-
* `async` method on the component is usually simpler.
|
|
14
|
+
* Use Task for genuinely CLIENT-only async data: search-as-you-type, a
|
|
15
|
+
* lazily loaded panel, a fetch driven by browser state. Data that is known at
|
|
16
|
+
* request time and belongs in the first paint goes through `async render()` or
|
|
17
|
+
* an async page function instead: a Task never runs at SSR, so its INITIAL
|
|
18
|
+
* state is what the server renders.
|
|
25
19
|
*
|
|
26
20
|
* ## Quick example
|
|
27
21
|
*
|
|
@@ -31,7 +25,8 @@
|
|
|
31
25
|
*
|
|
32
26
|
* class UserSearch extends WebComponent({ query: String }) {
|
|
33
27
|
* _search = new Task(this, {
|
|
34
|
-
*
|
|
28
|
+
* // ONE argument: the array `args()` returned. Destructure it.
|
|
29
|
+
* task: async ([query], { signal }) => {
|
|
35
30
|
* const res = await fetch(`/api/users?q=${query}`, { signal });
|
|
36
31
|
* if (!res.ok) throw new Error('Search failed');
|
|
37
32
|
* return res.json();
|
|
@@ -40,67 +35,28 @@
|
|
|
40
35
|
* });
|
|
41
36
|
*
|
|
42
37
|
* render() {
|
|
43
|
-
* return
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* complete: (users)=> html`<ul>${users.map(u => html`<li>${u.name}</li>`)}</ul>`,
|
|
50
|
-
* error: (err) => html`<p class="error">${err.message}</p>`,
|
|
51
|
-
* })}
|
|
52
|
-
* `;
|
|
38
|
+
* return this._search.render({
|
|
39
|
+
* initial: () => html`<p>Type to search</p>`,
|
|
40
|
+
* pending: () => html`<p>Searching</p>`,
|
|
41
|
+
* complete: (users) => html`<ul>${users.map((u) => html`<li>${u.name}</li>`)}</ul>`,
|
|
42
|
+
* error: (err) => html`<p class="error">${err.message}</p>`,
|
|
43
|
+
* });
|
|
53
44
|
* }
|
|
54
45
|
* }
|
|
55
46
|
* UserSearch.register('user-search');
|
|
56
47
|
* ```
|
|
57
48
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* Set `autoRun: false` to disable automatic re-runs on arg changes and
|
|
61
|
-
* call `task.run()` explicitly (e.g. on button click):
|
|
62
|
-
*
|
|
63
|
-
* ```js
|
|
64
|
-
* this._export = new Task(this, {
|
|
65
|
-
* task: async (format, { signal }) => { … },
|
|
66
|
-
* args: () => [this.format],
|
|
67
|
-
* autoRun: false,
|
|
68
|
-
* });
|
|
69
|
-
*
|
|
70
|
-
* // In render:
|
|
71
|
-
* html`<button @click=${() => this._export.run()}>Export</button>`
|
|
72
|
-
* ```
|
|
49
|
+
* The positional form `new Task(this, taskFn, argsFn)` is accepted too.
|
|
73
50
|
*
|
|
74
51
|
* @module
|
|
75
52
|
*/
|
|
76
53
|
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// TaskStatus enum
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
|
|
81
54
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* | Value | Name | Meaning |
|
|
85
|
-
* | ----- | --------- | ------------------------------------ |
|
|
86
|
-
* | 0 | INITIAL | Task has never run. |
|
|
87
|
-
* | 1 | PENDING | Task is in-flight (awaiting). |
|
|
88
|
-
* | 2 | COMPLETE | Task resolved successfully. |
|
|
89
|
-
* | 3 | ERROR | Task rejected or threw. |
|
|
55
|
+
* The four states a Task can be in.
|
|
90
56
|
*
|
|
91
57
|
* @readonly
|
|
92
58
|
* @enum {number}
|
|
93
59
|
*/
|
|
94
|
-
// The SSR walker now runs controllers' hostUpdate during the pre-render pass
|
|
95
|
-
// (issue #217). A Task must NOT invoke its task function server-side (that
|
|
96
|
-
// would fire a fetch during SSR); the browser runs it on hydration. Read live
|
|
97
|
-
// (not cached) so the auto-run gate reflects the current environment: SSR
|
|
98
|
-
// ships the task's INITIAL state, unchanged from before hostUpdate fired at
|
|
99
|
-
// SSR, and the browser runs it on connect.
|
|
100
|
-
function inBrowser() {
|
|
101
|
-
return typeof window !== 'undefined';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
60
|
export const TaskStatus = /** @type {const} */ ({
|
|
105
61
|
INITIAL: 0,
|
|
106
62
|
PENDING: 1,
|
|
@@ -108,290 +64,367 @@ export const TaskStatus = /** @type {const} */ ({
|
|
|
108
64
|
ERROR: 3,
|
|
109
65
|
});
|
|
110
66
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Returned (or resolved) from a task function to put the task back into the
|
|
69
|
+
* INITIAL state instead of COMPLETE, the way Lit's `initialState` does. Use it
|
|
70
|
+
* when the current args mean "nothing to do yet" (an empty search box).
|
|
71
|
+
*/
|
|
72
|
+
export const initialState = Symbol('webjs.task.initialState');
|
|
114
73
|
|
|
115
74
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
75
|
+
* Whether the task function may run in this environment. The SSR walk runs
|
|
76
|
+
* controllers' `hostUpdate` during its pre-render pass, and a Task must never
|
|
77
|
+
* fire its function (typically a fetch) server-side: SSR ships the INITIAL
|
|
78
|
+
* state, and the browser runs the task once the element connects. Read live,
|
|
79
|
+
* not cached, so the gate reflects the current environment.
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
function inBrowser() {
|
|
83
|
+
return typeof window !== 'undefined';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lit's default `argsEqual`: the two arrays are the same array, or have the
|
|
88
|
+
* same length and every element is `Object.is` equal (Lit's `notEqual`
|
|
89
|
+
* negated), so `NaN` equals `NaN` and `0` differs from `-0`.
|
|
121
90
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
*
|
|
132
|
-
*
|
|
91
|
+
* @param {ReadonlyArray<unknown>} oldArgs
|
|
92
|
+
* @param {ReadonlyArray<unknown>} newArgs
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export const shallowArrayEquals = (oldArgs, newArgs) =>
|
|
96
|
+
oldArgs === newArgs ||
|
|
97
|
+
(oldArgs.length === newArgs.length && oldArgs.every((v, i) => Object.is(v, newArgs[i])));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Structural equality for the value shapes args usually carry: primitives,
|
|
101
|
+
* arrays, plain objects, `Map`, `Set`, `RegExp`, and objects with a custom
|
|
102
|
+
* `valueOf` / `toString` (a `Date`, a `URL`). A port of `@lit/task`'s
|
|
103
|
+
* `deepEquals`, including its rule that two objects with different
|
|
104
|
+
* constructors are never equal.
|
|
133
105
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
106
|
+
* @param {unknown} a
|
|
107
|
+
* @param {unknown} b
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
export const deepEquals = (a, b) => {
|
|
111
|
+
if (Object.is(a, b)) return true;
|
|
112
|
+
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
|
113
|
+
const x = /** @type {any} */ (a);
|
|
114
|
+
const y = /** @type {any} */ (b);
|
|
115
|
+
if (x.constructor !== y.constructor) return false;
|
|
116
|
+
if (Array.isArray(x)) {
|
|
117
|
+
if (x.length !== y.length) return false;
|
|
118
|
+
return x.every((v, i) => deepEquals(v, y[i]));
|
|
119
|
+
}
|
|
120
|
+
if (x.valueOf !== Object.prototype.valueOf) return x.valueOf() === y.valueOf();
|
|
121
|
+
if (x.toString !== Object.prototype.toString) return x.toString() === y.toString();
|
|
122
|
+
if (x instanceof Map && y instanceof Map) {
|
|
123
|
+
if (x.size !== y.size) return false;
|
|
124
|
+
for (const [k, v] of x.entries()) {
|
|
125
|
+
if (deepEquals(v, y.get(k)) === false || (v === undefined && y.has(k) === false)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (x instanceof Set && y instanceof Set) {
|
|
132
|
+
if (x.size !== y.size) return false;
|
|
133
|
+
for (const k of x.keys()) {
|
|
134
|
+
if (y.has(k) === false) return false;
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (x instanceof RegExp) {
|
|
139
|
+
return x.source === y.source && x.flags === y.flags;
|
|
140
|
+
}
|
|
141
|
+
const keys = Object.keys(x);
|
|
142
|
+
if (keys.length !== Object.keys(y).length) return false;
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
if (!Object.hasOwn(y, key) || !deepEquals(x[key], y[key])) return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `argsEqual` that compares each argument with {@link deepEquals}, Lit's
|
|
153
|
+
* `deepArrayEquals`. Pass it when `args()` builds fresh objects every update
|
|
154
|
+
* and identity comparison would re-run the task on every render.
|
|
139
155
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
* @param {ReadonlyArray<unknown>} oldArgs
|
|
157
|
+
* @param {ReadonlyArray<unknown>} newArgs
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export const deepArrayEquals = (oldArgs, newArgs) =>
|
|
161
|
+
oldArgs === newArgs ||
|
|
162
|
+
(oldArgs.length === newArgs.length && oldArgs.every((v, i) => deepEquals(v, newArgs[i])));
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A ReactiveController that runs an async function, tracks its status
|
|
166
|
+
* (INITIAL, PENDING, COMPLETE, ERROR), and requests a host update on every
|
|
167
|
+
* transition.
|
|
145
168
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
169
|
+
* Behaviour, matching `@lit/task`:
|
|
170
|
+
* - The task function is called as `task(args, { signal })`, where `args` is
|
|
171
|
+
* the ARRAY `args()` returned (not spread), so destructure it:
|
|
172
|
+
* `async ([id], { signal }) => ...`.
|
|
173
|
+
* - `autoRun: true` (default) re-runs in `hostUpdate()` whenever `args()`
|
|
174
|
+
* differs from the previous args under `argsEqual`. `'afterUpdate'` does the
|
|
175
|
+
* same from `hostUpdated()`, after the host's DOM is committed. `false` runs
|
|
176
|
+
* only on `run()`. `autoRun` is a public field and can be flipped later.
|
|
177
|
+
* - A task with no `args` function never auto-runs; call `run()`.
|
|
178
|
+
* - `initialValue` starts the task COMPLETE with that value, and records the
|
|
179
|
+
* current args so it only runs once they change.
|
|
180
|
+
* - A newer run supersedes an older one: the older run's signal is aborted
|
|
181
|
+
* and its result is dropped.
|
|
182
|
+
* - `abort(reason)` aborts the in-flight run. The task function's rejection
|
|
183
|
+
* (an `AbortError` from `fetch`) then lands as the ERROR state, as in Lit.
|
|
184
|
+
* - `taskComplete` is a promise for the current run's result.
|
|
185
|
+
* - Disconnecting the host does NOT abort the run (Lit does not either), so a
|
|
186
|
+
* moved element keeps its result. Abort it yourself in
|
|
187
|
+
* `disconnectedCallback` if the work must stop.
|
|
188
|
+
* - Never runs at SSR (WebJs specific): the server renders the INITIAL state.
|
|
149
189
|
*
|
|
150
|
-
* @template T
|
|
190
|
+
* @template {ReadonlyArray<unknown>} [T=ReadonlyArray<unknown>]
|
|
191
|
+
* @template [R=unknown]
|
|
151
192
|
*/
|
|
152
193
|
export class Task {
|
|
153
194
|
/**
|
|
154
|
-
* @param {
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* autoRun?: boolean
|
|
160
|
-
* }} options
|
|
161
|
-
* - `task`: The async function to execute. Receives the spread args
|
|
162
|
-
* from `args()` followed by an options object with `{ signal }`.
|
|
163
|
-
* - `args`: A function returning an array of arguments. Re-evaluated
|
|
164
|
-
* on every host update cycle. Defaults to `() => []`.
|
|
165
|
-
* - `autoRun`: When true (default), the task re-runs automatically
|
|
166
|
-
* whenever `args()` returns values that differ from the previous
|
|
167
|
-
* run. Set to false for manual-only triggering via `run()`.
|
|
195
|
+
* @param {any} host The controller host (a WebComponent).
|
|
196
|
+
* @param {any} task A task config object `{ task, args, autoRun,
|
|
197
|
+
* argsEqual, initialValue, onComplete, onError }`, or the task function
|
|
198
|
+
* itself when `args` is passed positionally.
|
|
199
|
+
* @param {() => T} [args] The args function, positional form only.
|
|
168
200
|
*/
|
|
169
|
-
constructor(host,
|
|
170
|
-
/** @
|
|
201
|
+
constructor(host, task, args) {
|
|
202
|
+
/** @private */
|
|
171
203
|
this._host = host;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
204
|
+
host.addController(this);
|
|
205
|
+
const config = typeof task === 'object' && task !== null ? task : { task, args };
|
|
206
|
+
if (typeof config.task !== 'function') {
|
|
207
|
+
throw new TypeError(
|
|
208
|
+
'[webjs] new Task(host, { task, args }) needs a task function. ' +
|
|
209
|
+
'Pass a config object or new Task(host, taskFn, argsFn).',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
/** @private @type {(args: T, options: { signal: AbortSignal }) => any} */
|
|
213
|
+
this._task = config.task;
|
|
214
|
+
/** @private @type {(() => T) | undefined} */
|
|
215
|
+
this._argsFn = config.args;
|
|
216
|
+
/** @private @type {(oldArgs: T, newArgs: T) => boolean} */
|
|
217
|
+
this._argsEqual = config.argsEqual ?? shallowArrayEquals;
|
|
218
|
+
/** @private */
|
|
219
|
+
this._onComplete = config.onComplete;
|
|
220
|
+
/** @private */
|
|
221
|
+
this._onError = config.onError;
|
|
222
|
+
/** @type {boolean | 'afterUpdate'} */
|
|
223
|
+
this.autoRun = config.autoRun ?? true;
|
|
224
|
+
/** @private @type {number} */
|
|
180
225
|
this._status = TaskStatus.INITIAL;
|
|
181
|
-
/** @type {
|
|
226
|
+
/** @private @type {R | undefined} */
|
|
182
227
|
this._value = undefined;
|
|
183
|
-
/** @type {unknown} */
|
|
228
|
+
/** @private @type {unknown} */
|
|
184
229
|
this._error = undefined;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
/** @private @type {T | undefined} */
|
|
231
|
+
this._previousArgs = undefined;
|
|
232
|
+
/** @private */
|
|
233
|
+
this._callId = 0;
|
|
234
|
+
/** @private @type {AbortController | undefined} */
|
|
235
|
+
this._abortController = undefined;
|
|
236
|
+
/** @private @type {Promise<R> | undefined} */
|
|
237
|
+
this._taskComplete = undefined;
|
|
238
|
+
/** @private @type {((value: R) => void) | undefined} */
|
|
239
|
+
this._resolveTaskComplete = undefined;
|
|
240
|
+
/** @private @type {((error: unknown) => void) | undefined} */
|
|
241
|
+
this._rejectTaskComplete = undefined;
|
|
242
|
+
if ('initialValue' in config) {
|
|
243
|
+
this._value = config.initialValue;
|
|
244
|
+
this._status = TaskStatus.COMPLETE;
|
|
245
|
+
this._previousArgs = this._getArgs();
|
|
197
246
|
}
|
|
198
247
|
}
|
|
199
248
|
|
|
200
|
-
// ---- Read-only public properties ----------------------------------------
|
|
201
|
-
|
|
202
249
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
250
|
+
* A promise for the current run's result. It resolves with the value when
|
|
251
|
+
* the run completes and rejects with the error when it fails. When no run
|
|
252
|
+
* is pending it settles with the current value (or rejects with the current
|
|
253
|
+
* error) straight away.
|
|
254
|
+
* @returns {Promise<R>}
|
|
205
255
|
*/
|
|
256
|
+
get taskComplete() {
|
|
257
|
+
if (this._taskComplete) return this._taskComplete;
|
|
258
|
+
if (this._status === TaskStatus.PENDING) {
|
|
259
|
+
this._taskComplete = new Promise((resolve, reject) => {
|
|
260
|
+
this._resolveTaskComplete = resolve;
|
|
261
|
+
this._rejectTaskComplete = reject;
|
|
262
|
+
});
|
|
263
|
+
} else if (this._status === TaskStatus.ERROR) {
|
|
264
|
+
this._taskComplete = Promise.reject(this._error);
|
|
265
|
+
} else {
|
|
266
|
+
this._taskComplete = Promise.resolve(/** @type {R} */ (this._value));
|
|
267
|
+
}
|
|
268
|
+
return this._taskComplete;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @returns {number} One of the `TaskStatus` values. */
|
|
206
272
|
get status() {
|
|
207
273
|
return this._status;
|
|
208
274
|
}
|
|
209
275
|
|
|
210
|
-
/**
|
|
211
|
-
* The resolved value when status is `COMPLETE`, otherwise `undefined`.
|
|
212
|
-
* @returns {T | undefined}
|
|
213
|
-
*/
|
|
276
|
+
/** @returns {R | undefined} The last completed value. */
|
|
214
277
|
get value() {
|
|
215
278
|
return this._value;
|
|
216
279
|
}
|
|
217
280
|
|
|
218
|
-
/**
|
|
219
|
-
* The rejection reason when status is `ERROR`, otherwise `undefined`.
|
|
220
|
-
* @returns {unknown}
|
|
221
|
-
*/
|
|
281
|
+
/** @returns {unknown} The last error, when the status is ERROR. */
|
|
222
282
|
get error() {
|
|
223
283
|
return this._error;
|
|
224
284
|
}
|
|
225
285
|
|
|
226
|
-
|
|
286
|
+
/** ReactiveController hook: auto-run before the host renders. */
|
|
287
|
+
hostUpdate() {
|
|
288
|
+
if (this.autoRun === true) this._performTask();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** ReactiveController hook: auto-run after the host rendered (`'afterUpdate'`). */
|
|
292
|
+
hostUpdated() {
|
|
293
|
+
if (this.autoRun === 'afterUpdate') this._performTask();
|
|
294
|
+
}
|
|
227
295
|
|
|
228
296
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* Any in-flight invocation is aborted before the new one starts. The
|
|
232
|
-
* `args()` function is called to produce the current arguments.
|
|
233
|
-
*
|
|
234
|
-
* @returns {Promise<void>} Resolves when the task finishes (or is
|
|
235
|
-
* superseded). Does not reject: errors are captured in `this.error`.
|
|
297
|
+
* @private
|
|
298
|
+
* @returns {T | undefined}
|
|
236
299
|
*/
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
this.abort();
|
|
240
|
-
|
|
241
|
-
const ac = new AbortController();
|
|
242
|
-
this._abortController = ac;
|
|
243
|
-
|
|
300
|
+
_getArgs() {
|
|
301
|
+
if (this._argsFn === undefined) return undefined;
|
|
244
302
|
const args = this._argsFn();
|
|
245
|
-
|
|
303
|
+
if (!Array.isArray(args)) {
|
|
304
|
+
throw new Error('[webjs] The Task args function must return an array.');
|
|
305
|
+
}
|
|
306
|
+
return args;
|
|
307
|
+
}
|
|
246
308
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
309
|
+
/** @private */
|
|
310
|
+
async _performTask() {
|
|
311
|
+
// Never at SSR: the server renders the INITIAL state, and the args are
|
|
312
|
+
// left unrecorded so the browser's first update runs the task.
|
|
313
|
+
if (!inBrowser()) return;
|
|
314
|
+
const args = this._getArgs();
|
|
315
|
+
const prev = this._previousArgs;
|
|
316
|
+
this._previousArgs = args;
|
|
317
|
+
if (args !== prev && args !== undefined && (prev === undefined || !this._argsEqual(prev, args))) {
|
|
318
|
+
await this.run(args);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
250
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Run the task now, with `args` or else the current `args()`. A pending
|
|
324
|
+
* run is aborted and superseded. Resolves when this run settles; never
|
|
325
|
+
* rejects (a failure lands in `error` and `taskComplete`).
|
|
326
|
+
*
|
|
327
|
+
* @param {T} [args]
|
|
328
|
+
* @returns {Promise<void>}
|
|
329
|
+
*/
|
|
330
|
+
async run(args) {
|
|
331
|
+
args ??= this._getArgs();
|
|
332
|
+
this._previousArgs = args;
|
|
333
|
+
if (this._status === TaskStatus.PENDING) {
|
|
334
|
+
this._abortController?.abort();
|
|
335
|
+
} else {
|
|
336
|
+
// A settled `taskComplete` belongs to the previous run.
|
|
337
|
+
this._taskComplete = undefined;
|
|
338
|
+
this._resolveTaskComplete = undefined;
|
|
339
|
+
this._rejectTaskComplete = undefined;
|
|
340
|
+
}
|
|
341
|
+
this._status = TaskStatus.PENDING;
|
|
342
|
+
/** @type {any} */
|
|
343
|
+
let result;
|
|
344
|
+
/** @type {unknown} */
|
|
345
|
+
let error;
|
|
346
|
+
// Report the pending state. From `hostUpdated` the host is mid-cycle, so
|
|
347
|
+
// the request waits a microtask, as Lit does.
|
|
348
|
+
if (this.autoRun === 'afterUpdate') {
|
|
349
|
+
queueMicrotask(() => this._host.requestUpdate());
|
|
350
|
+
} else {
|
|
351
|
+
this._host.requestUpdate();
|
|
352
|
+
}
|
|
353
|
+
const key = ++this._callId;
|
|
354
|
+
this._abortController = new AbortController();
|
|
355
|
+
let errored = false;
|
|
251
356
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
357
|
+
result = await this._task(/** @type {T} */ (args), { signal: this._abortController.signal });
|
|
358
|
+
} catch (e) {
|
|
359
|
+
errored = true;
|
|
360
|
+
error = e;
|
|
361
|
+
}
|
|
362
|
+
// Only the most recent run reports.
|
|
363
|
+
if (this._callId === key) {
|
|
364
|
+
if (result === initialState) {
|
|
365
|
+
this._status = TaskStatus.INITIAL;
|
|
366
|
+
} else {
|
|
367
|
+
if (errored === false) {
|
|
368
|
+
try {
|
|
369
|
+
this._onComplete?.(result);
|
|
370
|
+
} catch {
|
|
371
|
+
// An onComplete throw must not wedge the task, as in Lit.
|
|
372
|
+
}
|
|
373
|
+
this._status = TaskStatus.COMPLETE;
|
|
374
|
+
this._resolveTaskComplete?.(result);
|
|
375
|
+
} else {
|
|
376
|
+
try {
|
|
377
|
+
this._onError?.(error);
|
|
378
|
+
} catch {
|
|
379
|
+
// An onError throw must not wedge the task, as in Lit.
|
|
380
|
+
}
|
|
381
|
+
this._status = TaskStatus.ERROR;
|
|
382
|
+
this._rejectTaskComplete?.(error);
|
|
383
|
+
}
|
|
384
|
+
this._value = result;
|
|
385
|
+
this._error = error;
|
|
269
386
|
}
|
|
387
|
+
this._host.requestUpdate();
|
|
270
388
|
}
|
|
271
|
-
|
|
272
|
-
this._requestHostUpdate();
|
|
273
389
|
}
|
|
274
390
|
|
|
275
391
|
/**
|
|
276
|
-
* Abort the
|
|
392
|
+
* Abort the pending run, if any, passing `reason` to its `AbortSignal`.
|
|
393
|
+
* The status is left to the task function: a fetch that rejects on abort
|
|
394
|
+
* lands as ERROR.
|
|
277
395
|
*
|
|
278
|
-
*
|
|
279
|
-
* `fetch()` or other signal-aware work inside the task function. The
|
|
280
|
-
* task's status is **not** changed: it remains at whatever state it
|
|
281
|
-
* was before the abort. A subsequent `run()` will transition to
|
|
282
|
-
* PENDING.
|
|
396
|
+
* @param {unknown} [reason]
|
|
283
397
|
*/
|
|
284
|
-
abort() {
|
|
285
|
-
if (this.
|
|
286
|
-
this._abortController
|
|
287
|
-
this._abortController = null;
|
|
398
|
+
abort(reason) {
|
|
399
|
+
if (this._status === TaskStatus.PENDING) {
|
|
400
|
+
this._abortController?.abort(reason);
|
|
288
401
|
}
|
|
289
402
|
}
|
|
290
403
|
|
|
291
404
|
/**
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* Returns the result of calling the callback matching the current
|
|
295
|
-
* status. Omitted callbacks return `undefined`.
|
|
296
|
-
*
|
|
297
|
-
* ```js
|
|
298
|
-
* task.render({
|
|
299
|
-
* initial: () => html`<p>Ready</p>`,
|
|
300
|
-
* pending: () => html`<p>Loading…</p>`,
|
|
301
|
-
* complete: (data) => html`<p>${data}</p>`,
|
|
302
|
-
* error: (err) => html`<p>Error: ${err.message}</p>`,
|
|
303
|
-
* })
|
|
304
|
-
* ```
|
|
405
|
+
* Render the template for the current status. A missing callback renders
|
|
406
|
+
* nothing.
|
|
305
407
|
*
|
|
306
408
|
* @param {{
|
|
307
|
-
* initial?:
|
|
308
|
-
* pending?:
|
|
309
|
-
* complete?: (value:
|
|
310
|
-
* error?:
|
|
311
|
-
* }}
|
|
312
|
-
* @returns {unknown}
|
|
409
|
+
* initial?: () => unknown,
|
|
410
|
+
* pending?: () => unknown,
|
|
411
|
+
* complete?: (value: R) => unknown,
|
|
412
|
+
* error?: (error: unknown) => unknown,
|
|
413
|
+
* }} renderer
|
|
414
|
+
* @returns {unknown}
|
|
313
415
|
*/
|
|
314
|
-
render(
|
|
416
|
+
render(renderer) {
|
|
315
417
|
switch (this._status) {
|
|
316
418
|
case TaskStatus.INITIAL:
|
|
317
|
-
return
|
|
419
|
+
return renderer.initial?.();
|
|
318
420
|
case TaskStatus.PENDING:
|
|
319
|
-
return
|
|
421
|
+
return renderer.pending?.();
|
|
320
422
|
case TaskStatus.COMPLETE:
|
|
321
|
-
return
|
|
423
|
+
return renderer.complete?.(/** @type {R} */ (this._value));
|
|
322
424
|
case TaskStatus.ERROR:
|
|
323
|
-
return
|
|
425
|
+
return renderer.error?.(this._error);
|
|
324
426
|
default:
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ---- ReactiveController lifecycle ---------------------------------------
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Called before the host renders. When `autoRun` is enabled, checks
|
|
333
|
-
* whether `args()` have changed and re-runs the task if so.
|
|
334
|
-
*/
|
|
335
|
-
hostUpdate() {
|
|
336
|
-
if (!this._autoRun) return;
|
|
337
|
-
// Never auto-run server-side: SSR ships the INITIAL state and the task
|
|
338
|
-
// function (typically a fetch) runs only in the browser on hydration.
|
|
339
|
-
if (!inBrowser()) return;
|
|
340
|
-
|
|
341
|
-
const nextArgs = this._argsFn();
|
|
342
|
-
|
|
343
|
-
if (!this._argsEqual(this._prevArgs, nextArgs)) {
|
|
344
|
-
// Don't await: the run will trigger another host update when it
|
|
345
|
-
// transitions status.
|
|
346
|
-
this.run();
|
|
427
|
+
throw new Error(`[webjs] Unexpected Task status: ${this._status}`);
|
|
347
428
|
}
|
|
348
429
|
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Called after the host has rendered. Currently a no-op; required by
|
|
352
|
-
* the ReactiveController interface.
|
|
353
|
-
*/
|
|
354
|
-
hostUpdated() {}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Called when the host connects to the DOM. Currently a no-op -
|
|
358
|
-
* initial run (if autoRun) happens on the first `hostUpdate()`.
|
|
359
|
-
*/
|
|
360
|
-
hostConnected() {}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Called when the host disconnects from the DOM. Aborts any in-flight
|
|
364
|
-
* task to prevent updates to an unmounted component.
|
|
365
|
-
*/
|
|
366
|
-
hostDisconnected() {
|
|
367
|
-
this.abort();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ---- Internal -----------------------------------------------------------
|
|
371
|
-
|
|
372
|
-
/** @private */
|
|
373
|
-
_requestHostUpdate() {
|
|
374
|
-
if (typeof this._host.requestUpdate === 'function') {
|
|
375
|
-
this._host.requestUpdate();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Shallow comparison of two args arrays by identity (`===`).
|
|
381
|
-
* Returns true if both are non-null, same length, and every element
|
|
382
|
-
* is strictly equal.
|
|
383
|
-
*
|
|
384
|
-
* @param {unknown[] | null} a
|
|
385
|
-
* @param {unknown[]} b
|
|
386
|
-
* @returns {boolean}
|
|
387
|
-
* @private
|
|
388
|
-
*/
|
|
389
|
-
_argsEqual(a, b) {
|
|
390
|
-
if (a === null) return false;
|
|
391
|
-
if (a.length !== b.length) return false;
|
|
392
|
-
for (let i = 0; i < a.length; i++) {
|
|
393
|
-
if (a[i] !== b[i]) return false;
|
|
394
|
-
}
|
|
395
|
-
return true;
|
|
396
|
-
}
|
|
397
430
|
}
|