@webjsdev/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +22 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-reader.js +197 -23
  19. package/src/binding-prefixes.js +0 -12
  20. package/src/component/lifecycle.js +760 -209
  21. package/src/component/reactive.js +2 -10
  22. package/src/component/server-element.js +23 -1
  23. package/src/component.d.ts +151 -41
  24. package/src/context.d.ts +94 -18
  25. package/src/context.js +341 -217
  26. package/src/converters.d.ts +7 -0
  27. package/src/converters.js +38 -0
  28. package/src/css.d.ts +32 -6
  29. package/src/css.js +178 -14
  30. package/src/directive.d.ts +58 -0
  31. package/src/directive.js +119 -0
  32. package/src/directives.d.ts +38 -1
  33. package/src/directives.js +158 -1
  34. package/src/form-action.d.ts +13 -0
  35. package/src/form-action.js +139 -4
  36. package/src/hot.js +23 -0
  37. package/src/html.d.ts +4 -0
  38. package/src/html.js +35 -3
  39. package/src/is-server.d.ts +2 -0
  40. package/src/is-server.js +18 -0
  41. package/src/jsx-guard.js +130 -0
  42. package/src/metadata.d.ts +22 -3
  43. package/src/nav.d.ts +4 -1
  44. package/src/nav.js +44 -11
  45. package/src/optimistic.js +2 -2
  46. package/src/refresh-header.js +52 -0
  47. package/src/registry.js +10 -1
  48. package/src/render-client/adopt-slots.js +8 -3
  49. package/src/render-client/parts.js +745 -31
  50. package/src/render-client/reconciler.js +56 -11
  51. package/src/render-client/template-compiler.js +139 -44
  52. package/src/render-client.d.ts +5 -1
  53. package/src/render-server/attrs.js +2 -2
  54. package/src/render-server/compile.js +34 -0
  55. package/src/render-server/errors.js +37 -0
  56. package/src/render-server/expand.js +61 -8
  57. package/src/render-server/served-attrs.js +1 -2
  58. package/src/render-server/walk.js +135 -3
  59. package/src/render-server.d.ts +3 -0
  60. package/src/render-server.js +2 -0
  61. package/src/repeat.d.ts +7 -2
  62. package/src/repeat.js +20 -6
  63. package/src/router-client/dom-differ.js +17 -0
  64. package/src/router-client/fetch-apply.js +86 -5
  65. package/src/router-client/navigator.js +45 -9
  66. package/src/router-client/prefetch.js +3 -4
  67. package/src/router-client/root-attrs.js +43 -0
  68. package/src/router-client/stream.js +7 -0
  69. package/src/router-client/swap.js +4 -1
  70. package/src/routes.d.ts +75 -18
  71. package/src/serialize.d.ts +2 -0
  72. package/src/serialize.js +35 -4
  73. package/src/signal.d.ts +7 -0
  74. package/src/signal.js +26 -6
  75. package/src/slot/polyfills.js +3 -3
  76. package/src/slot.js +4 -8
  77. package/src/static-html.d.ts +22 -0
  78. package/src/static-html.js +144 -0
  79. package/src/task.d.ts +63 -20
  80. package/src/task.js +326 -293
  81. package/src/webjs-config.d.ts +1 -8
package/src/task.js CHANGED
@@ -1,27 +1,21 @@
1
1
  /**
2
- * Task: a ReactiveController for managing async data fetching inside
3
- * web components.
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 when a component needs to fetch data and render
8
- * loading / error / success states. Task handles `AbortController`
9
- * automatically: navigating away or re-running cancels the previous
10
- * in-flight request, preventing race conditions and wasted work.
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
- * task: async (query, { signal }) => {
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 html`
44
- * <input .value=${this.query}
45
- * @input=${e => this.query = e.target.value}>
46
- * ${this._search.render({
47
- * initial: () => html`<p>Type to search</p>`,
48
- * pending: () => html`<p>Searching…</p>`,
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
- * ### Manual control
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
- * Possible states of a Task.
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
- // Task controller
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
- * A ReactiveController that runs an async function, tracks its lifecycle
117
- * (initial → pending → complete | error), and triggers host re-renders
118
- * on state transitions.
119
- *
120
- * ## AI guidance
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
- * - **`task.render()`** provides declarative async UI. Pass an object
123
- * with `initial`, `pending`, `complete`, and `error` callbacks and
124
- * Task returns the right template for the current status:
125
- * ```js
126
- * task.render({
127
- * pending: () => html`Loading…`,
128
- * complete: (data) => html`${data.name}`,
129
- * error: (err) => html`Error: ${err.message}`,
130
- * })
131
- * ```
132
- * Omitted callbacks return `undefined` (renders nothing).
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
- * - **AbortController** is managed automatically. Each call to `run()`
135
- * aborts the previous in-flight request. The `{ signal }` object
136
- * passed as the last argument to your task function is wired to the
137
- * internal AbortController: pass it to `fetch()`, `ReadableStream`,
138
- * or any other signal-aware API.
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
- * - **`args`** is a function that returns an array. It is re-evaluated
141
- * on every `hostUpdate()`. When `autoRun` is true (the default) and
142
- * the args have changed (shallow identity comparison per element),
143
- * the task automatically re-runs. This is how search-as-you-type and
144
- * reactive data loading work.
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
- * - When the host disconnects (e.g. the component is removed from the
147
- * DOM), any in-flight task is aborted automatically: no cleanup
148
- * needed.
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 {import('./component.js').WebComponent} host
155
- * The host component that owns this task.
156
- * @param {{
157
- * task: (...args: [...unknown[], { signal: AbortSignal }]) => Promise<T>,
158
- * args?: () => unknown[],
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, { task, args, autoRun = true }) {
170
- /** @type {import('./component.js').WebComponent} */
201
+ constructor(host, task, args) {
202
+ /** @private */
171
203
  this._host = host;
172
- /** @type {(...args: any[]) => Promise<T>} */
173
- this._taskFn = task;
174
- /** @type {() => unknown[]} */
175
- this._argsFn = args || (() => []);
176
- /** @type {boolean} */
177
- this._autoRun = autoRun;
178
-
179
- /** @type {number}: current TaskStatus */
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 {T | undefined} */
226
+ /** @private @type {R | undefined} */
182
227
  this._value = undefined;
183
- /** @type {unknown} */
228
+ /** @private @type {unknown} */
184
229
  this._error = undefined;
185
-
186
- /** @type {AbortController | null} */
187
- this._abortController = null;
188
-
189
- /**
190
- * Snapshot of args from the last run, used for shallow comparison.
191
- * @type {unknown[] | null}
192
- */
193
- this._prevArgs = null;
194
-
195
- if (typeof host.addController === 'function') {
196
- host.addController(this);
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
- * Current status of the task.
204
- * @returns {number} One of the `TaskStatus` enum values.
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
- // ---- Public methods -----------------------------------------------------
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
- * Manually trigger (or re-trigger) the task.
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
- async run() {
238
- // Abort previous.
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
- this._prevArgs = args;
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
- this._status = TaskStatus.PENDING;
248
- this._error = undefined;
249
- this._requestHostUpdate();
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
- const result = await this._taskFn(...args, { signal: ac.signal });
253
-
254
- // Guard: if this invocation was superseded while awaiting, discard.
255
- if (ac.signal.aborted) return;
256
-
257
- this._value = result;
258
- this._status = TaskStatus.COMPLETE;
259
- this._error = undefined;
260
- } catch (err) {
261
- if (ac.signal.aborted) return;
262
-
263
- this._error = err;
264
- this._status = TaskStatus.ERROR;
265
- this._value = undefined;
266
- } finally {
267
- if (this._abortController === ac) {
268
- this._abortController = null;
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 current in-flight task (if any).
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
- * The AbortController's signal is set to aborted, which cancels any
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._abortController) {
286
- this._abortController.abort();
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
- * Declarative async UI helper.
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?: () => unknown,
308
- * pending?: () => unknown,
309
- * complete?: (value: T) => unknown,
310
- * error?: (error: unknown) => unknown,
311
- * }} handlers
312
- * @returns {unknown} The template result for the current status.
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(handlers = {}) {
416
+ render(renderer) {
315
417
  switch (this._status) {
316
418
  case TaskStatus.INITIAL:
317
- return handlers.initial?.();
419
+ return renderer.initial?.();
318
420
  case TaskStatus.PENDING:
319
- return handlers.pending?.();
421
+ return renderer.pending?.();
320
422
  case TaskStatus.COMPLETE:
321
- return handlers.complete?.(/** @type {T} */ (this._value));
423
+ return renderer.complete?.(/** @type {R} */ (this._value));
322
424
  case TaskStatus.ERROR:
323
- return handlers.error?.(this._error);
425
+ return renderer.error?.(this._error);
324
426
  default:
325
- return undefined;
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
  }