diff --git a/.changeset/new-rules-wait.md b/.changeset/new-rules-wait.md new file mode 100644 index 0000000..1fd04bd --- /dev/null +++ b/.changeset/new-rules-wait.md @@ -0,0 +1,5 @@ +--- +'suspense-element': minor +--- + +Support multiple pending tasks sent in separate events. Support going back to pending state after success. Support going back to pending state after error, by sending a ResetErrorEvent to the suspense-element. diff --git a/README.md b/README.md index 9bccd30..bc70ca9 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,45 @@ class MainElement extends HTMLElement { } ``` -> With a Lit component you would probably set shouldUpdate to false until all data is available that the template depends on and trigger a manual re-render. +### Demo subsequent pending tasks + +If you're viewing the docs site, below is a demo of the suspense-element where the main element fires pending tasks in 3 second intervals, each resolving in 1 second. It switches between resolving and rejecting. + + + Loading... + Error :( + + + +#### ResetErrorEvent + +When sending multiple pending tasks, either in a single event, stacking multiple, or in subsequent (unstacked) pending tasks, `suspense-element` has to decide what to do when any of these tasks throw. +It will display the error slot if it encounters any error. +It will keep doing so even if all pending tasks have completed (some threw), and you send a new one completely separately. +The reason for this behavior is that when your main element depends on asynchronous tasks, and one of them throws at any point, new pending tasks do not mean a recovery from old errors even if the new task resolves, so it makes more sense to maintain the error state. + +If you need to recover from this you can do so manually, by sending a ResetErrorEvent to the `suspense-element`. +This will reset the internal error state and re-evaluate if there are any pending tasks, if so, render the fallback slot. + +```js +import { ResetErrorEvent } from 'suspense-element'; + +class MainElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.dispatchEvent(new PendingTaskEvent( + new Promise((resolve, reject) => setTimeout(reject, 100)), + )); + + setTimeout(() => { + this.dispatchEvent(new ResetErrorEvent()); + // error slot is still displayed, but sending a new pending task event + // will set the state to 'pending' and display fallback slot. + }, 110); + } +} +``` ## Rationale diff --git a/demo/DemoElement.js b/demo/DemoElement.js index 627cc91..3690456 100644 --- a/demo/DemoElement.js +++ b/demo/DemoElement.js @@ -1,4 +1,4 @@ -import { PendingTaskEvent } from '../src/PendingTaskEvent.js'; +import { PendingTaskEvent, ResetErrorEvent } from '../index.js'; export class DemoElement extends HTMLElement { constructor() { @@ -6,6 +6,7 @@ export class DemoElement extends HTMLElement { this.attachShadow({ mode: 'open' }); /** @type {string[]} */ this.listData = []; + this.resolve = false; } connectedCallback() { @@ -18,6 +19,22 @@ export class DemoElement extends HTMLElement { }, 1000), ); this.dispatchEvent(new PendingTaskEvent(this.list)); + + if (this.hasAttribute('pending-interval')) { + setInterval(() => { + this.dispatchEvent(new ResetErrorEvent()); + this.dispatchEvent( + new PendingTaskEvent( + new Promise((resolve, reject) => + setTimeout(() => { + this.resolve ? resolve() : reject(); + this.resolve = !this.resolve; + }, 1000), + ), + ), + ); + }, 3000); + } } render() { diff --git a/index.js b/index.js index 5508fee..7a696fb 100644 --- a/index.js +++ b/index.js @@ -1,2 +1,3 @@ export { SuspenseElement } from './src/SuspenseElement.js'; export { PendingTaskEvent } from './src/PendingTaskEvent.js'; +export { ResetErrorEvent } from './src/ResetErrorEvent.js'; diff --git a/package.json b/package.json index ef0a792..e90e5d3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "scripts": { "build": "npm run custom-elements-manifest && tsc -p tsconfig.build.types.json && npm run demo:build", "custom-elements-manifest": "custom-elements-manifest analyze", - "debug": "wtr test/**/*.test.js --watch", + "debug": "wtr test/**/*.test.js --watch --config web-test-runner-chromium.config.mjs", + "debug:firefox": "wtr test/**/*.test.js --watch --config web-test-runner-firefox.config.mjs", + "debug:webkit": "wtr test/**/*.test.js --watch --config web-test-runner-webkit.config.mjs", "demo:build": "node marked/run-marked.js", "demo:prod": "npm run demo:build && rollup -c rollup.config.js", "demo:watch": "node marked/watch-marked.js", diff --git a/src/ResetErrorEvent.js b/src/ResetErrorEvent.js new file mode 100644 index 0000000..7c33753 --- /dev/null +++ b/src/ResetErrorEvent.js @@ -0,0 +1,5 @@ +export class ResetErrorEvent extends Event { + constructor() { + super('reset-error', { bubbles: true, composed: true }); + } +} diff --git a/src/SuspenseElement.js b/src/SuspenseElement.js index 91e951e..963adb5 100644 --- a/src/SuspenseElement.js +++ b/src/SuspenseElement.js @@ -1,5 +1,6 @@ /** * @typedef {import('./PendingTaskEvent.js').PendingTaskEvent} PendingTaskEvent + * @typedef {import('./ResetErrorEvent.js').ResetErrorEvent} ResetErrorEvent */ export class SuspenseElement extends HTMLElement { get state() { @@ -11,12 +12,27 @@ export class SuspenseElement extends HTMLElement { this.setAttribute('state', value); } + get pendingTaskCount() { + return /** @type {number} */ (this._pendingTaskCount); + } + + /** @param {number} value */ + set pendingTaskCount(value) { + this._pendingTaskCount = value; + + this._handleState(); + } + constructor() { super(); + this._pendingTaskCount = 0; this.state = 'pending'; + this._errorState = false; this.attachShadow({ mode: 'open' }); this.boundPendingTaskEventHandler = this.pendingTaskEventHandler.bind(this); + this.boundResetErrorHandler = this.resetErrorHandler.bind(this); this.addEventListener('pending-task', this.boundPendingTaskEventHandler); + this.addEventListener('reset-error', this.boundResetErrorHandler); this.render(); } @@ -49,21 +65,63 @@ export class SuspenseElement extends HTMLElement { } } + /** @param {ResetErrorEvent} e */ + resetErrorHandler(e) { + e.stopPropagation(); + this._errorState = false; + if (this.pendingTaskCount > 0) { + this._setPending(); + } + } + /** @param {Event} e */ async pendingTaskEventHandler(e) { const _e = /** @type {PendingTaskEvent} */ (e); _e.stopPropagation(); - + this.pendingTaskCount += 1; _e.complete - .then(() => { - this.state = 'success'; - this.style.setProperty('--main-display', 'block'); - this.style.setProperty('--fallback-display', 'none'); - }) .catch(() => { - this.state = 'error'; - this.style.setProperty('--error-display', 'block'); - this.style.setProperty('--fallback-display', 'none'); + this._errorState = true; + }) + .finally(() => { + this.pendingTaskCount -= 1; }); } + + _handleState() { + if (this._errorState) { + this._setError(); + return; + } + + if (this.pendingTaskCount > 0 && this.state !== 'pending') { + this._setPending(); + return; + } + + if (this.pendingTaskCount === 0) { + this._setSuccess(); + } + } + + _setPending() { + this.state = 'pending'; + this.style.setProperty('--error-display', 'none'); + this.style.setProperty('--fallback-display', 'block'); + this.style.setProperty('--main-display', 'none'); + } + + _setError() { + this.state = 'error'; + this.style.setProperty('--error-display', 'block'); + this.style.setProperty('--fallback-display', 'none'); + this.style.setProperty('--main-display', 'none'); + } + + _setSuccess() { + this.state = 'success'; + this.style.setProperty('--error-display', 'none'); + this.style.setProperty('--fallback-display', 'none'); + this.style.setProperty('--main-display', 'block'); + } } diff --git a/test/SuspenseElement.test.js b/test/SuspenseElement.test.js index 6deeab8..7660a7a 100644 --- a/test/SuspenseElement.test.js +++ b/test/SuspenseElement.test.js @@ -1,5 +1,5 @@ import { expect, fixture as _fixture, defineCE, aTimeout } from '@open-wc/testing'; -import { PendingTaskEvent } from '../src/PendingTaskEvent.js'; +import { PendingTaskEvent, ResetErrorEvent } from '../index.js'; import '../define.js'; /** @@ -152,4 +152,160 @@ describe('', () => { expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); }); + + it('handles subsequent async tasks through pending-task events, showing again the fallback until it resolves', async () => { + const el = await fixture(` + + Loading... + Error :( + <${mainTag}> + + `); + + const { mainSlottable, fallbackSlot, errorSlot, mainSlot } = getMembers(el); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + }); + + it('handles multiple stacked pending tasks and resolves to rendering the main element after all of them have resolved', async () => { + const el = await fixture(` + + Loading... + Error :( + <${mainTag}> + + `); + + const { mainSlottable, fallbackSlot, errorSlot, mainSlot } = getMembers(el); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 150))), + ); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + await aTimeout(150); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + }); + + it('keeps showing the error slot by default when a single pending task rejects', async () => { + const el = await fixture(` + + Loading... + Error :( + <${mainTag} reject> + + `); + + const { mainSlottable, fallbackSlot, errorSlot, mainSlot } = getMembers(el); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + }); + + it('supports resetting the error state in order for pending and success to be possible again', async () => { + const el = await fixture(` + + Loading... + Error :( + <${mainTag} reject> + + `); + + const { mainSlottable, fallbackSlot, errorSlot, mainSlot } = getMembers(el); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + mainSlottable.dispatchEvent(new ResetErrorEvent()); + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + }); + + it('supports resetting the error state while a pending task was already sent and is still pending', async () => { + const el = await fixture(` + + Loading... + Error :( + <${mainTag} reject> + + `); + + const { mainSlottable, fallbackSlot, errorSlot, mainSlot } = getMembers(el); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + mainSlottable.dispatchEvent( + new PendingTaskEvent(new Promise((resolve) => setTimeout(resolve, 50))), + ); + mainSlottable.dispatchEvent(new ResetErrorEvent()); + + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`block`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`none`); + + await aTimeout(50); + expect(getComputedStyle(errorSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(fallbackSlot).getPropertyValue('display')).to.equal(`none`); + expect(getComputedStyle(mainSlot).getPropertyValue('display')).to.equal(`block`); + }); }); diff --git a/web-test-runner-chromium.config.mjs b/web-test-runner-chromium.config.mjs new file mode 100644 index 0000000..38710a6 --- /dev/null +++ b/web-test-runner-chromium.config.mjs @@ -0,0 +1,7 @@ +import { playwrightLauncher } from '@web/test-runner-playwright'; +import baseConfig from './web-test-runner.config.mjs'; + +export default { + ...baseConfig, + browsers: [playwrightLauncher({ product: 'chromium' })], +}; diff --git a/web-test-runner-firefox.config.mjs b/web-test-runner-firefox.config.mjs new file mode 100644 index 0000000..60ce90a --- /dev/null +++ b/web-test-runner-firefox.config.mjs @@ -0,0 +1,7 @@ +import { playwrightLauncher } from '@web/test-runner-playwright'; +import baseConfig from './web-test-runner.config.mjs'; + +export default { + ...baseConfig, + browsers: [playwrightLauncher({ product: 'firefox', concurrency: 1 })], +}; diff --git a/web-test-runner-webkit.config.mjs b/web-test-runner-webkit.config.mjs new file mode 100644 index 0000000..5de663e --- /dev/null +++ b/web-test-runner-webkit.config.mjs @@ -0,0 +1,7 @@ +import { playwrightLauncher } from '@web/test-runner-playwright'; +import baseConfig from './web-test-runner.config.mjs'; + +export default { + ...baseConfig, + browsers: [playwrightLauncher({ product: 'webkit' })], +};