[docs] ADR: Selenium waits for interaction readiness before it interacts - #17886
[docs] ADR: Selenium waits for interaction readiness before it interacts#17886AutomatedTester wants to merge 4 commits into
Conversation
181658f to
d03f066
Compare
Proposes that Selenium wait for interaction readiness before it acts: click, send_keys, clear and submit wait for the element to be actionable by default on a BiDi-enabled session, bounded by a new readiness timeout and raising today's interaction errors with the diagnosis appended. Classic sessions are unchanged, and a session toggle disables it. Readiness is modelled in three composable layers - a pending-work ledger, DOM settledness, and element actionability - also exposed as explicit protocol-neutral waits, with the semantics defined once in a shared JavaScript atom injected as a BiDi preload script, and framed as a prototype of a proposed BiDi quiescence module. Status: Proposed.
d03f066 to
e71ef1c
Compare
titusfortner
left a comment
There was a problem hiding this comment.
Having a better way to wait before acting will be a huge improvement, but I'd like more flexibility in how this functionality is provided to users. Some of my thoughts suggestions on how this would work below. These all assume that users enable bidi, even for classic methods that have not yet been re-implemented with BiDi.
- Actionable should be a replacement for "implicit wait" when bidi is enabled, rather than its own thing.
- Actionable still needs to default to 0 to maintain expectations from existing code. Honestly, I don't see how we force this through without breaking users existing code. Maybe a major release?
- Provide snapshot access to actionable so users can integrate into their existing explicit wait code without needing to rewrite.
- Provide events for settledness that users can subscribe to with a add_dom_settled_handler.
- Provide snapshots for settledness so users can wrap with whatever waiting strategy they want.
- Have settledness be a 4th supported PageLoadStrategy that we manage locally for navigations and clicks.
- Allow navigation and click methods to override page load strategy per action.
Have different atoms for each action (that take only the arguments applicable to the feature without requiring additional client code).
| default behavior of the ordinary interaction commands; the semantics are defined once, implemented | ||
| once, and also exposed as explicit waits. | ||
|
|
||
| **1. Readiness is modelled in three composable layers.** |
There was a problem hiding this comment.
These aren't three composable layers: settled builds on pending work, but actionable is completely independent (which is what decision 2 depends on). Conflating these makes some of the other decisions less clear.
There was a problem hiding this comment.
Conceded — and you were right that it was leaking into the other decisions. Fixed in a9f895c.
Decision 1 is now two independent signals, with the pending-work ledger demoted to an input of settledness rather than a peer:
- Page settledness — no meaningful DOM mutation for a settle window, with the ledger (timers, intervals, animation frames,
fetch,XHR, WebSocket) as its input. - Element actionability — visible, enabled, editable when the interaction writes, in the viewport, not obstructed at its interaction point, not moving.
The record now states outright that actionability does not depend on settledness and is not derived from it, and that this independence is what makes decision 2 affordable: the interaction path consults actionability only. An app that long-polls or animates continuously would otherwise pay a settle timeout on every click.
| not yet ready: the page is still hydrating, a spinner is still animating, a modal is fading in over | ||
| the button, a list is re-rendering under the pointer. WebDriver's own actionability checks run at | ||
| the moment of the command — click scrolls into view and verifies the in-view centre point is | ||
| pointer-interactable — and then either succeeds or throws. They do not wait, and they do not |
There was a problem hiding this comment.
Couple important clarifications here. The spec does say to wait for interactability on send keys and clear, using the implicit wait timeout. Additionally, Chrome waits for the element to be displayed before clicking, so we can't assume that nothing is happening currently with an implicit wait value set.
There was a problem hiding this comment.
You are right, and the corrected version is a stronger Context than the claim I had. Rewritten in a9f895c.
I checked the spec text rather than take it from memory, and the detail matters for the argument:
| Command | Waiting behavior |
|---|---|
| Element Clear | steps 6–10: take the implicit wait timeout, start a timer, "wait for element to become interactable" |
| Element Send Keys | steps 7.2–7.6: the same pattern, "wait for element to become keyboard-interactable" |
| Element Click | steps 5–7: scroll into view, then fail. No wait step |
So "they do not wait" was wrong for clear and send keys. What survives is the predicate. Keyboard-interactable is "any element that has a focusable area, is a body element, or is the document element" — invisible, covered by a modal, mid-animation and aria-disabled all satisfy it. Interactable is "pointer-interactable or keyboard-interactable", so clear's wait is satisfied the same way.
The Context now argues the gap as: the predicate is too weak to prevent the failures users hit, it is absent for click, it says nothing about why it expired, it shares one budget with element location, and what happens beyond it differs per remote end. Your chromedriver point is in there too, as evidence that remote ends already improvise here — which reads as an argument for defining it once rather than against defining it at all.
| interaction point, and not moving. Which checks apply depends on the interaction: `type` and | ||
| `clear` additionally require editable; `drop` and `screenshot` do not require enabled. | ||
|
|
||
| **2. Interaction commands wait for actionability by default when the session has BiDi enabled.** |
There was a problem hiding this comment.
Waiting for actionable will stack with the interactability waits remote ends already run under the implicit wait timeout as I mentioned above. I don't think we can make this default behavior without causing problems for users. As such, I think it makes the most sense to replace implicit wait setting with this behavior because we don't want to send an implicit wait value to the remote end if we are also executing this actionable wait. But we'll still need to keep implicit wait value at 0 by default.
There was a problem hiding this comment.
The stacking defect is real and it changed the design. Landed in a9f895c: readiness is off by default behind an se:interactionReadiness capability, Selenium stops forwarding a non-zero implicit wait to the remote end when it is on, and the budget is the implicit wait value when set with a documented 10s default otherwise — so there is no second timeout and no new toggle. The default-on proposal is recorded as retreated from, and re-filed as a follow-up decision, probably release-gated as you suggested.
On your later point about locateNodes — the premise checks out, and it is worse than you put it. From the spec source:
browsingContext.LocateNodesParameters = {
context: browsingContext.BrowsingContext,
locator: browsingContext.Locator,
? maxNodeCount: (js-uint .ge 1),
? serializationOptions: script.SerializationOptions,
? startNodes: [ + script.SharedReference ]
}
No wait, no timeout, no retry. Beyond that, the string timeout appears twice in the whole of webdriver-bidi, both TODO notes about a possible future script.evaluate timeout. BiDi has no timeout concept at all. So there is nothing for the remote end to drive an implicit wait with, and I agree that kills "actionability is the BiDi implicit wait" — they do different jobs and my current decision 2 is wrong on its own terms.
Where I would push back is on the conclusion that implicit wait must therefore be unsupported. The protocol cannot provide it; that does not stop us, and the machinery this record already proposes is a better vehicle than the remote end ever was: subscribe to the mutation signal the preload observer already emits, and re-run locateNodes only when the DOM actually changed, instead of polling on a fixed interval. That is a handful of round trips over a socket that is already open rather than N per 100ms, and it fails with a real diagnosis instead of a bare NoSuchElementException. The alternative — reimplementing locators inside the atom so the retry is fully client-side — I would reject, since XPath, inner text and accessibility locators would drift from the remote end's implementations.
Dropping implicit wait instead is a defensible strategic call, and I would rather it were chosen as a deprecation than accepted as a protocol casualty, because the migration cost lands on the wrong people: someone who enables BiDi for network interception and happens to have implicitly_wait(10) set silently loses element-location retry. That is a worse break than the stacking it fixes.
One consequence worth naming before you finish thinking it through: if implicit wait is no longer the budget, readiness needs its own timeout value — the knob you asked me to delete on the decision 3 comment. Not a contradiction, just a consequence, and there are three ways out: repurpose the now-free implicit wait setting as the readiness budget (I would reject that — same name, changed meaning), a new readiness timeout with implicit wait deprecated on BiDi, or a new readiness timeout with implicit wait implemented client-side as above. I would take the third. Which do you want?
Edge cases I would want on the list, since you said you were still working through them:
- The runtime setter.
timeouts().implicitlyWait()on a BiDi session: raise, warn, no-op, or silently become the readiness budget? Silent is the worst option and the easiest to ship by accident. - A non-zero default versus negative tests. Your "maybe a major release?" concern survives the stacking fix — suites asserting an element is not interactable now wait N seconds before failing. A timing regression rather than a correctness one, but real.
- Stale references. Ordering between the readiness wait and
StaleElementReferenceExceptionfor an element found before a navigation. - Partial BiDi.
webSocketUrlpresent but some commands still routed over classic, or Grid in between — does the capability alone gate the behavior, or per-command transport? - The Actions API. The record currently says it does not auto-wait, on the grounds that it exists to dispatch an exact input sequence. Worth confirming you agree, because it is where Playwright puts real effort and it is the obvious "why not here too" question from users.
I am deliberately not rewriting decision 2 a third time until you land, since you flagged the position as unfinished.
| named, nothing new is reachable off the driver — 17670 constrains what the API *says*, and it stays | ||
| silent about BiDi here. | ||
|
|
||
| **3. A new `readiness` session timeout bounds the implicit wait, and failures keep their current |
There was a problem hiding this comment.
Agree failures should keep their current type ,and since the timeout raises client-side we could even use per-type subclasses so readiness timeouts are distinguishable without breaking existing rescues. As per decision 2 comment I don't think we want a new toggle or timeout value.
There was a problem hiding this comment.
Both halves taken, in a9f895c.
Decision 3 now keeps the existing types with readiness-specific subclasses: obstruction raises a subclass of ElementClickInterceptedException, not-visible/not-enabled/not-editable a subclass of ElementNotInteractableException, each carrying the diagnosis in its message. Existing catch/except/rescue keeps matching; anyone who wants to tell "still not ready after the budget" from "not ready at the instant of the command" can catch the subclass. Raising client-side is what makes the subclass possible at all, which is worth stating since it is the only reason this is available to us.
The new readiness session timeout and the session-level toggle are both deleted. Also dropped a divergence I had recorded against the prototype: blocking waits raise, snapshots return records, so there is no "returns a result object instead of raising" inconsistency left to reconcile.
| appended to the message. Existing `catch`/`except` blocks keep working; a test that fails today | ||
| fails the same way, later and with a better message. | ||
|
|
||
| **4. Two explicit entry points remain, both on the driver, both protocol-neutral.** Names follow |
There was a problem hiding this comment.
The pattern of driver.method(element) breaks object orientation, and would work better either acting on the element object, or in the driver.script domain. The latter seems the easier change, especially since I don't think we want either of these methods to be the primary way users access this functionality. I'd much rather see us integrate them better into our existing API, and then provide more flexibility beyond just the remote-end controlled wait. Maybe we can discuss what these might look like on Slack.
There was a problem hiding this comment.
Agreed, and we went further than moving it. Landed in a9f895c, with signatures added in 2fd6078.
There is now no new wait method on the driver or on the element. The wait lives inside click, send_keys, clear and submit, where the user does not have to know it is there — which is your "integrate it into the existing API" point rather than a second blessed entry point.
Placement of the state follows the object model, with one rule stated explicitly so it cannot drift back: document state on the script domain, element state on the element.
driver.script.pending_work() # document
driver.script.settledness() # document
element.actionability("click") # elementThe record fixes arguments and return shapes but deliberately leaves the per-binding spelling of the names open — happy to take that to Slack, along with the per-action override shape in decision 7, which currently reads as a keyword argument and may not survive contact with Java and .NET.
| The pending-work ledger is an input to the other two (`requirePendingQuiet`), not a third public | ||
| entry point — "are there pending timers?" is not a question users should have to ask. | ||
|
|
||
| **5. An explicit wait that times out raises the binding's existing timeout error, and the error |
There was a problem hiding this comment.
I don't think we want to limit exposure to this functionality to two remote-end managed wait methods. The plan for script module is to have a synchronous script execution method and then support async behavior with handlers. So the examples in your python repo won't fit what we're planning to support.
I think we want to provide more powerful access to settledness via a callable method similar to add_dom_mutation_handler, and there is a lot of value to providing access to a snapshot of the record for any client library to manage with their own waiting strategies instead of forcing a single option.
There was a problem hiding this comment.
Agreed on both counts, and it turns out to be cheap because the pattern already exists.
Decision 5 now exposes the state as snapshots and events, not only as waits:
- a snapshot returning the current record without waiting — pending work classes, still-mutating regions, or for an element the per-check result and the obstructing element — so it composes with whatever waiting strategy a client already has, including inside an existing
WebDriverWait; - a settledness handler following the existing
driver.script.add_dom_mutation_handlerpath (java/src/org/openqa/selenium/remote/RemoteScript.java:85) — preload script,ChannelValue,script.onMessage. That is the same wiring the prototype's observer already uses, with a different payload, so this is an existing pattern rather than new machinery.
Signatures went in with 2fd6078. On the synchronous script module: nothing here needs an async evaluate — the atom exports are synchronous functions, so a snapshot is one synchronous call and the async side is handlers only, which lines up with where you are taking that module. The prototype's Python examples do not fit that plan and are evidence only; the record now says so in the appendix.
| to fix. A non-blocking inspection call returning the same snapshot without waiting is available for | ||
| diagnostics and for users building their own conditions. | ||
|
|
||
| **6. One implementation, shared by all bindings.** The readiness oracle is a single JavaScript |
There was a problem hiding this comment.
This is conflating a shared implementation with a single atom combining all the functionality described here. We can share the functionality with multiple atoms that perform specific functions with specific arguments that are directly applicable so the bindings don't need to add additional logic beyond passing arguments to the atom.
There was a problem hiding this comment.
Agreed, and updated in 5a48bdf after talking this through offline.
The record now proposes one shared atom that exposes narrow named exports rather than several separate atoms. One artifact keeps the heuristics that share definitions together — what counts as meaningful mutation, when a spinner is inert, how obstruction is hit-tested — and stays a single thing to hand to the BiDi working group. The per-signal exports are what keep a caller from taking on the parts it did not ask for: pending work for a document, settledness given a root and a settle window, actionability given an element and an interaction, each taking only its own arguments and returning only its own result. A binding that wants an actionability snapshot neither constructs arguments for settledness nor interprets a record containing it, so there is still no place for per-binding logic to creep back in.
Decision 8 is rewritten and the considered options now carry all three shapes explicitly — combined entry point (rejected), separate atoms per signal (rejected, since artifacts that build and version apart are the divergence this record exists to prevent), named exports (accepted).
- correct the Context: Element Clear and Element Send Keys do wait on the implicit wait timeout; the gap is the weak predicate, click having no wait, the shared budget, and the absent diagnosis - separate the two readiness signals; actionability no longer presented as a layer stacked on settledness - gate on a se:interactionReadiness capability and replace the implicit wait instead of adding a second timeout that stacks with the remote end's - take over element-location waiting on the client where the implicit wait is no longer forwarded - raise readiness-specific subclasses of the existing interaction errors - drop the driver-level wait methods; expose snapshots and a settledness handler in the script domain instead - add a locally managed settled page load strategy and per-action override - split the single atom into narrow per-signal entry points
Titus and David agreed on a single shared atom provided it exposes succinct per-signal exports, so callers do not take on the whole record. Decision 8 and considered options 22-24 updated accordingly; options renumbered.
📄 The decision, its rationale, considered options and consequences are in the record file this PR
adds; read it there. The sections below are proposal notes and review logistics.
Revised after review (a9f895c):
an earlier revision proposed that readiness waiting become the default for every BiDi session. It no
longer does. Readiness is enabled by a new
se:interactionReadinesscapability, off by default, andwhen enabled it replaces the implicit wait rather than adding a second timeout beside it. Making
it the default is now a follow-up decision, probably release-gated.
🔗 Related
this is high-level, protocol-neutral API with BiDi as an implementation mechanism that must not
leak into signatures. No method signature changes and no BiDi type is named; the behavior is
capability-gated.
py-quiescence-bidi-preload— the readiness oracle as a JavaScript atom registered as a BiDi preload script, with ~1,500 lines
of behavioral tests. Evidence that the semantics are implementable and testable; not the
proposed API shape (see divergences below).
quiescencemodule; no working-group issue filed yet.📝 Proposal notes
A correction the record now carries. The first revision claimed WebDriver does not wait before
interacting. That is wrong, and the corrected version is a stronger argument:
keyboard-interactable is "any element that has a focusable area, is a
bodyelement, or is thedocument element" — invisible, covered, mid-animation and
aria-disabledelements all satisfy it,and interactable is satisfied the same way. The timeout is initially
0, and when set it is alsospent on element retrieval. So the gap is not that WebDriver never waits: it is that the predicate is
too weak to prevent the failures users hit, it is absent for
click, it cannot say why it expired,it shares one budget with element location, and remote ends improvise past it (chromedriver waits for
displayed before clicking).
Why the capability replaces the implicit wait instead of adding a timeout. For
send_keysandclearthe remote end already runs its own interactability wait on the implicit wait timeout. Aseparate readiness timeout would stack with it and hand a user who set an implicit wait a total they
never asked for. So when the capability is on, Selenium stops forwarding a non-zero implicit wait and
spends that budget client-side on a predicate that is actually useful.
The consequence that needs a decision. Because the implicit wait is no longer forwarded,
findElementloses its remote-end retry, so the record proposes Selenium take over element-locationwaiting on the client for these sessions. That trades local spinning at the remote end for polling
round-trips. It is decision 2 and considered option 8, and it is the part of the design I would most
like confirmed.
What the wait covers. Actionability only — visible, enabled (including
aria-disabledand thefieldset/legendexception), editable where the interaction writes, in the viewport, unobstructed,not moving. It deliberately does not require page settledness: an application that long-polls or
animates continuously would pay a settle timeout on every click. The low-level Actions API does not
auto-wait either.
How users reach the rest. There is no new wait method on the driver or on the element. The wait
is inside the interaction commands; the underlying state is reachable as a snapshot (composable with
an existing
WebDriverWait) and as a settledness handler following the existingdriver.script.add_dom_mutation_handlerpattern — preload script,ChannelValue,script.onMessage— so no client library is forced into Selenium's waiting strategy to use Selenium's readiness data.
The record fixes the arguments and return shapes of these
accessors; only the per-binding spelling of their names is deliberately left open.
Also proposed: a
settledpage load strategy, managed locally becausepageLoadStrategyis avalidated
none/eager/normalcapability, plus a per-action strategy override.Compatibility. Nothing changes for a session that does not set the capability. Where it is set,
an expired wait raises a readiness-specific subclass of what the command raises today
(
ElementClickInterceptedException,ElementNotInteractableException), so existingcatch/except/rescueblocks keep matching while the readiness case stays distinguishable.Deliberately out of scope, as follow-up decisions: making readiness the default for BiDi sessions
and in which release; a variant for classic sessions (considered option 10 argues it cannot be done
at full fidelity without a preload); deprecating
ExpectedConditions.elementToBeClickableandequivalents; the per-binding spelling of the accessor names; the BiDi module proposal itself.
Divergences from the reference implementation. It exposes two
driver.*wait methods, whichdecisions 4 and 5 replace, and a single atom behind one combined entry point, which decision 8 keeps
as one artifact but re-shapes into narrow named exports. The capability, the implicit-wait replacement, the
client-side element-location retry, the error subclasses and the
settledstrategy are proposed hereand not yet built.
Cross-binding impact. Every binding gains the capability, the interaction-path change, the
snapshot and handler surface, the error subclasses, the page load strategy value, and the packaging
wiring to ship a JavaScript resource; bindings that already ship atoms have most of the last part.
Because the capability is off by default, existing tests are unaffected — each binding instead needs
new coverage for on, off, and preload registration failing.
🗣 Discussion
Questions I would most like input on:
findElementretry on the client the intendedreading of "readiness replaces implicit wait", or should this be scoped to interaction only and
the implicit wait still forwarded?
value makes failing suites slow; a low one makes the feature look unreliable on slow CI.
separate discussion.
passing test into a timeout. Is "best-effort, documented limits, off unless asked for" enough?
capability exists — which is not the population that flakes. What would make default-on
acceptable: a major release, a release cycle of feedback, something else?
Discussed at the TLC meetings below; see the minutes for the full discussion and attribution.
separate exposed entry points rather than separate atoms, split along stateful vs stateless lines,
with the interactability path fixed to use the readiness state it currently ignores.
📌 Tracking
Tracking issue: (linked on acceptance)