Skip to content

Allow focus moves within a document's own subtree with target - #3

Draft
ffiori wants to merge 6 commits into
mainfrom
user/ffiori/focus-threaded-source-with-target
Draft

ffiori wants to merge 6 commits into
mainfrom
user/ffiori/focus-threaded-source-with-target

Conversation

@ffiori

@ffiori ffiori commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

This extends allow focus steps with an explicit initiating document, allowing parent-to-child focus delegation and continued focus management within an already-focused embedded application.

The motivation is to preserve ordinary focus behavior in nested applications, including Teams experiences, without letting an unrelated, unactivated frame use the target's activation or focus state to authorize a move.

This follows up on whatwg#11519 and the discussions in whatwg#11839, whatwg#12032, and whatwg#12470. Parent-to-child delegation was part of the TPAC 2024 resolution; continued internal focus management and parent recapture are the additional behavior proposed here.

Authorization model

The updated algorithm is:

allow focus steps(target, focusSetterDocument):
  1. If target is allowed to use the feature, return true.
  2. If target is not fully active, return false.
  3. If focusSetterDocument is not fully active, return false.
  4. If focusSetterDocument's relevant global has transient activation,
     return true.
  5. If focusSetterDocument's inclusive descendant navigables does not
     contain target's node navigable, return false.
  6. If focusSetterDocument is allowed to use the feature, return true.
  7. If has focus steps(focusSetterDocument) returns true, return true.
  8. Return false.

There are three distinct authorization paths:

  • Target policy: the existing allowance for a policy-enabled target is retained.
  • Setter activation: an activated setter may request focus outside its subtree, including handing focus back to a restricted ancestor.
  • Contained delegation without activation: otherwise, a restricted target must be within the setter's subtree, and the setter must be policy-enabled or already on the focus chain.

has focus steps includes descendant focus: it returns true for an ancestor of the focused document when the top-level traversable has system focus. This lets an embedded application delegate focus to a child and later recapture it.

Changes since the previous revision

The target's transient-activation shortcut has been removed. Activation belonging to C no longer independently authorizes unrelated setter B to focus C, addressing whatwg#12470.

The target's has focus steps shortcut has also been removed. Previously, an unrelated setter could focus a restricted document whose descendant held focus, bypassing containment. Once containment holds, checking the setter's focus is sufficient: focus inside the target also places the setter on the focus chain.

Setter activation is checked before containment. Otherwise, a genuinely activated child would be prevented from returning focus to a restricted parent. Only the setter-policy and existing-focus fallback paths are subtree-limited.

The separate fully-active guard in the dialog focusing steps is now redundant and has been removed: allow focus steps rejects an inactive target itself.

Threading the setter

The initiating document is captured from the incumbent global object at six script entry points: Window.focus(), HTMLOrSVGOrMathMLElement.focus(), dialog.show(), dialog.showModal(), showPopover(), and togglePopover().

It is then passed explicitly through the dialog and popover algorithms. The incumbent identifies the calling context rather than relying on the current realm of a method obtained from another document. Explicit threading keeps that context available throughout nested algorithms.

Declarative button-command, popover-target, and select-picker paths pass the invoking element's node document. Autofocus insertion passes its element's document as both target and setter, without consulting the script stack. It therefore also receives the new existing-focus allowance.

Behavior

Unless noted, targets are policy-denied and the setter has no transient activation. “Allow” means that this policy check succeeds; other focusing requirements still apply.

Scenario Result
Focused B focuses another element in B Allow
Focused B delegates focus into child C Allow
C is focused; parent B focuses an element in B Allow
Policy-enabled parent A focuses an element in restricted B Allow
A is focused; restricted B attempts to focus itself Deny
Unrelated B targets C, and only C has transient activation Deny
Unrelated B targets C while C or its descendant has focus Deny
Focused but unactivated B targets a restricted cousin Deny
Activated B targets a restricted ancestor or sibling Allow
Target is policy-enabled Allow through the retained target-policy rule

Compatibility and scope

This is intentionally not behavior-preserving in every case. Requests previously authorized solely by a restricted target's activation can now be rejected. Conversely, setter activation can authorize cross-document requests that the old target-activation check rejected.

The target-policy allowance remains unchanged. This is therefore not a universal requirement that every focus request be authorized by the setter. The existing distinction between an iframe element's node document and its content document is also retained; this PR does not claim that the element and Window focus APIs are equivalent for every caller.

Popups are separate top-level traversables, not descendants of their openers. The contained-delegation path does not cover opener/popup relationships; target policy or setter activation may still authorize those requests.

No fullscreen-specific restriction is added. Fullscreen concerns remain deferred to a possible follow-up, rather than being treated as resolved by this change.

Policy points for review


(See WHATWG Working Mode: Changes for more details.)

ffiori and others added 6 commits September 3, 2026 14:49
Carry the initiating document through direct focus, dialog, and popover algorithms. Use explicit invoking documents for user-agent paths and block the focused-subtree exception while a focused descendant is fullscreen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Prevent caller-based authorization from reaching dialog focusing steps that dereference a null node navigable for detached or synthetic documents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Keep the focus-without-user-activation change scoped to caller attribution and focused descendants. Fullscreen interaction can be specified separately once its exact boundary is agreed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Builds on the threading in this branch. `allow focus steps` took only
`focusSetterDocument`, so the document the focus would move *into* was never
consulted. Two consequences.

A document that holds the focus could move it anywhere. A (top, allowed) embeds
cross-origin B and V, both denied. The user clicked into B once and the
activation has expired, so the focus is in B. B runs
`top.frames['v'].focus()`: B is not allowed and has no activation, but B holds
the focus, so the steps returned true and the focus landed in V -- which was
denied the feature precisely to stop that. V is not related to B at all.

And three things that work on main stopped working, because element.focus()
consulted the setter instead of the element's own document:

  * a denied frame calling `aWin.focus()` on its allowed parent;
  * a denied frame focusing an element of an allowed same-origin ancestor;
  * a same-origin helper frame driving focus through a shared ancestor's DOM,
    e.g. `parent.document.getElementById('fB').focus()`.

Both are fixed by consulting the target first and scoping the setter's authority
to its own subtree:

  1. target is allowed to use the feature                 -> true
  2. target's relevant global has transient activation    -> true
  3. target is not fully active                           -> false
  4. has focus steps(target)                              -> true
  5. focusSetterDocument is not fully active              -> false
  6. focusSetterDocument does not contain target          -> false
  7. focusSetterDocument is allowed to use the feature    -> true
  8. focusSetterDocument has transient activation         -> true
  9. has focus steps(focusSetterDocument)                 -> true
 10.                                                         false

Steps 1 and 2 are main's, unchanged and still first, so nothing that focuses
today stops focusing. Steps 5-9 say: the setter could have moved the focus to
itself, and the target is inside the setter's subtree. Scoping by containment is
what stops the cousin case, and it is safe within the subtree because an
embedder determines its descendants' permissions policy.

The two focus APIs still agree. `iframeB.focus()` is checked against A with A as
the setter and passes at step 1; `bWin.focus()` is checked against B with A as
the setter and passes at step 7. A child calling `window.focus()` on itself is
its own setter and passes none of 7-9.

Also collapses the descendant loop. `has focus steps` walks down the focus chain
from the top-level traversable and returns true as soon as it reaches its
argument, so it is already true when the focus is in that document or in any of
its descendant navigables. The loop over inclusive descendant navigables was
exactly one `has focus steps` call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove target activation and target-focus shortcuts that could authorize an unrelated focus initiator before containment was checked. For a policy-denied target, require the target to be in the setter's subtree and use only the setter's policy, activation, or existing focus.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Allow a genuinely activated setter to move focus outside its subtree while keeping policy- and focus-based fallback authorization contained. Remove the dialog fully-active guard made redundant by the target check in allow focus steps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
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.

1 participant