Keeping Focus Stable During Island Activation
An island that replaces its own markup during mount takes the visitorβs focus with it. On a fast connection nobody notices because activation completes before anyone has tabbed anywhere; on a slow one it lands mid-interaction. This walkthrough implements the record-and-restore pattern named in accessibility of deferred hydration, and shows how to reproduce the loss deliberately.
Prerequisites
Implementation Steps
Step 1 β Prefer the mount that needs no restoration
The best fix is structural: write mounts that enhance rather than replace. Everything below is for the cases where a framework mount genuinely re-creates nodes.
Step 2 β Record before, restore after
// Focus is identified by a stable attribute, never by a node reference:
// the node may not exist after the mount.
function captureFocus(container) {
const el = document.activeElement;
if (!el || !container.contains(el)) return null;
return {
key: el.dataset.focusKey || el.id || null,
start: 'selectionStart' in el ? el.selectionStart : null,
end: 'selectionEnd' in el ? el.selectionEnd : null,
};
}
function restoreFocus(container, snapshot) {
if (!snapshot?.key) return;
const el = container.querySelector(`[data-focus-key="${snapshot.key}"], #${CSS.escape(snapshot.key)}`);
if (!el) return; // element genuinely gone: do nothing
el.focus({ preventScroll: true }); // never yank the viewport
if (snapshot.start != null && 'setSelectionRange' in el) {
el.setSelectionRange(snapshot.start, snapshot.end);
}
}
export async function activate(container, mod, props) {
const snapshot = captureFocus(container);
mod.mount(container, props);
restoreFocus(container, snapshot);
}
Step 3 β Preserve what the visitor typed, not only where they were
If the mount re-creates an input, its value resets to whatever the props say. Capture the current value alongside the focus snapshot and write it back before restoring the caret, or the visitor gets their cursor returned to an empty field β arguably worse than losing focus outright, because it looks deliberate.
Step 4 β Never move focus for any other reason
Activation should be invisible to the focus model. A component that calls focus() on mount to be helpful becomes actively hostile when six islands activate at different moments during a slow load.
Verification
Automate the reproduction once it is understood. A headless script can set the throttle, focus an element by selector, type, wait for the islandβs activation mark, and assert the element still has focus and the expected value. That test belongs in the same suite as the performance budget, because both regress for the same reason: someone changed how a mount works.
Two supporting checks are worth adding. Assert that document.activeElement never becomes document.body between the first keypress and the activation mark β a transition to body is the fingerprint of a replacing mount even when the restoration hides it. And assert window.scrollY is unchanged across activation, which catches a restoration that forgot preventScroll.
Why This Is Really a Resilience Fix
Focus preservation reads as an accessibility concern and is usually funded as one, but the underlying property is broader: it means activation is not observable to the visitor except as the feature becoming available.
That property is what makes deferred activation safe to use aggressively. If mounting an island can disturb typing, scroll position or selection, every deferral is a small risk and teams respond by making things eager β which costs exactly the performance the deferral was meant to buy. If mounting is invisible, deferral is free, and you can push activation later with confidence.
It also generalises past focus. The same discipline β enhance rather than replace, never move the viewport, never reset a value the visitor supplied β is what makes a page tolerate late-arriving fragments, background content refreshes, and the mid-session updates described in revalidating cached pages without rehydrating islands. All three are the same requirement seen from different angles: the visitorβs state belongs to the visitor, and no background process may take it.
One organisational note, because this is a class of bug that returns whenever a new component is written rather than staying fixed. Make the capture-and-restore wrapper the only sanctioned way to call a mount function. If activation goes through one helper in the loader, every island gets the behaviour by construction and no reviewer has to remember to ask about it. Islands that opt out β because their mount genuinely cannot disturb focus β should say so in a comment, which turns an invisible assumption into a reviewable statement.
The same applies to the rule about not moving focus on mount. It is easy to state and easy to violate accidentally, particularly when a component was originally written for a client-rendered page where an autofocus on mount was reasonable. Porting such a component into an island changes when that autofocus fires: no longer immediately after a deliberate navigation, but at an arbitrary moment while the visitor is reading something else.
Troubleshooting
Why does holding a reference to the focused element not work?
Because a mount that replaces markup produces new nodes, and the reference you held points at nodes that are no longer in the document. Calling focus on a detached element does nothing and reports no error, which is why this bug survives code review β the code looks correct and simply has no effect. Re-querying by a stable attribute after the mount finds whichever element now occupies that role.
Should focus ever move on activation?
Only when the visitor caused the activation and would expect it β clicking a control that opens a panel, for instance. Focus moving because a background island finished loading is disorienting for everyone and actively harmful for a screen reader user, who is dropped into an unrelated part of the page mid-sentence. The default should be that activation is invisible to the focus model.
What about text selected inside an island?
Selection is lost in the same way as focus and is restored the same way: record the start and end offsets before the mount, re-resolve the element afterwards, and reapply. It matters less often than focus because visitors rarely have a selection open while a page is still activating, but it costs three extra lines in code you are already writing.
Related
- Announcing Streamed Content with Live Regions β the other half of making late arrival perceivable and non-disruptive.
- Accessibility of Deferred Hydration β the full checklist this belongs to.
- Preventing Layout Shift with Skeleton Placeholders β keeping the page still while content arrives.
β Back to Accessibility of Deferred Hydration