-
Notifications
You must be signed in to change notification settings - Fork 295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
once() and on() async utilities for EventTarget #1038
Comments
This seems like a nice addition, although it might be worth bikeshedding on the name and API surface. E.g. I'd personally do something more like If I recall, previous discussions in this vein have raised the worry that any developer code using such helpers would get access to the As some background, we've historically been "saving" |
Yeah, it definitely limits some of the interaction with the
|
I dunno what the general guidance is, but should web APIs that return async iterators be using That would also cover all the behaviour of how things like the abort signal, queueing and such work, as these are all baked into the streams spec already. |
If API additions along these lines become a thing, I hope that API can be devised in a way that accounts for race/all patterns. I've used homegrown promise/async-iterable DOM event utils similar to what's described above for a while and the most interesting thing I've learned from it has been that "race patterns" are pretty common with DOM events: "any of these events once." Usually they're events dispatched by a single target, but even so, |
There's also https://github.com/tc39/proposal-emitter that has a bunch of ideas. I quite like the simplicity of this proposal though, makes it more tractable. I do think there are cases where you would want |
There are a few simple options, one would be just to accept a sync handler step i.e.: const event = await target.once("some-event", {
handleSync: (event) => {
if (someCondition) event.preventDefault();
},
}); Alternatively const event = await element.once("click");
// Still works
event.preventDefault(); However this does depend on the weird zalgo behaviour that events dispatched by the user agent have microtask checkpoints after firing event listeners but before finishing the event, this power can't be accessed by userland events in any way. And although this behaviour is fairly zalgo, some libraries such as idb do actually depend on that exact behaviour†, so it's not particularly uncharted terrority. † For that library specifically indexedDB fires events for transactions, once the event is over the transaction is no longer usable, however because it's a UA event one can schedule microtasks (e.g. via Another another model could be that const x = await element.once("click", async (event) => {
// All code prior to an await is run synchronously, so event.preventDefault is fine here
if (someCondition) event.preventDefault();
return event.x;
}); This is considerably less zalgo than the above, but does require a somewhat different structure for code. |
Requiring
Yes, we get those once in a while in Node.js also. The most common are await eventTarget.next("foo"); // Wait asynchronously for only 'foo', other events emitted synchronously while waiting may be be missed.
await eventTarget.nextRace(["foo", "bar"]); // Wait asynchronous for either 'foo' or 'race', other events emitted synchronously while waiting may be missed, including either 'foo' or 'bar' events. The possibility of missing events is definitely very possible with this API and needs to be one of the main considerations when adding it.
Hmm.. this is significantly less attractive as an option for me simply because it doesn't seem to be much better than just continuing to use Great discussion tho! |
+1 to async iterator over ReadableStream; ReadableStream is designed for I/O (see e.g. the FAQ, or the frequent analogy that arrays are specialized sync iterables for random access, whereas ReadableStreams are specialized async iterables for I/O). I thought further on the naming question and think that although it's slightly verbose, if the names were
I agree that it's not really desirable to combine these proposals with cancelability; for that we should continue to use I think race and all patterns already work pretty well with the base proposal? You end up with an extra event listener hanging around that is ultimately ignored; that isn't fully optimal, but in most cases it shouldn't matter: const e = await Promise.race([eventTarget.toPromise("foo"), eventTarget.toPromise("bar")]); |
That's true - probably doesn't matter for indexeddb success/error* for example - but it seems you can silently accrue a lot of extras when handling ui events for mouse/pointer/touch/drag behaviors * in our homegrown version I eventually bit the bullet and said that "error" and "messageerror" events unilaterally map to rejections in the async-iterators-of-events, not yielded results. may not be a reasonable angle for built in behavior but fwiw, i've had no reason to regret it so far - in some cases it's radically more intuitive |
I just want to clarify this. Are you saying that we should build the async iterator piece on |
I meant we should not; you can s/over/instead of. |
Ok, so I just want to summarize where we seem to be landing on this:
Using Does this seem correct? |
Seems correct, although we should realize this discussion has moved fast and hasn't had time for everyone to weigh in. Also
might deserve some more thought, per @bathos's comment. His footnote made me wonder about const messageEvent = await window.toPromise("message", { errorType: "messageerror" }); or something? ... also, writing that out,
|
The naming issue there is why I originally was thinking about static methods off |
Spitballing follows. Not sure if maybe some of these are already unavailable due to collision given how many EventTarget subclasses there are. Single
Iterator
None of the single-promise ones seem quite right: when doesn’t strongly imply “once,” eventually maybe sounds more like you’re asking the agent to schedule work, and advent, though nearly an exact match for the concept in other regards (even being from the same root word as event), too strongly implies the start of something (i.e., the very first event, not the next event). Still, maybe one of them will kick off better ideas. (for a second I thought “oh! ‘await’!” before realizing why that’s a terrible method name — though at least no one could knock |
Again, just bikeshedding here, but we could flip this around a bit and hang the new API off const event = await Event.from(target, 'foo'); |
Really? I can't find the rationale for this in this thread, is it to avoid And to participate in the bikeshedding, I like to call my event promisifier |
In general, I like this sort of idea. However, I'd caution against it because of the nuance involved with converting something that's entirely push-based (EventTarget) to something that is pull-push based (AsyncIterable). The latter is much more complicated and that complication/nuance is generally lost in async functions with for-await loops. We looked at making RxJS's observable implement Consider the complexity added even in this simple case: textInput.addEventListener('input', async (e) => {
// this is hit IMMEDIATELY every time the input is changed
const data = await getData(textInput.value);
render(data);
});
// vs
for await (const e of textInput.on('input')) {
// This is hit IMMEDIATELY the FIRST time the input is changed
// however, if the input is changed while `getData` is doing its thing,
// then it will not be hit again until `getData` finishes doing its thing.
// BUT, if the button is clicked two or three times while `getData` is doing
// its thing, THEN you have to wait for each of the previous events to be
// processed.
// ADDITIONALLY, the state of `textInput.value`, and the rest of the app,
// will have changed by the time this is hit in those cases, so you may
// end up sending something you're not expecting.
const data = await getData(textInput.value);
render(data);
} In short, while I think this improves the superficial ergonomics of A pushed based type, like an observable, in likely a better choice for this use-case IMO: #544 However, I do think that there's some merit to making other things, like |
@domenic: I honestly like the #544 was unfortunately proposed at a time when Edge and Chrome were two different codebases. Chrome was interested, and Google even had a contractor lined up to do the implementation, but we couldn't get "two implementers" interested, and now the director that had approved the contractor has moved on from Google. :) If only the Edge/Chromium thing had happened a year or two earlier. haha. |
That's an interesting example, I guess the counter-example is: for await (const e of textInput.on('input')) {
getData(textInput.value).then((data) => {
render(data);
});
} To prevent the blocking behavior you were mentioning there, but regardless the main crux here is that returning an async iterator may guide people to using structures like for await... of that isn't what they probably intend, and if they want to use an async iterator differently they'll need to code around that in ways that aren't incredibly common. Like say you wanted debouncing or to collect a series of touch events together, that's more intuitive with an Observable. So before we commit to an async iterator for
And if I've opened the door to the bikeshed, I've often used "when" for things Promise factories that are not Promises themselves, because it looks nice next to "then". (when() this event fires, then() take this action). |
Related: proposal to add Observables and make EventTarget observable. The |
|
What's the actual difference? The thing the OP proposes is
which sounds exactly like
|
A while back Node.js introduced the
once()
andon()
utility functions for Node.js'EventEmitter
object. They are essentially just promise-based alternatives to adding a callback listener for an event. They've proven to be so useful in practice that I'd like to propose adding similar utilities forEventTarget
Essentially,
and
The text was updated successfully, but these errors were encountered: