Skip to content

Commit

Permalink
Merge pull request #16 from jorenbroekema/feat/pending-count
Browse files Browse the repository at this point in the history
feat: support multiple pending tasks and recovering from error
  • Loading branch information
jorenbroekema authored Jun 29, 2021
2 parents 98f2979 + b51c91c commit 5f1b34c
Show file tree
Hide file tree
Showing 11 changed files with 316 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/new-rules-wait.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<suspense-element class="demo">
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<demo-element pending-interval></demo-element>
</suspense-element>

#### 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

Expand Down
19 changes: 18 additions & 1 deletion demo/DemoElement.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { PendingTaskEvent } from '../src/PendingTaskEvent.js';
import { PendingTaskEvent, ResetErrorEvent } from '../index.js';

export class DemoElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
/** @type {string[]} */
this.listData = [];
this.resolve = false;
}

connectedCallback() {
Expand All @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { SuspenseElement } from './src/SuspenseElement.js';
export { PendingTaskEvent } from './src/PendingTaskEvent.js';
export { ResetErrorEvent } from './src/ResetErrorEvent.js';
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/ResetErrorEvent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ResetErrorEvent extends Event {
constructor() {
super('reset-error', { bubbles: true, composed: true });
}
}
76 changes: 67 additions & 9 deletions src/SuspenseElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* @typedef {import('./PendingTaskEvent.js').PendingTaskEvent} PendingTaskEvent
* @typedef {import('./ResetErrorEvent.js').ResetErrorEvent} ResetErrorEvent
*/
export class SuspenseElement extends HTMLElement {
get state() {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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');
}
}
158 changes: 157 additions & 1 deletion test/SuspenseElement.test.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -152,4 +152,160 @@ describe('<suspense-element>', () => {
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(`
<suspense-element>
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<${mainTag}></${mainTag}>
</suspense-element>
`);

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(`
<suspense-element>
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<${mainTag}></${mainTag}>
</suspense-element>
`);

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(`
<suspense-element>
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<${mainTag} reject></${mainTag}>
</suspense-element>
`);

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(`
<suspense-element>
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<${mainTag} reject></${mainTag}>
</suspense-element>
`);

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(`
<suspense-element>
<span slot="fallback">Loading...</span>
<span slot="error">Error :(</span>
<${mainTag} reject></${mainTag}>
</suspense-element>
`);

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`);
});
});
7 changes: 7 additions & 0 deletions web-test-runner-chromium.config.mjs
Original file line number Diff line number Diff line change
@@ -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' })],
};
Loading

0 comments on commit 5f1b34c

Please sign in to comment.