Skip to content

Overhaul exit Picture-in-Picture algorithm#260

Open
theIDinside wants to merge 10 commits into
w3c:mainfrom
theIDinside:exit-picture-in-picture-overhaul
Open

Overhaul exit Picture-in-Picture algorithm#260
theIDinside wants to merge 10 commits into
w3c:mainfrom
theIDinside:exit-picture-in-picture-overhaul

Conversation

@theIDinside

@theIDinside theIDinside commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Rework the exit Picture-in-Picture algorithm to mirror the requestPictureInPicture() overhaul (#245):

  • run the work on the document's picture-in-picture parallel queue
  • perform DOM mutation, promise resolution inside a queued global task
  • keep the window close in parallel since closing may be asynchronous/disjoint across implementations.

The algorithm now takes |doc| and a promise-or-null |p| as input arguments.

First commit does not deal with:

  • Fixing "unloading document steps". Unfortunately it can not re-use the algorithm, because it runs on the event loop, it needs to mutate DOM stuff and then enqueue work on the parallel queue to close the window.
  • disablePictureInPicture must call the new algorithm by enqueing steps, providing input args
  • when fullscreen flag is set, it must call the new algorithm by enqueing steps, providing input args

Addresses #252


Preview | Diff

Rework the exit Picture-in-Picture algorithm to mirror the
requestPictureInPicture() overhaul (w3c#245):
- run the work on the document's picture-in-picture parallel queue
- perform DOM mutation, promise resolution inside a queued global task
- keep the window close in parallel since closing may be asynchronous/disjoint across implementations.

The algorithm now takes |doc| and a promise-or-null |p| as input arguments.

This commit does not deal with:
- Fixing "unloading document steps". Unfortunately it can not re-use the algorithm, because it runs on the event loop, it needs to mutate DOM stuff and then enqueue work on the parallel queue to close the window.
- disablePictureInPicture must call the new algorithm by enqueing steps, providing input args
- when fullscreen flag is set, it must call the new algorithm by enqueing steps, providing input args
@theIDinside

Copy link
Copy Markdown
Contributor Author

Hmm can the spec not be formatted using specfmt?

@theIDinside

Copy link
Copy Markdown
Contributor Author

@chrisn mentioned in #252 that we should update the "unloading steps" to call the exit algorithm, but unfortunately this algorithm turned out to be trickier to actually formulate than I had anticipated (though I think it's in a good/better state now).

I don't know if it's possible to actually call it from the unloading steps, because we also need to run the "window closing" steps, which just like for requestPictureInPicture is one of those thing that have to run on the queue and take forever, so I was thinking for the unloading steps, we would have to just change the "normal" order (the order when user says doc.exitPictureInPicture() or when being called from with el2.requestPictureInPicture()), from

  1. "close window"
  2. "Queue task to mutate DOM stuff"
    To the reverse,
  3. "mutate DOM stuff" (we are on the event loop so probably no task needed)
  4. Enqueue steps to run on the parallel queue to close the window. (this can take forever. Needs to run on the queue)

I can add this change in a follow-up commit in this PR if that's preferable from a reviewer perspective

cc: @chrisn || @marcoscaceres

Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs
1. If |doc|'s [=Picture-in-Picture element=] is `null`:
1. [=/Resolve=] |p| if |p| is not `null`.
2. Return.
2. Let |element| be |doc|'s [=Picture-in-Picture element=].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can element be null?

Comment thread index.bs Outdated
Comment thread index.bs Outdated
Comment thread index.bs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Reworks the spec’s “exit Picture-in-Picture algorithm” to follow the same concurrency model as the recent requestPictureInPicture() overhaul, by moving work onto the document’s picture-in-picture parallel queue and deferring DOM mutation/promise resolution to a queued global task.

Changes:

  • Updates the exit algorithm to take |doc| and promise-or-null |p|, assert parallel-queue execution, and perform DOM mutation/promise resolution in a queued global task.
  • Adjusts requestPictureInPicture() to invoke the new exit algorithm (with null promise) from the parallel-queue steps before enqueueing its own global task.
  • Refactors Document.exitPictureInPicture() to enqueue work onto the picture-in-picture parallel queue and resolve via a global task.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread index.bs Outdated
Comment on lines +228 to +232
2. Assert that these steps are running on the [=picture-in-picture parallel queue=].

Note: This algorithm is called from {{exitPictureInPicture}} and {{requestPictureInPicture}}.
The document unloading steps have to be structured differently due to running on the event loop
and can therefore not call this algorithm.
Comment thread index.bs Outdated
marcoscaceres added a commit to marcoscaceres/playground that referenced this pull request Jun 18, 2026
Demo page for testing exit PiP behavior across various destruction
scenarios (window close, iframe removal, document navigation) relevant
to w3c/picture-in-picture#260.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@marcoscaceres

Copy link
Copy Markdown
Member

The question of unloading might be one better handled through the document becoming non-fully active... if the document becomes not fully active, exit full screen is the observable behavior on Chrome and WebKit (PiP closes).

Could we use that that instead of the unloading steps? @chrisn and I tested this using a little demo page. https://marcoscaceres.github.io/playground/pip-test/

@chrisn

chrisn commented Jun 18, 2026

Copy link
Copy Markdown
Member

Web Platform Design Principles has some detail on non-fully active: https://www.w3.org/TR/design-principles/#handle-non-fully-active-documents. But I'm not sure where we can hook into on the transition from fully active to non-fully active.

@chrisn

chrisn commented Jun 18, 2026

Copy link
Copy Markdown
Member

I don't know if it's possible to actually call it from the unloading steps, because we also need to run the "window closing" steps, which just like for requestPictureInPicture is one of those thing that have to run on the queue and take forever, so I was thinking for the unloading steps, we would have to just change the "normal" order (the order when user says doc.exitPictureInPicture() or when being called from with el2.requestPictureInPicture()), from

  1. "close window"
  2. "Queue task to mutate DOM stuff"
    To the reverse,
  3. "mutate DOM stuff" (we are on the event loop so probably no task needed)
  4. Enqueue steps to run on the parallel queue to close the window. (this can take forever. Needs to run on the queue)

I can add this change in a follow-up commit in this PR if that's preferable from a reviewer perspective

Makes sense to me, and adding to this PR sounds fine.

@marcoscaceres

Copy link
Copy Markdown
Member

Following up on @chrisn's point about handling this through the document becoming non-fully active, and the TAG Design Principles §2.13 he linked.

I think this PR (and the spec as it stands) has a gap worth closing as part of the exit overhaul: nothing defines what happens to an active Picture-in-Picture session when its document stops being fully active, that is, when the containing <iframe> is removed or navigated, on top-level navigation, or when the document enters bfcache. The reworked algorithm only guards on pictureInPictureElement being null; it never references "fully active". Deferring the unloading-steps integration from #252 is fine, but I'd like us to agree on the model now so the deferred work has a target.

On @chrisn's open question (where can we hook into the transition from fully active to non-fully active?): there isn't a single event for it, but Fullscreen already solves the same problem and is a good precedent. It hooks the two concrete algorithms that cause non-fully-active, namely the DOM removing steps (element or subtree removed) and the HTML unloading document cleanup steps (document unloaded or navigated), and it also fails fast at call time if the document isn't fully active.

Mapping §2.13 onto exit PiP, I'd suggest we define three things:

  1. At call time: if the relevant document is not fully active, exitPictureInPicture() (and the exit-on-replace path inside requestPictureInPicture()) should fail immediately. §2.13 suggests InvalidStateError. Today we only check pictureInPictureElement.
  2. When the queued global task runs: an explicit "if the document is not fully active, stop with no observable effects" check, rather than leaning on the pictureInPictureElement is null proxy (§2.13 also says not to rely on garbage collection for this).
  3. On the transition itself: define the teardown by hooking the removing steps and the unloading document cleanup steps (the Fullscreen pattern), and spell out what happens to pictureInPictureElement, whether leavepictureinpicture fires, and how any in-flight promise settles. That is all currently left to the implementation.

One thing for the group on (3): when a document becomes non-fully active mid-exit, should the returned promise reject with AbortError (§2.13's general rule for aborted async operations), or resolve, since the requested end state ("no longer in PiP") is arguably already satisfied by the teardown? I lean toward making it definite and observable either way; I don't think the current text forces a single outcome.

I put together a small test bench for these transitions (remove iframe, navigate iframe, top-level navigation, bfcache restore, and a parent holding a PictureInPictureWindow across destruction): https://marcoscaceres.github.io/playground/pip-test/. In my testing the behaviour across these paths isn't consistent today, which is the main reason I'd rather we define the model than leave it implementation-defined. It's set up so you can check your own engine.

marcoscaceres added a commit to marcoscaceres/playground that referenced this pull request Jun 18, 2026
Minimal standalone repro for exit-PiP behaviour when a document becomes non-fully active via bfcache. Linked from w3c/picture-in-picture#260 and an upcoming WebKit bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcoscaceres

Copy link
Copy Markdown
Member

WebKit tracking bug https://bugs.webkit.org/show_bug.cgi?id=317409

theIDinside and others added 6 commits June 25, 2026 13:13
Co-authored-by: Marcos Cáceres <marcos@marcosc.com>
Co-authored-by: Marcos Cáceres <marcos@marcosc.com>
Co-authored-by: Marcos Cáceres <marcos@marcosc.com>
Co-authored-by: Marcos Cáceres <marcos@marcosc.com>
Co-authored-by: Marcos Cáceres <marcos@marcosc.com>
@theIDinside

Copy link
Copy Markdown
Contributor Author

The question of unloading might be one better handled through the document becoming non-fully active... if the document becomes not fully active, exit full screen is the observable behavior on Chrome and WebKit (PiP closes).

Could we use that that instead of the unloading steps? @chrisn and I tested this using a little demo page. https://marcoscaceres.github.io/playground/pip-test/

This is fantastic. Could you maybe add an example from #261 - basically a button that both does getDisplayMedia + requestPip. Effectively, it may end up being the case that, that should fail (though it doesn't on safari and chrome), given that these two requests are spec'ed on consuming and being gated by user activation.

@theIDinside

Copy link
Copy Markdown
Contributor Author

Following up on @chrisn's point about handling this through the document becoming non-fully active, and the TAG Design Principles §2.13 he linked.

I think this PR (and the spec as it stands) has a gap worth closing as part of the exit overhaul: nothing defines what happens to an active Picture-in-Picture session when its document stops being fully active, that is, when the containing <iframe> is removed or navigated, on top-level navigation, or when the document enters bfcache.
...
On @chrisn's open question (where can we hook into the transition from fully active to non-fully active?): there isn't a single event for it, but Fullscreen already solves the same problem and is a good precedent. It hooks the two concrete algorithms that cause non-fully-active, namely the DOM removing steps (element or subtree removed) and the HTML unloading document cleanup steps (document unloaded or navigated), and it also fails fast at call time if the document isn't fully active.

So can we really hook into DOM removing steps (element or subtree removed)? We have tests historically that rely on unbound elements (and also being able to remove the actual element being PIP'ed). And in the case of an iframe; would the removal of the iframe eventually also mean that the HTML unloading document cleanup steps run? (thus having our "not fully active"-case be caught fully by the unloading steps?)

Mapping §2.13 onto exit PiP, I'd suggest we define three things:

1. At call time: if the relevant document is not fully active, `exitPictureInPicture()` (and the exit-on-replace path inside `requestPictureInPicture()`) should fail immediately. §2.13 suggests `InvalidStateError`. Today we only check `pictureInPictureElement`.

2. When the queued global task runs: an explicit "if the document is not fully active, stop with no observable effects" check, rather than leaning on the `pictureInPictureElement is null` proxy (§2.13 also says not to rely on garbage collection for this).

Adding these checks to exit and has filed an issue for the requestPIP part. And let's follow the recommendations; InvalidStateError for sync/pre-checks. Currently I've made the async-part resolve with undefined as per the tear down of state being satisfied, even if being different from recommendations I think we can argue that striving for simplicity also is a goal. But no hard requirement on my part.

@theIDinside

Copy link
Copy Markdown
Contributor Author

Added the unloading steps in the last commit. I think that at least partially addresses most of your concerns @marcoscaceres ?

Comment thread index.bs
Comment on lines +396 to +397
1. If [=this=]'s [=Picture-in-Picture element=] is `null` or [=this=] is not [=fully active=],
return [=a promise rejected with=] {{InvalidStateError}} {{DOMException}}.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
1. If [=this=]'s [=Picture-in-Picture element=] is `null` or [=this=] is not [=fully active=],
return [=a promise rejected with=] {{InvalidStateError}} {{DOMException}}.
1. If [=this=] is not [=fully active=],
return [=a promise rejected with=] {{InvalidStateError}} {{DOMException}}.
2. If [=this=]'s [=Picture-in-Picture element=] is `null`,
return [=a promise rejected with=] {{InvalidStateError}} {{DOMException}}.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also have web-platform tests along this PR to help figuring out what needs to be fixed in different implementations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually wrote a postMessage web platform tests specifically for this "not fully active" scenario, and it involves actually testing that something "did not happen" - and we wouldn't be able to test this here, I think. This promise will never get resolve or rejected, I believe. It should just be doing the null check.

Comment thread index.bs
4. [=Queue a global task=] on the [=media element event task source=] given |global|, to perform the
following steps:
1. If |doc| is not [=fully active=] or |doc|'s [=Picture-in-Picture element=] is `null`:
1. If |p| is not `null`, [=/resolve=] |p| with undefined.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I naively thought we should reject instead of resolve promise. Why not in this case?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree... this should reject with an InvalidStateError.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, these should happen both before [=Queue a global task=] and recheck after [=Queue a global task=].

See https://www.w3.org/TR/design-principles/#support-non-fully-active

@theIDinside theIDinside Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment in the main thread, but I think there's an argument for that we should not be doing this check at all.

Comment thread index.bs
4. Unset {{pictureInPictureElement}}.
5. Remove one <a>item</a> matching <a>relevant settings object</a>'s <a>origin</a> from
<a>initiators of active Picture-in-Picture sessions</a>.
When the <dfn>exit Picture-in-Picture algorithm</dfn> is invoked given |doc|, and nullable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
When the <dfn>exit Picture-in-Picture algorithm</dfn> is invoked given |doc|, and nullable
When the <dfn>exit Picture-in-Picture algorithm</dfn> is invoked given a {{Document}} |doc|, and nullable

Comment thread index.bs
4. [=Queue a global task=] on the [=media element event task source=] given |global|, to perform the
following steps:
1. If |doc| is not [=fully active=] or |doc|'s [=Picture-in-Picture element=] is `null`:
1. If |p| is not `null`, [=/resolve=] |p| with undefined.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree... this should reject with an InvalidStateError.

Comment thread index.bs
4. [=Queue a global task=] on the [=media element event task source=] given |global|, to perform the
following steps:
1. If |doc| is not [=fully active=] or |doc|'s [=Picture-in-Picture element=] is `null`:
1. If |p| is not `null`, [=/resolve=] |p| with undefined.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, these should happen both before [=Queue a global task=] and recheck after [=Queue a global task=].

See https://www.w3.org/TR/design-principles/#support-non-fully-active

Comment thread index.bs
2. Return.
2. Let |element| be |doc|'s [=Picture-in-Picture element=].
3. Set |doc|'s [=Picture-in-Picture element=] to `null`.
4. <a>Fire an event</a> named {{leavepictureinpicture}} using {{PictureInPictureEvent}} at the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[=Queue a task=] to perform the follow steps?

Comment thread index.bs
As one of the <a>unloading document cleanup steps</a>, run the <a>exit
Picture-in-Picture algorithm</a>.
### Unloading steps ### {#unloading-steps}
As one of the <a>unloading document cleanup steps</a> given |doc| run the following steps:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
As one of the <a>unloading document cleanup steps</a> given |doc| run the following steps:
As one of the <a>unloading document cleanup steps</a> given {{Document}} |doc| run the following steps:

Comment thread index.bs

It is RECOMMENDED to run the <a>exit Picture-in-Picture algorithm</a> when the
{{pictureInPictureElement}} <a>fullscreen flag</a> is set.
It is RECOMMENDED that when a [=Picture-in-Picture element=]'s <a>fullscreen flag</a> is set, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we talked about this, so apologies... why is the RECOMMENDED instead of MUST?

Can we add a note here explaining why it's not a MUST requirement?

@theIDinside

theIDinside commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

So I just realized something @marcoscaceres | @chrisn | @beaufortfrancois while working on other stuff in gecko, I was reminded of the idea of runnable task. We (Firefox) had a bug in postMessage, which posted a message from inside an unload handler, which would be seen by a "new" document upon reload. We don't have the concept of queuing a task like blink or webkit does as you may know, but effectively doing such a check was the fix (if document is not fully active, don't run the work posted to our internal implementation of dispatched work)

We queue tasks to run from the PIP parallel queue. And per spec these should not run if the document is not fully active which means that our "is fully active" is entirely superfluous. Because the task should not be started if the document itself is no longer active.

Comment thread index.bs

It is RECOMMENDED to run the <a>exit Picture-in-Picture algorithm</a> when the
{{pictureInPictureElement}} <a>fullscreen flag</a> is set.
It is RECOMMENDED that when a [=Picture-in-Picture element=]'s <a>fullscreen flag</a> is set, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrisn chrisn Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also #191. But let's work on that in a follow up PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, yeah... it's basically transitioning in/out of fullscreen otherwise PiP,

Comment thread index.bs

As one of the <a>unloading document cleanup steps</a>, run the <a>exit
Picture-in-Picture algorithm</a>.
### Unloading steps ### {#unloading-steps}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talking to @chrisn, would be great to just move these over to HTML.

Comment thread index.bs
As one of the <a>unloading document cleanup steps</a>, run the <a>exit
Picture-in-Picture algorithm</a>.
### Unloading steps ### {#unloading-steps}
As one of the <a>unloading document cleanup steps</a> given |doc| run the following steps:

@chrisn chrisn Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
As one of the <a>unloading document cleanup steps</a> given |doc| run the following steps:
The <dfn export>picture in picture unloading steps</dfn>, given {{Document}} |doc| are:

Exported so we can call this from HTML

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See whatwg/html#12689 for the corresponding HTML change.

@marcoscaceres

Copy link
Copy Markdown
Member

@theIDinside added what you requested above to https://marcoscaceres.github.io/playground/pip-test/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants