diff --git a/docs/architecture/transport-and-protocol.md b/docs/architecture/transport-and-protocol.md
index af30e96..a3591c3 100644
--- a/docs/architecture/transport-and-protocol.md
+++ b/docs/architecture/transport-and-protocol.md
@@ -101,6 +101,8 @@ Three kinds of target get a session of their own:
**Execution context IDs are numbered per session.** Two renderers will both hand out context 1, so the map from context to frame is keyed by session and context together. A frame's context ID is only meaningful on the session that owns that frame, and every call site pairs the two.
+**A frame's context ID is replaced on every navigation.** `Runtime.executionContextCreated` records the main world of the frame named in its `auxData` (an isolated world, with `isDefault: false`, never replaces it), `Runtime.executionContextDestroyed` drops the entry with that ID from the session that reported it, and `Runtime.executionContextsCleared` drops every entry that session reported. The three are read from one channel per session, so a navigation's teardown is always handled before the context that replaces it. They do not share a channel with `Page.loadEventFired`, so a navigation can complete while the old context is still recorded or before the new one is. Commands that name a context cover both cases. A frame with nothing recorded waits for its next context, bounded by the caller's timeout or 30 seconds, and fails at once with `MotusTargetClosedException` if the frame is detached or the page closes; the main frame instead leaves the ID out, which the browser resolves to the main frame's current main world. A command refused with "Cannot find context with specified id" is sent once more against whatever has been recorded since. The browser looks the context up before running anything, so the refused attempt never ran and the retry cannot run a script twice. BiDi has no counterpart: script there is addressed to a browsing context and runs in whatever realm it currently holds, so there is no ID to track or wait for.
+
### Routing a frame to its session
`Page.SessionFor(IFrame?)` is the single lookup. A frame that owns a session is routed to it; every other frame, including a same-process child of an out-of-process frame, falls through to the page's own session. That fallback is what keeps every existing round trip byte-identical.
diff --git a/docs/guides/frames-and-iframes.md b/docs/guides/frames-and-iframes.md
index 1d4881b..9a3c831 100644
--- a/docs/guides/frames-and-iframes.md
+++ b/docs/guides/frames-and-iframes.md
@@ -90,7 +90,9 @@ if (frame.IsDetached)
}
```
-`IsDetached` is how a removed frame is told apart from a selector that simply did not match. A held `IFrame` answers rather than failing obscurely several calls later.
+`IsDetached` is how a removed frame is told apart from a selector that simply did not match. A held `IFrame` answers rather than failing obscurely several calls later. Evaluating, reading content or resolving a locator through a detached frame throws `MotusTargetClosedException` straight away, and so does a call that was already waiting on the frame when it went.
+
+An evaluation, a locator or a content read issued the moment a frame finishes navigating runs in the new document. A frame's main world is replaced on every navigation and the browser reports the new one separately from the end of the navigation, so for a frame other than the main frame such a call waits for the new world to be reported, for up to 30 seconds or the call's own timeout, rather than running against the old document or in the main frame by mistake.
---
@@ -159,7 +161,7 @@ Each of these costs real time to rediscover.
**The endpoint's target list is not a list of a page's frames.** `http://127.0.0.1:9222/json/list` reports one entry per CDP target, and only a frame the browser put in its own process has a target of its own. So the list shows those, typed `iframe` rather than `page`, and shows nothing at all for every frame the page's own renderer hosts. A document with two frames, one cross-origin and one `srcdoc`, produces exactly one `iframe` entry. Counting entries there will undercount the frames on the page, and filtering for `page` entries will miss all of them. Motus builds `page.Frames` from auto-attach and the frame tree instead, which covers both kinds, so read frames from there and treat the target list as a view of processes rather than of documents.
-**A frame changing process is not a frame going away, but a frame whose parent navigated is.** The browser announces a frame moved into another renderer process as a detach carrying the reason `swap`, and says the same for a document it puts into the back-forward cache. Motus tells both apart from a real removal: the frame stays in `page.Frames`, `IsDetached` stays false, no `FrameDetached` arrives, and the `IFrame` being held keeps working across the move. Navigation is the other case. Committing a new document in a frame drops the frames that frame hosted, because the new document reports its own, so a child frame is rediscovered after its parent navigates and comes back as a different `IFrame`, even when it loads the same URL. What that leaves to watch for is the handle from before. It reports `IsDetached`, and checking that is worth the line, because a frame the page no longer lists has no execution context of its own to aim at and an evaluation through it falls back to the page's, quietly answering for the main document rather than failing. Read the frame from `page.Frames` again after a navigation and wait for it as under [Traversing](#traversing), since for a moment after the parent commits its new document the subtree is empty.
+**A frame changing process is not a frame going away, but a frame whose parent navigated is.** The browser announces a frame moved into another renderer process as a detach carrying the reason `swap`, and says the same for a document it puts into the back-forward cache. Motus tells both apart from a real removal: the frame stays in `page.Frames`, `IsDetached` stays false, no `FrameDetached` arrives, and the `IFrame` being held keeps working across the move. Navigation is the other case. Committing a new document in a frame drops the frames that frame hosted, because the new document reports its own, so a child frame is rediscovered after its parent navigates and comes back as a different `IFrame`, even when it loads the same URL. What that leaves to watch for is the handle from before. It reports `IsDetached`, and anything run through it throws `MotusTargetClosedException` rather than answering for some other document. Read the frame from `page.Frames` again after a navigation and wait for it as under [Traversing](#traversing), since for a moment after the parent commits its new document the subtree is empty.
**Application globals are absent from isolated worlds.** An isolated world shares the frame's document and nothing else, so the DOM is fully present while everything the application put on `window` is not. An expression that reads a framework handle returns `undefined` there and the failure looks like the application not having loaded. Use `ExecutionWorld.Main`, which is the default, whenever you are reading application state.
diff --git a/src/Motus.Mcp/Snapshot/FrameCollector.cs b/src/Motus.Mcp/Snapshot/FrameCollector.cs
index fbc932d..f1134f9 100644
--- a/src/Motus.Mcp/Snapshot/FrameCollector.cs
+++ b/src/Motus.Mcp/Snapshot/FrameCollector.cs
@@ -32,6 +32,10 @@ internal static class FrameCollector
private const string SelfIndexExpression =
"(() => { const p = window.parent; for (let i = 0; i < p.length; i++) { if (p[i] === window) return i; } return -1; })()";
+ // How long the frames of one document get to say which of their parent's contexts they are.
+ // A frame with a document answers in milliseconds.
+ private static readonly TimeSpan SelfIndexAllowance = TimeSpan.FromSeconds(1);
+
// Which child browsing context this element holds, asked of the document the element lives in.
private const string HostIndexExpression =
"el => { const w = el.ownerDocument.defaultView; for (let i = 0; i < w.length; i++) { if (w[i] === el.contentWindow) return i; } return -1; }";
@@ -182,24 +186,32 @@ void Walk(IFrame frame)
/// Frames that cannot answer, and any index two frames both claim, are left out, so a reading
/// that is not trustworthy falls through to the order the engine gives instead of guessing.
///
+ ///
+ /// A frame that has not loaded a document yet, such as a lazy iframe still below the fold, has
+ /// no context to evaluate in, and an evaluation there waits for one until its own timeout. So
+ /// every frame is asked at once and the answers are read after
+ /// at most: a frame that cannot answer costs the snapshot that allowance once, rather than a
+ /// full evaluation timeout for each such frame.
+ ///
private static async Task> ByContextIndexAsync(
IReadOnlyList