DISCLAIMER: While the code discussed in this article is fairly short in lines of code, it has a pretty robust feature set, much of which I do not purport to fully understand. While one of my goals with this article is to better understand the code discussed herein, one which I feel I've achieved (many paragraphs around suggested alterations have gone through a number of versions in and of themselves), I look forward to any insight you’d like to share regarding any functionality I might be missing or misunderstanding supporting a deeper and broader understanding of what's being delivered. In the case that you're interested in sharing, please share in the comments below so that I can update the main body of the article as needed. Thanks!
And so, it begins...
Last year at Chrome Dev Summit, Justin Fagnani presented on some really exciting extensions to the currently available lit-html
and LitElement
feature set. Including ideas like advanced scheduling for longrunning tasks, chunked rendering, and more, it's quite worth full and repeated watches. If you’ve not already checked it out, I suggest you jump straight to the work presented around async rendering as powered by the runAsync
directive for lit-html
:
runAsync
looks like a pretty awesome addition to the set of directives you can choose to employ when working with lit-html
and, while it’s taken me a long time to get it in writing, I’ve been thinking a lot about what the technique would look like in a more declarative context, something more DOM driven. I wanted to take the power that this addition would give lit-html
and apply it to LitElement
so that it was easily accessible to the broader web components community. Something like:
<do-something-lazily wait="2000">
<div slot="success">Success</div>
<div slot="initial">Initial</div>
<div slot="error">Error</div>
<div slot="pending">Pending</div>
</do-something-lazily>
You could then push things a little further so that you can have a staged “pending” state via something like:
<do-something-lazily wait="2000">
<div slot="success">Success</div>
<div slot="initial">Initial</div>
<div slot="error">Error</div>
<staged-pending slot="pending" wait="500">
<div slot="success">Waiting a lot</div>
<div slot="pending">Waiting a little</div>
</staged-pending>
</do-something-lazily>
But, really, let's do it!
To make this possible, we can apply the runAsync()
directive, in the most creatively named <run-async />
element, and it looks like the following:
Making the DOM for each of the possible states of the async action a slot named after the current stage (though error
seemed more appropriate that failure
, change my mind!) means that with very little work we get a generic version of the example listed above available for us to use. We can take advantage of our fallbackAction
that translates the wait
attribute into the milliseconds with which to start a countdown before our "asynchronous action" completes. By supplying an actual action
this really starts to come to life.
The example below takes advantage of jsonplaceholder.typicode.com and a little bit of synthetic delay over the pipe to really give you an idea of how this could work:
For example only, notice the use of the following to help the placeholder JSON take random amounts of extra time to make it back to the client:
var wait = ms => new Promise((r, j)=>setTimeout(r, ms));
// ...
const simulatedDelay = Math.floor(Math.random() * Math.floor(2000));
await wait(simulatedDelay);
This means that an otherwise "immediate" response to the request for content takes a perceivable amount of time and we are allowed to experience the benefits of the nested <run-async />
element in the "pending" slot. Allowing the UI to be even more communicative with the user based on network conditions is one of the most immediately valuable benefits of this technique. This is much better than keeping your users in suspense as to what's going on in your application as it acquires the content and data with which those users want to interact.
Why can’t I have it now?
Why? Indeed. Currently, this feature is sitting on the lit-html
repo as an open PR that hasn’t had much love from Justin and team for some time. Maybe if everyone reading this is also interested in this functionality we can guilt them into finishing the work and making it available in a production release! I’d be very excited to wrap this implementation of <run-async />
element with some tests and get it on NPM in sort order if it were.
That’s not to say that the current implementations (both the directive and my element) aren't without issue.
Remaining issues, and open questions
When is it initialized?
As currently proposed the only way for the code to get into the "initial" state is for a new InitialStateError();
to be thrown, which is not my favorite thing in the world. Firstly, I think the code should be in the "initial" state by default, not by explicit action, so I don't know why we need this interface (pardon the pun) to begin with. Luckily, in the context of our <run-async />
element, we can hide this implementation detail a bit. However, it still feels a little hinky and whether it's me, you, or the next person to test out runAsync()
, I think it'll continue to be an issue about which people develop confusion. Please share your thoughts about this approach in the comments below, OR even better comment directly into the PR about it. You can agree with me, possibly suggest a better way forward, or suggest some docs to support a broader understanding of this use case, to help me and the next person be less confused. Whichever way, I'll count it as a win!
When throwing isn't really throwing
Even the code internals of runAsync()
rely on thrown errors to manage various state transitions. Particularly, this approach is used to reject our lazy action in favor of a new one. Here, the pendingPromise
stored internally by runAsync()
also gets rejected with a throw. In preparation for this possibility, you will have been able to acquire a reference that promise when it moves to "pending" via a custom event, at which point you can capture any errors that it runs into:
this.addEventListener('pending-state', ({detail}) => {
detail.promise.catch(() => {});
});
In the above example, I catch everything that might reject this promise. If this is the path that runAsync()
releases with, an API for fully managing this state will need to be added to <run-async />
. The work is never done, amirite? Where this causes an issue is when the same pendingPromise
is used to announce the state of the action moving from "pending" to "initial".
Promise.resolve(promise).then(
(value: unknown) => {
// ...
},
(error: Error) => {
const currentRunState = runs.get(part);
runState.rejectPending(new Error());
This causes pendingPromise
to reject even when the new InitialStateError()
is thrown. AND, being the custom event that supplied the pendingPromise
is only dispatched when currentRunState.state === 'pending'
, which is queried after a microtask to mirror the most recently rendered state, which would be "initial" when throwing new InitialStateError()
immediately when your action cannot complete due to a missing key.
(async () => {
// Wait a microtask for the initial render of the Part to complete
await 0;
const currentRunState = runs.get(part);
if (currentRunState === runState && currentRunState.state === 'pending') {
part.startNode.parentNode!.dispatchEvent(
new CustomEvent('pending-state', {
composed: true,
bubbles: true,
detail: {promise: pendingPromise}
})
);
}
})();
This means that you won't have received a reference to pendingPromise
by the point that it rejects in this context in order to catch the error thrown. This doesn't block any of the later functionality, but having random errors flying around your application is certainly not the sort of thing that we engineers pride ourselves about. To work around this issue, I suggest we expand the contexts where the pending-state
event will be dispatched to include the "initial" state, like so:
if (
currentRunState === runState &&
(
currentRunState.state === 'pending' ||
currentRunState.state === 'initial'
)
) {
I'm not 100% sure that this captures the whole of the functionality of which pendingPromise
is supposed to be the basis or not, but it allows the page to run error-free while supplying the lazily loaded content UI that runAsync()
is purpose-built to provide. I've suggested this change in the PR, so feel free to agree or suggest other paths forward here as well.
I want to be "pending", again...
If you’ve looked closely at my code sample and the PR you’ll notice that I reacquire the runState
from the runs
map when testing whether or not to allow the UI to be updated to the “pending” state when a template for that state is available. Currently, the PR outlines the following code:
// If the promise has not yet resolved, set/update the defaultContent
if ((currentRunState === undefined || currentRunState.state === 'pending') &&
typeof pending === 'function') {
part.setValue(pending());
}
However, currentRunState
is taken from the previous run as const currentRunState = runs.get(part);
before being later set to “pending” via:
const runState: AsyncRunState = {
key,
promise,
state: 'pending',
abortController,
resolvePending,
rejectPending,
};
runs.set(part, runState);
This means that the check of currentRunState.state === 'pending'
can never be true if you attempting to supply a new key
to the directive. In the case of the above example, that means you won’t be able to get back to the “Early Wait” or “Long Wait” messaging when requesting a second (or later) form of data to display.
I’ve outlined the following to get around this issue:
const runState = runs.get(part);
// If the promise has not yet resolved, set/update the defaultContent
if ((runState === undefined || runState.state === 'pending') &&
typeof pending === 'function') {
While I agree that it’s not the most creative or even informational variable naming, without going back to the runs
map for the current state you will never be able to find that state to be 'pending'
. Hopefully this suggestion helps this addition to move towards a merge, soon.
TODOs
Beyond the realities that I've run into preparing <run-async />
, Justin has noted some specific contexts where he'd like to add polish to this PR: here and here. The ability to customize invalidation of the key
and the emission of a custom error when aborting the promise would certainly be quality additions to this piece of functionality. However, I feel like they don't need to be blocking the PR by any means. Extending this directive to support those as additional features down the road seems like a decent balance between getting this out soon and ensuring all use cases are covered long term.
What do you think?
How do you feel about the runAsync()
directive? Does it make sense to wrap something like this in a custom element? Have I wrapped the directive in a way that you could see getting benefit from? I'd love to hear your thoughts in the comments below. I can also be found on Twitter as @westbrookj or on the Polymer Slack Channel. Hit me up at any of these places if you wanna talk more about lazy UIs, LitElement, the modern web, or improvised music...I'm always down to chat!
<do-something-lazily wait=${justinMergesThePRInMilliseconds}>
<div slot="success">Publish to NPM</div>
<div slot="initial">Have the idea...</div>
<div slot="error">Learn about issues I've not seen, yet.</div>
<div slot="pending">Review the PR</div> <!-- WE ARE HERE! -->
</do-something-lazily>
Cover image by Tertia van Rensburg on Unsplash
Top comments (1)
Nice @westbrook . I'd love to see this get adopted in. Lot's of uses for tiny amount code.