whatwg / html

HTML Standard
https://html.spec.whatwg.org/multipage/
Other
8.06k stars 2.65k forks source link

Timing of `<dialog>` 'close' event, popover 'toggle' event, `<details>` 'toggle' event #9046

Open jakearchibald opened 1 year ago

jakearchibald commented 1 year ago

https://html.spec.whatwg.org/multipage/popover.html https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element https://html.spec.whatwg.org/multipage/interactive-elements.html#details-notification-task-steps

The show/hide steps for these happen on the same thread. However, a task is queued to fire the "close" and "toggle" events on dialog and popovers respectfully.

This creates a weird situation where the event could fire before or after the next render. Given that "I'm here now!" events are often used by developers as a time to enhance elements, this creates a race condition where the original state may be seen for a frame - a flash of unenhanced content. This would be really difficult to debug.

The solution in the <dialog>/<details> case would be to instead use a mutation observer for the open attribute, since that's guaranteed to happen before rendering. With popover, I guess you could use the "beforetoggle" event, but this event is also cancellable, so it isn't exactly "I'm here now!".

Option 1: Do nothing

Accept that it's a rotten bit of the platform, that will grow, since new features (like popover) will copy the behaviour in the name of consistency.

'select' events on various inputs also queue in this way.

Option 2: Dispatch the event synchronously

This is usually how events work when the action is performed on the main thread.

However, I think there's some reluctance to do this for <dialog> and <details>, since it's related to an attribute change, and for some reason we don't want to fire events in relation to DOM changes?

Also, changing from async to sync might be a compat risk for <dialog> and <details>.

Option 3: Dispatch the event in a microtask

This is how mutation observers work, which are related to DOM changes. It also allows for some debouncing, and it's still async.

Option 4: Dispatch the event in the render steps

This is how resize/scroll/pointer events work.

However, if the dialog is shown after this point in the render steps, you'd still get a flash of unenhanced content.


My preference is for option 2 or 3.

I can't decide if "input" events behave this way. The HTML spec suggests it might, but the UI events spec says it must be dispatched immediately.

jakearchibald commented 1 year ago

Open UI resolution: Fire "closed" and "toggle" events in a microtask rather than a task (option 3 above)

Minutes: https://www.w3.org/2023/03/23-openui-minutes.html#t02

annevk commented 1 year ago

I'd like @smaug---- to look at this as well and maybe @rniwa. I tend to agree that events for UI changes should happen before the next paint, but I'm not sure I fully understand the implications of using microtasks for a lot more things.

jakearchibald commented 1 year ago

@annevk I'd also like to hear more why a sync event is bad here. I agree it's a more compatible change for <dialog>, but what about popover and similar features going forward?

josepharhar commented 1 year ago

I'd like @smaug---- to look at this as well and maybe @rniwa. I tend to agree that events for UI changes should happen before the next paint, but I'm not sure I fully understand the implications of using microtasks for a lot more things.

Do yall have any thoughts? I can make a spec PR and implement this in chrome, but it would be great to have your support beforehand

smaug---- commented 1 year ago

microtasks were designed for this kind of case - MutationObserver callback should fire before the next rendering update. So, using microtasks also there sounds reasonable, and it is probably quite web compatible, since microtasks are kind of asynchronous from JS point of view (but synchronous from browser point of view).

josepharhar commented 1 year ago

Thanks! I'll go ahead with this change then

josepharhar commented 1 year ago

I created a PR to switch to microtasks: https://github.com/whatwg/html/pull/9775

josepharhar commented 1 year ago

Since this is going to be a breaking change, there is a chance that I have to roll it back once I ship it in chrome. Assuming that I can ship it without rolling it back, would WebKit also implement this too? I don't want to go through with this unless we will eventually have interop. @rniwa @nt1m

josepharhar commented 1 year ago

I filed standards positions:

annevk commented 12 months ago

When I look at "show popover" (and "hide popover") we already fire beforetoggle synchronously. Is there any reason to queue a (micro)task for the second event at that point? What does that enable us to do?

Edit: I guess the main thing is that if it happened multiple times you get some deduplication, but is that still true enough with microtasks? And meaningful enough given that beforetoggle exists now and doesn't have deduplication?

cc @domenic

josepharhar commented 12 months ago

Yeah I suppose there isn't as much value for popovers given beforetoggle. Details elements don't have beforetoggle though.

This creates a weird situation where the event could fire before or after the next render

This is one of the main value points as I see it - it would simply make the events more consistent, right? I'm not sure how much this applies to other events in the platform though.

annevk commented 12 months ago

To be clear, I think overall changing our approach here makes sense. I'm just discussing the details to make sure we don't want to change this again a couple years down the road.

Thoughts?

domenic commented 12 months ago

I'd like to keep popover, details, and dialog on the same timing if at all possible.

annevk commented 12 months ago

I think details could be synchronous too as it's just an attribute mutation. It obviates the need for the task tracker thing.

josepharhar commented 8 months ago

I agree with domenic that we should keep them all the same, and I don't think that making any of them synchronous instead of async or microtask would be great. The fact that the beforetoggle event for popovers is synchronous (which it has to be in order for it to be useful) has caused us to add a lot of extra logic to handle what happens when the state is changed in the event handler. Maybe the toggle and close events aren't subject to that, but I don't see the value in making them synchronous instead of microtask.

The cancel event task: seems preferable to fire synchronously. I don't think the difference with a microtask is observable due to the JS stack being empty, but it just seems cleaner. (I kinda doubt implementations have the double task setup this currently seems to require.)

The cancel event is already synchronous right...? We should keep it that way so that preventing the closing of dialogs still works, right?

keithamus commented 8 months ago

If I can attempt to move towards a consensus:

Does this sound suitable to everyone?

domenic commented 8 months ago

Does this sound suitable to everyone?

This works for me, but the compat impact means implementers need to be the ones making the call.

I will say that having dialog cancel and close have the same timing is nice. Their different timing (cancel sync, close async) caused some pain with the close watcher integration.

josepharhar commented 8 months ago

If yall really want to make dialog close synchronous, then I'll do it carefully with a flag and go back on it if it breaks websites. I feel more confident that that moving things from slow task queueing to microtasks won't have compat issues - although I'm still going to ship that behind a flag and undo it if it breaks websites as well.

chrishtr commented 7 months ago

There are use cases for an async close or toggle event for popover, <details> and <dialog>. One example is analytics code in response to accepting a cookie consent dialog which has no need to block rendering.

Further, since these events can't prevent the default action there is not a reason they have to be synchronous. And synchronous JavaScript callbacks cause the potential for blocking the user if the script takes a long time to run, so should be avoided when possible. (This unfortunately happens often in practice due to web developers not realizing the time spent in their script callbacks can be quite substantial.)

Therefore, I think we should leave them async but consider extensions to addEventListener to allow the callback to have user-blocking priority.

jakearchibald commented 7 months ago

Since all of the options are (by design) before the next paint, they're all equally blocking in terms of UX. 500ms of JS execution will feel the same whether it's immediate, microtask, or render steps.

chrishtr commented 7 months ago

Since all of the options are (by design) before the next paint, they're all equally blocking in terms of UX. 500ms of JS execution will feel the same whether it's immediate, microtask, or render steps.

I agree that if the developer needs it to block paint, a delay is inevitable if script runs long. My main point is that the default being async is a good thing, for those developers who don't need it to block paint.

smaug---- commented 6 months ago

Therefore, I think we should leave them async but consider extensions to addEventListener to allow the callback to have user-blocking priority.

This doesn't really make sense. Event listeners are called synchronously when the event is dispatched. There is not concept of asynchronousness there, nor concept of priority.

chrishtr commented 6 months ago

This doesn't really make sense. Event listeners are called synchronously when the event is dispatched. There is not concept of asynchronousness there, nor concept of priority.

I'm suggesting we add the ability for them to be async and have a priority. I agree that these would be new.