From 28e8741395cdc96b2c94d1bf185cd83e2cd9122f Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 8 Jul 2023 09:56:41 -0700 Subject: [PATCH 1/7] Run tasks in update instead of updated --- .changeset/chilled-jeans-march.md | 5 ++ packages/labs/task/src/task.ts | 93 ++++++++++++++---------- packages/labs/task/src/test/task_test.ts | 43 ++++++++++- 3 files changed, 101 insertions(+), 40 deletions(-) create mode 100644 .changeset/chilled-jeans-march.md diff --git a/.changeset/chilled-jeans-march.md b/.changeset/chilled-jeans-march.md new file mode 100644 index 0000000000..822d2cb889 --- /dev/null +++ b/.changeset/chilled-jeans-march.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/task': major +--- + +Run tasks in update instead of updated diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index 5e7f6f4322..56f47ae6fe 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -63,57 +63,77 @@ export interface TaskConfig, R> { onError?: (error: unknown) => unknown; } -// TODO(sorvell): Some issues: -// 1. When task is triggered in `updated`, this generates a ReactiveElement -// warning that the update was triggered in response to an update. -// 2. And as a result of triggering in `updated`, if the user waits for the -// `updateComplete` promise they will not see a `pending` state since this -// will be triggered in another update; they would need to -// `while (!(await el.updateComplete));`. -// 3. If this is instead or additionally triggered in `willUpdate`, the -// warning goes away in the common case that the update itself does not change -// the deps; however, the `requestUpdate` to render pending state will not +// TODO(sorvell / justinfagnani): Some issues: +// 1. With the task triggered in `update`, there is no ReactiveElement +// change-in-update warning in the common case that the update itself does not change +// the deps; however, Task's `requestUpdate` call to render pending state will not // trigger another update since the element is updating. This `requestUpdate` -// could be triggered in updated, but that results in the same issue as #2. -// 4. There is no good signal for when the task has resolved and rendered other +// could be triggered in updated, but that results a change-in-update warning. +// 2. There is no good signal for when the task has resolved and rendered other // than requestAnimationFrame. The user would need to store a promise for the -// task and then wait for that and the element to update. +// task and then wait for that and the element to update. (Update just justinfagnani: +// Why isn't waiting taskComplete and updateComplete sufficient? This comment is +// from before taskComplete existed!) /** - * A controller that performs an asynchronous task like a fetch when its host - * element updates. The controller performs an update on the host element - * when the task becomes pending and when it completes. The task function must - * be supplied and can take a list of dependencies specified as a function that - * returns a list of values. The `value` property reports the completed value, - * and the `error` property an error state if one occurs. The `status` property - * can be checked for status and is of type `TaskStatus` which has states for - * initial, pending, complete, and error. The `render` method accepts an - * object with optional corresponding state method to easily render values - * corresponding to the task state. + * A controller that performs an asynchronous task (like a fetch) when its + * host element updates. + * + * Task requests an update on the host element when the task starts and + * completes so that the host can render the task status, value, and error as + * the task runs. + * + * The task function must be supplied and can take a list of arguments. The + * arguments are given to the Task as a function that returns a list of values, + * which is run and checked for changes on every host update. + * + * The `value` property reports the completed value, and the `error` property + * an error state if one occurs. The `status` property can be checked for + * status and is of type `TaskStatus` which has states for initial, pending, + * complete, and error. + * + * The `render` method accepts an object with optional methods corresponding + * to the task statuses to easily render different templates for each task + * status. * * The task is run automatically when its arguments change; however, this can * be customized by setting `autoRun` to false and calling `run` explicitly * to run the task. * - * class MyElement extends ReactiveElement { + * For a task to see state changes in the current update pass of the host + * element, those changes must be made in `willUpdate()`. State changes in + * `update()` or `updated()` will not be visible to the task until the next + * update pass. + * + * @example + * + * ```ts + * class MyElement extends LitElement { * url = 'example.com/api'; * id = 0; + * * task = new Task( - * this, { - * task: ([url, id]) => - * fetch(`${this.url}?id=${this.id}`).then(response => response.json()), - * args: () => [this.id, this.url] + * this, + * { + * task: async ([url, id]) => { + * const response = await fetch(`${this.url}?id=${this.id}`); + * if (!response.ok) { + * throw new Error(response.statusText); + * } + * return response.json(); + * }, + * args: () => [this.id, this.url], * } * ); * - * update(changedProperties) { - * super.update(changedProperties); - * this.task.render({ - * pending: () => console.log('task pending'), - * complete: (value) => console.log('task value', value); + * render() { + * return this.task.render({ + * pending: () => html`

Loading...

`, + * complete: (value) => html`

Result: ${value}

` * }); * } * } + * ``` */ export class Task< T extends ReadonlyArray = ReadonlyArray, @@ -204,7 +224,7 @@ export class Task< } } - hostUpdated() { + hostUpdate() { this.performTask(); } @@ -252,9 +272,8 @@ export class Task< let result!: R | typeof initialState; let error: unknown; - // Request an update to report pending state. Do this in a - // microtask to avoid the change-in-update warning - queueMicrotask(() => this._host.requestUpdate()); + // Request an update to report pending state. + this._host.requestUpdate(); const key = ++this._callId; this._abortController = new AbortController(); diff --git a/packages/labs/task/src/test/task_test.ts b/packages/labs/task/src/test/task_test.ts index bce98225a6..396c096dbf 100644 --- a/packages/labs/task/src/test/task_test.ts +++ b/packages/labs/task/src/test/task_test.ts @@ -686,7 +686,7 @@ suite('Task', () => { assert.equal(warnMessages.length, 0); }); - test('Tasks can see effects of update()', async () => { + test('Tasks can see effects of willUpdate()', async () => { class TestElement extends ReactiveElement { task = new Task(this, { args: () => [], @@ -697,8 +697,7 @@ suite('Task', () => { value = 'foo'; taskObservedValue: string | undefined = undefined; - override update(changedProps: PropertyValues) { - super.update(changedProps); + override willUpdate() { this.value = 'bar'; } } @@ -711,6 +710,44 @@ suite('Task', () => { assert.equal(el.taskObservedValue, 'bar'); }); + test('Elements only render once for pending tasks', async () => { + let resolveTask: (v: unknown) => void; + let renderCount = 0; + class TestElement extends ReactiveElement { + task = new Task(this, { + args: () => [], + task: () => new Promise((res) => (resolveTask = res)), + }); + + override update(changedProperties: PropertyValues) { + super.update(changedProperties); + renderCount++; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.appendChild(el); + // The first update will trigger the task + await el.task.taskComplete; + assert.equal(renderCount, 1); + assert.equal(el.task.status, TaskStatus.PENDING); + + // The task starting should not trigger another update + await el.updateComplete; + assert.equal(renderCount, 1); + assert.equal(el.task.status, TaskStatus.PENDING); + + // But the task completing should + resolveTask!(undefined); + // TODO (justinfagnani): Awaiting taskComplete and updateComplete is + // similar in function to await tasksUpdateComplete(), but more accurate + // due to not relying on a rAF. We should update all the tests. + await el.task.taskComplete; + await el.updateComplete; + assert.equal(renderCount, 2); + assert.equal(el.task.status, TaskStatus.COMPLETE); + }); + test('performTask waits on the task', async () => { const el = getTestElement({ args: () => [el.a], From 6c47b8f6ce6ba76ed44f859800202f79eacf415f Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sun, 9 Jul 2023 09:24:22 -0700 Subject: [PATCH 2/7] Add autoRun = 'afterUpdate' support --- packages/labs/task/src/task.ts | 73 +++++++++++++++++------- packages/labs/task/src/test/task_test.ts | 33 ++++++++++- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index 56f47ae6fe..0ef57ccf12 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -48,7 +48,26 @@ export type StatusRenderer = { export interface TaskConfig, R> { task: TaskFunction; args?: ArgsFunction; - autoRun?: boolean; + + /** + * Determines if the task is run automatically when arguments change after a + * host update. + * + * If `true`, the task checks arguments during the host update (after + * `willUpdate()` and before `update()` in Lit) and runs if they change. For + * a task to see argument changes they must be done in `willUpdate()` or + * earlier. The host element can see task status changes caused by its own + * current update. + * + * If `'afterUpdate'`, the task checks arguments and runs _after_ the host + * update. This means that the task can see host changes done in update, such + * as rendered DOM. The host element can not see task status changes caused + * by its own update, so the task must trigger a second host update to make + * those changes renderable. + * + * Note: `'afterUpdate'` is unlikely to be SSR compatible in the future. + */ + autoRun?: boolean | 'afterUpdate'; /** * If initialValue is provided, the task is initialized to the COMPLETE @@ -154,7 +173,7 @@ export class Task< /** * Controls if they task will run when its arguments change. Defaults to true. */ - autoRun = true; + autoRun: boolean | 'afterUpdate'; /** * A Promise that resolve when the current task run is complete. @@ -204,17 +223,14 @@ export class Task< task: TaskFunction | TaskConfig, args?: ArgsFunction ) { - this._host = host; - this._host.addController(this); + (this._host = host).addController(this); const taskConfig = typeof task === 'object' ? task : ({task, args} as TaskConfig); this._task = taskConfig.task; this._getArgs = taskConfig.args; this._onComplete = taskConfig.onComplete; this._onError = taskConfig.onError; - if (taskConfig.autoRun !== undefined) { - this.autoRun = taskConfig.autoRun; - } + this.autoRun = taskConfig.autoRun ?? true; // Providing initialValue puts the task in COMPLETE state and stores the // args immediately so it only runs when they change again. if ('initialValue' in taskConfig) { @@ -225,7 +241,15 @@ export class Task< } hostUpdate() { - this.performTask(); + if (this.autoRun === true) { + this.performTask(); + } + } + + hostUpdated() { + if (this.autoRun === 'afterUpdate') { + this.performTask(); + } } protected async performTask() { @@ -236,23 +260,27 @@ export class Task< } /** - * Determines if the task should run when it's triggered as part of the - * host's reactive lifecycle. Note, this is not checked when `run` is - * explicitly called. A task runs automatically when `autoRun` is `true` and - * either its arguments change. + * Determines if the task should run when it's triggered because of a + * host update. A task should run when its arguments change from the + * previous run. + * + * Note: this is not checked when `run` is explicitly called. + * * @param args The task's arguments - * @returns */ protected shouldRun(args?: T) { - return this.autoRun && this._argsDirty(args); + return this._argsDirty(args); } /** - * A task runs when its arguments change, as long as the `autoRun` option - * has not been set to false. To explicitly run a task outside of these - * conditions, call `run`. A custom set of arguments can optionally be passed - * and if not given, the configured arguments are used. - * @param args optional set of arguments to use for this task run + * Runs a task manually. + * + * This can be useful for running tasks in response to events as opposed to + * automatically running when host element state changes. + * + * @param args an optional set of arguments to use for this task run. If args + * is not given, the args function is called to get the arguments for + * this run. */ async run(args?: T) { args ??= this._getArgs?.(); @@ -273,7 +301,12 @@ export class Task< let error: unknown; // Request an update to report pending state. - this._host.requestUpdate(); + if (this.autoRun === 'afterUpdate') { + // Avoids a change-in-update warning + queueMicrotask(() => this._host.requestUpdate()); + } else { + this._host.requestUpdate(); + } const key = ++this._callId; this._abortController = new AbortController(); diff --git a/packages/labs/task/src/test/task_test.ts b/packages/labs/task/src/test/task_test.ts index 396c096dbf..f1b2d16cd4 100644 --- a/packages/labs/task/src/test/task_test.ts +++ b/packages/labs/task/src/test/task_test.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {ReactiveElement, PropertyValues} from '@lit/reactive-element'; -import {property} from '@lit/reactive-element/decorators/property.js'; +import {ReactiveElement, LitElement, html, PropertyValues} from 'lit'; +import {property, query} from 'lit/decorators.js'; import { initialState, Task, @@ -748,6 +748,35 @@ suite('Task', () => { assert.equal(el.task.status, TaskStatus.COMPLETE); }); + test('Tasks can depend on host rendered DOM', async () => { + LitElement.enableWarning?.('change-in-update'); + class TestElement extends LitElement { + task = new Task(this, { + args: () => [this.foo], + task: async ([foo]) => foo, + autoRun: 'afterUpdate', + }); + + @query('#foo') + foo?: HTMLElement; + + override render() { + console.log('render'); + return html`
`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.appendChild(el); + await el.updateComplete; + await el.task.taskComplete; + + assert.ok(el.task.value); + assert.equal(el.task.status, TaskStatus.COMPLETE); + // Make sure we avoid change-in-update warnings + assert.equal(warnMessages.length, 0); + }); + test('performTask waits on the task', async () => { const el = getTestElement({ args: () => [el.a], From b22a4bf351475a8e71e26b6c7e216dabc7cbb987 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sun, 9 Jul 2023 09:30:26 -0700 Subject: [PATCH 3/7] Fix --- .changeset/chilled-jeans-march.md | 2 +- packages/labs/task/package.json | 3 ++- packages/labs/task/src/task.ts | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/chilled-jeans-march.md b/.changeset/chilled-jeans-march.md index 822d2cb889..4ee4eec009 100644 --- a/.changeset/chilled-jeans-march.md +++ b/.changeset/chilled-jeans-march.md @@ -2,4 +2,4 @@ '@lit-labs/task': major --- -Run tasks in update instead of updated +Adds the `'afterUpdate'` option for `autoRun` to Task, and runs tasks by default in `hostUpdate()` instead of `hostUpdated()`. diff --git a/packages/labs/task/package.json b/packages/labs/task/package.json index 6258fde7a1..76e500f4af 100644 --- a/packages/labs/task/package.json +++ b/packages/labs/task/package.json @@ -144,7 +144,8 @@ "author": "Google LLC", "devDependencies": { "@types/trusted-types": "^2.0.2", - "@lit-internal/scripts": "^1.0.0" + "@lit-internal/scripts": "^1.0.0", + "lit": "^2.0.0" }, "dependencies": { "@lit/reactive-element": "^1.1.0" diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index 0ef57ccf12..7c50e15d0d 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -66,6 +66,9 @@ export interface TaskConfig, R> { * those changes renderable. * * Note: `'afterUpdate'` is unlikely to be SSR compatible in the future. + * + * If `false`, the task is not run automatically, and must be run with the + * {@linkcode run} method. */ autoRun?: boolean | 'afterUpdate'; From 579a92703e617b86946bee31a32a7e596c16d9d1 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sun, 9 Jul 2023 09:39:03 -0700 Subject: [PATCH 4/7] Update package-lock --- package-lock.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a94133979c..c7a86feb97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25734,7 +25734,8 @@ }, "devDependencies": { "@lit-internal/scripts": "^1.0.0", - "@types/trusted-types": "^2.0.2" + "@types/trusted-types": "^2.0.2", + "lit": "^2.0.0" } }, "packages/labs/test-projects/test-element-a": { @@ -28392,7 +28393,8 @@ "requires": { "@lit-internal/scripts": "^1.0.0", "@lit/reactive-element": "^1.1.0", - "@types/trusted-types": "^2.0.2" + "@types/trusted-types": "^2.0.2", + "lit": "^2.0.0" } }, "@lit-labs/testing": { From 62a0bb225448d03823e6da5718780d5c1c1eb7c1 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 10 Jul 2023 10:06:05 -0700 Subject: [PATCH 5/7] Add lit to wireit build dependency --- packages/labs/task/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/labs/task/package.json b/packages/labs/task/package.json index 76e500f4af..2454986d55 100644 --- a/packages/labs/task/package.json +++ b/packages/labs/task/package.json @@ -50,12 +50,14 @@ "build:ts", "build:ts:types", "build:rollup", + "../../lit:build", "../../reactive-element:build" ] }, "build:ts": { "command": "tsc --build --pretty", "dependencies": [ + "../../lit:build:ts:types", "../../reactive-element:build:ts:types" ], "clean": "if-file-deleted", From f60735928aad0ded0b7d8fb69b42d2785d035144 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 10 Jul 2023 17:15:52 -0700 Subject: [PATCH 6/7] Address feedback --- packages/labs/task/src/task.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index 7c50e15d0d..9bb861f161 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -51,7 +51,7 @@ export interface TaskConfig, R> { /** * Determines if the task is run automatically when arguments change after a - * host update. + * host update. Default to `true`. * * If `true`, the task checks arguments during the host update (after * `willUpdate()` and before `update()` in Lit) and runs if they change. For @@ -174,7 +174,25 @@ export class Task< status: TaskStatus = TaskStatus.INITIAL; /** - * Controls if they task will run when its arguments change. Defaults to true. + * Determines if the task is run automatically when arguments change after a + * host update. Default to `true`. + * + * If `true`, the task checks arguments during the host update (after + * `willUpdate()` and before `update()` in Lit) and runs if they change. For + * a task to see argument changes they must be done in `willUpdate()` or + * earlier. The host element can see task status changes caused by its own + * current update. + * + * If `'afterUpdate'`, the task checks arguments and runs _after_ the host + * update. This means that the task can see host changes done in update, such + * as rendered DOM. The host element can not see task status changes caused + * by its own update, so the task must trigger a second host update to make + * those changes renderable. + * + * Note: `'afterUpdate'` is unlikely to be SSR compatible in the future. + * + * If `false`, the task is not run automatically, and must be run with the + * {@linkcode run} method. */ autoRun: boolean | 'afterUpdate'; From b35973734e861c20e397801bbf610b0d56e17f62 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 10 Jul 2023 21:13:56 -0700 Subject: [PATCH 7/7] Address feedback --- .changeset/chilled-jeans-march.md | 2 +- packages/labs/task/src/task.ts | 17 +---------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/.changeset/chilled-jeans-march.md b/.changeset/chilled-jeans-march.md index 4ee4eec009..42fb9a2287 100644 --- a/.changeset/chilled-jeans-march.md +++ b/.changeset/chilled-jeans-march.md @@ -2,4 +2,4 @@ '@lit-labs/task': major --- -Adds the `'afterUpdate'` option for `autoRun` to Task, and runs tasks by default in `hostUpdate()` instead of `hostUpdated()`. +Adds the `'afterUpdate'` option for `autoRun` to Task, and runs tasks by default in `hostUpdate()` instead of `hostUpdated()`. `'afterUpdate'` is needed to run tasks dependent on DOM updates, but will cause multiple renders of the host element. diff --git a/packages/labs/task/src/task.ts b/packages/labs/task/src/task.ts index bfe794b349..782d3887ea 100644 --- a/packages/labs/task/src/task.ts +++ b/packages/labs/task/src/task.ts @@ -176,22 +176,7 @@ export class Task< * Determines if the task is run automatically when arguments change after a * host update. Default to `true`. * - * If `true`, the task checks arguments during the host update (after - * `willUpdate()` and before `update()` in Lit) and runs if they change. For - * a task to see argument changes they must be done in `willUpdate()` or - * earlier. The host element can see task status changes caused by its own - * current update. - * - * If `'afterUpdate'`, the task checks arguments and runs _after_ the host - * update. This means that the task can see host changes done in update, such - * as rendered DOM. The host element can not see task status changes caused - * by its own update, so the task must trigger a second host update to make - * those changes renderable. - * - * Note: `'afterUpdate'` is unlikely to be SSR compatible in the future. - * - * If `false`, the task is not run automatically, and must be run with the - * {@linkcode run} method. + * @see {@link TaskConfig.autoRun} for more information. */ autoRun: boolean | 'afterUpdate';