@valdres/browser-focus
v1.0.0-beta.9
Published
Reactive document focus state from the window focus/blur events
Readme
browser-focus
Whether this window has focus, as a read-only external atom. It follows the window's own focus / blur events, and samples the same state from document.hasFocus() and document.activeElement when nothing is listening.
Install
bun add @valdres/browser-focusLive example
▶ Live example: https://valdres.dev/react/plugins/browser-focus
Usage
Every read works through a store, with no adapter at all:
import { store } from "valdres"
import { focusAtom } from "@valdres/browser-focus"
const app = store()
app.get(focusAtom) // boolean — reads without subscribing
const stop = app.sub(focusAtom, () => {
console.log(app.get(focusAtom) ? "focused" : "blurred")
})
stop()import { useValue } from "valdres-react"
import { focusAtom } from "@valdres/browser-focus"
function FocusBadge() {
const focused = useValue(focusAtom)
return <span>{focused ? "Focused" : "Blurred"}</span>
}Adapter support in this beta
valdres-reactis the only adapter migrated to the v1 core. The Vue, Svelte, Solid and Angular adapters cannot read an external atom yet:valdres-vue'suseValueandvaldres-angular'sinjectValueare typed forAtom | Selectoronly,valdres-solid'screateValuestill expects the pre-v1 two-parameterState, andvaldres-svelteexportsfromState, not thewatchthese pages used to show. Until those adapters ship, read this package withstore.get/store.subas above.
Exports
| Export | Kind | Type |
| ----------- | ------------------------- | --------- |
| focusAtom | external atom (read-only) | boolean |
Ownership and meaning
Focus belongs to the browser, so focusAtom is an external atom: stores
cannot write it, and set, reset and update reject it at compile time and at
runtime.
Intended meaning
The value describes this window: whether it is the focused browsing
context, as its own focus and blur events announce. This is narrower than
document.hasFocus(), which also reports true while focus is inside a nested
frame, such as an embedded video player or a payment form. For focusAtom, focus
inside a nested frame means this window is not focused. This is the meaning
the package has always had. If the application should count focus inside a frame
as presence, it has to derive that itself, for example from its own frame
messaging.
What has been measured
Native behavior was checked in Chromium only (Chrome for Testing 151 on macOS), using real OS app switches, trusted mouse input into same- and cross-origin iframes, and tab switches:
- Leaving the browser fires
bluron the window, andhasFocus()is alreadyfalsewhen it does. Returning firesfocus. - Focus entering an iframe fires
bluron the parent window whilehasFocus()staystrueanddocument.activeElementis the<iframe>. Focus coming back to the parent firesfocus. - Moving between iframes, or leaving and returning to the browser while an iframe holds focus, fires no event on the parent window.
There were two runs. The OS app switches were exercised in the earlier run,
which used the sampler before it tolerated a missing document.activeElement. In
that run, the attached value, dormant samples, and a hub-less variant that
samples on every read agreed with the intended meaning at every step, including
six OS-driven blur/focus transitions. The final code was then run again, but
macOS declined the app switches, so only the iframe, tab and API steps were
repeated. Those agreed too. The only code difference between the runs is the
missing-activeElement tolerance, which a regular HTML document never reaches.
The runs show the meaning can be followed in Chromium. They do not show that the shared listener pair is necessary in other browsers.
One further Chromium observation came from the run where macOS declined app
activation. The window was never OS-active, hasFocus() stayed true, and a tab
switch fired visibilitychange but no blur, so focusAtom stayed true while
the tab was hidden. presenceSelector still read false, because the page was
hidden. This is one observed case, not a guarantee about how any browser pairs
these events.
Not verified: Safari/WebKit, Firefox/Gecko, minimized windows, frames inside
shadow roots the page cannot inspect (closed roots), <frame> and <object>
embeddings, and any other embedding case not listed above.
Sampling limits
When nothing is listening, a read samples the intended meaning: hasFocus(), and
the focused element is not a frame, following open shadow roots. The sampler
cannot see into a closed shadow root. If a frame inside one holds focus, the
sample reports true. That is a limit of what the sampler can observe, not a
measured browser behavior. On a host whose document has no activeElement, the
sample is hasFocus() alone.
Server rendering
focusAtom reports true when focus cannot be observed. That covers server
rendering and runtimes without a document and its window, such as a server or a
worker. The seed is fixed and deterministic. useValue renders it first and
switches to the live value after hydration, which is a normal two-pass render,
not a hydration mismatch.
Lifetime
The package attaches one focus and one blur listener per document. Every
store tree that retains focusAtom registers with that one pair, and the pair is
removed when the last such tree's last retaining subscriber leaves or its store
is disposed. Child scopes share their root's registration. Importing the package
attaches nothing.
- While any store tree retains the source, the source holds one value: the state the window's events last announced, seeded from a fresh sample when the pair attached. A store that retains nothing reads that same value instead of sampling.
- When nothing retains it, a read samples the window and attaches nothing. The next attachment starts again from a fresh sample.
Delivery across stores
Sharing one source value does not make reads atomic across independent stores. On each change, every store is invalidated and notified in turn, in the order the stores subscribed. While that is happening, a subscriber in the first store can read its own updated value and a not-yet-updated value from another store. Once delivery completes successfully, every store agrees.
If a subscriber causes another focus change while a change is being delivered, for example by moving focus, intermediate states can be coalesced. One store may observe both transitions, while another is notified only once or not at all because its value ended where it started. When the feedback converges, every store settles on the final state. Do not rely on one notification per native event.
Feedback that never converges is different. An example is a subscriber that
toggles focus on every notification. The core's synchronous work limit stops it
with an error such as ExternalSourceNonConvergenceError, and the store caught
in the loop can serve that error instead of a value until a later change
recovers it. Keep subscribers from re-triggering the change they react to.
When one store's subscriber throws, the others are still notified. The failure is
then rethrown from the native listener so the browser reports it, as an
AggregateError when several stores failed.
Compose with @valdres/browser-visibility for a "user is present" signal. See
@valdres/browser-presence.
Full documentation: https://valdres.dev/react/plugins/browser-focus
