The DOM of a same-origin frame
Each document has its own JavaScript realm: an instance of the engine, compiled to Wasm, with its own heap. A browser's frames
share a heap between documents of one origin, and pages rely on it: iframe.contentDocument.write(html),
frame.contentWindow.someGlobal, parent.document, window.parent.foo(), rich-text editors, payment widgets, test
harnesses. Wren gets the same result without sharing a heap, in two parts.
Nodes: handles that name their document
The DOM lives on the browser's side; a wrapper in a realm is a handle. A handle now says which document it belongs to: 15
bits of its generation word are the document's tag (base.pool_insert_tagged, src/base/pool.odin). So a wrapper of a realm
can hold a node of another document, and contentDocument and all that is reached from it are ordinary DOM wrappers of the
caller's realm.
Every generated import trampoline hands its handle arguments to api_route (src/page/api_host.odin). A handle of another
document moves the whole call to that document: Call.page is that page, Call.caller the one whose realm is calling, and the
implementation does what it does for a page calling itself, so the frame's styles, layout, hooks and scripts follow. Imports
of Document that name no node (createTextNode, title, write...) are preceded by a one-shot Frame_target.
What keeps a frame of another origin out:
- a handle resolves in its own document only. The tag is in the slot's generation, so a handle given to the wrong document fails like a dead one, in any implementation, routed or not;
- the document is looked up on each call, among the documents of the caller's tab, and its origin compared with the
caller's (
frames_may_reach,src/page/frames_access.odin: HTML's "same origin-domain"). A frame gone, navigated to another origin, or sandboxed withoutallow-same-originleaves handles that resolve to nothing; - a cross-origin frame's nodes never get a handle in another realm in the first place:
contentDocumentis null,contentWindow.documentandparent.documentthrow aSecurityError.
document.domain = value sets the domain of the document's origin (url.Origin.domain) to its host or a registrable domain suffix
of it (not a public suffix, not for an IP address), for every document that has that origin (an about:blank or srcdoc frame has
its parent's: Page.origin_owner); a sandboxed document gets a SecurityError. Two documents that set the same domain reach each
other whatever their ports and subdomains, and a navigation between them is told to the target (its navigate event). In an
origin-keyed agent cluster (Origin-Agent-Cluster: ?1, or cross-origin isolated) the assignment changes nothing; without the header
the agent cluster is keyed by site, as HTML says.
An insertion of a node of another document moves it first (DOM "adopt", Frame_adoptNode): a node has its handle in its
document's pool, so this makes new nodes, with their listeners, and the wrappers of the realm that asked follow them. A
custom element keeps its definition: its reactions run in the realm that defined it, adoptedCallback included. A
wrapper another realm holds on the old node goes dead; a shadow root does not follow its host; a form control's typed
value and the state of a media element stay behind. document.adoptNode and importNode across documents leave the node where
it is until it is inserted, so ownerDocument tells the old document until then.
A wrapper belongs to a realm: the same element has one wrapper per realm that reached it, and a property set on one is
not on the other. A method of a custom element of the frame's registry is not visible on the parent's wrapper of it. A
MutationObserver follows other documents with a browser object in each. The methods of a wrapper are its realm's, but
one given too few arguments throws the TypeError of the node's realm (throw_arity_of, frames_bridge.odin), as the
node's own method would in a browser: frame.contentWindow.TypeError.
Listeners may come from both realms. A listener added from another realm records a Foreign_Owner (its page, its instance), and
runs in the realm that added it, a nested instance call. A script inserted in a frame runs in the frame's realm, wherever its
element was made. All documents of a tab are on one thread, so a nested call needs no lock; the native stack check of
src/wasm ends a recursion through realms as it ends one in a realm.
A document with a running call in it cannot be freed: page_close then takes it out of the tree at once and closes it when
the call has returned (frames_close_later). The script goes on, in a document nobody can reach.
Blank frames
A frame with no address (about:blank, the initial document) is a real document with a realm of its own, like any other,
made on first need. Reached by another realm, it becomes a document that finished loading, shown in its frame
(frames_make_live).
The first document that replaces it keeps its Window when it is of the same origin, as HTML says: what a script put on the
blank frame's window (frame.contentWindow.navigation.onnavigate = ..., window[0].foo = ..., its listeners and timers) is the
next document's. The blank document's Page takes the new document (frame_keep_window, src/page/frames.odin) instead of a
Page of its own, but only when its realm was started: a blank frame no script touched has nothing to keep. The realm, its
instance and the Window's objects stay; the new tree gets a tag of its own when it is parsed, so that a wrapper of the blank
document is dead in every realm, while the objects the Window made keep the old tag (Page.prior_tag, which the routing of
frames_access.odin takes for the page's own). The URL, the policies, the frames and the scripts are the new document's, and the
realm forgets what belongs to the document (wj_document_replaced): history, the custom element registry with its definitions,
navigation.activation. A document of another origin, or one a sandbox makes opaque, gets a Window of its own.
Globals: a value bridge
frame.contentWindow.foo and parent.foo() go through native/js/odin/frames_bridge.odin and web_bridge.js. A request is
bytes the browser carries from an instance to another (Frame_bridge, which checks the origin each time):
| value | in the other realm |
|---|---|
| undefined, null, booleans, numbers, strings, bigints | copied |
| well-known symbols | by name; any other symbol is not carried |
| a DOM node | that realm's wrapper of the node |
| a window | that realm's WindowProxy, or its global |
| an interface object, its prototype | by name: that realm's own (a node is that realm's wrapper, with its prototypes) |
| any other object, a function, an ECMAScript built-in | a Proxy forwarding get, set, has, delete, keys, descriptors, prototype, call, construct |
An object coming home is itself. A realm that is gone answers undefined. Proxies are collected with FinalizationRegistry, which
releases the object in its realm.
Each window keeps its ECMAScript built-ins, as in a browser: w.Array !== Array, new w.Object and new w.Array are objects of w
with w's prototypes, x instanceof Array is false for an array of w and x instanceof w.Array true, Array.isArray sees
through (it is how pages tell an array of another frame), and what w throws is a w.TypeError, not a TypeError. A built-in
reached through a window is a Proxy like any other object of it, so each use of it crosses: measured on a page calling into a
frame 2000 times (jspage, a dev build, 2026-11-02), a call w.add(i, 1) costs 15 µs before and after and a read w.obj.a 11 µs;
reading w.Array went from 1 µs (the reader's own) to 10 µs, w.Array.isArray([]) from 1 to 99 µs (a new array goes to w as an
object, which w releases later: 30 µs when w already holds it), new w.Object from 1 to 35 µs, xOfW instanceof Array from 6
to 19 µs (each prototype is asked of w). A page that takes a built-in of a frame once and calls it in a loop pays a crossing per call.
A listener or a handler that is a Proxy of another realm's function runs as a callback of that realm: what it throws is reported at
that window's error event, and once that realm is gone it is not called. A listener of another realm that cannot be called (no
handleEvent, one that is not callable, a revoked Proxy) fails with a TypeError of that realm, reported there.
What it is not is one heap:
- the interface objects are the exception to the rule above:
w.HTMLElement === HTMLElement, because a node ofw's document is the reader's wrapper of it.w.document.body instanceof w.HTMLElementholds, and so doesinstanceof HTMLElement, where a browser says false;new w.Event("x")makes an event of the reader; - a native function refuses a
Proxyfor one of its own objects: a typed array or aBlobof one realm passed to a native of another throws aTypeError. AnEventtootherWindow.dispatchEventis handled, andframeDocument.createRange()andgetSelection()give the frame's own, which itsSelectiontakes; - a cycle between two realms (a listener of one kept by the other) lives until one of them goes;
navigator,screen,cryptoandconsoleof another window are the caller's (a page takes a cleancryptofrom a blank frame and hands it typed arrays of its own, which the frame's would refuse);- a frame without a realm (sandboxed without
allow-scripts) gives the caller's native constructors for its upper-case names; what the caller's scripts set on its window is kept in the caller's realm (another realm sets and reads its own), and itsopen()opens a window as the frame (its sandbox, the frame its opener); w.Function.prototype.toString.call(f)on a function of the caller says[native code]: the frame sees aProxy.
A custom element class of one realm defined in the registry of another (frame.contentWindow.customElements.define(name, class extends
HTMLElement {})) is not supported: its constructor would run in one realm for an element of another.
Rich-text editors reach their frame, but Wren has no editing of its own in contenteditable or designMode (typing, caret, execCommand):
that limit is not about frames.
Navigating a frame
A navigable keeps its session history across its documents (Navigable.session): navigation.entries(), back(), traverseTo() and
reload() see them, keys and ids stay. The navigate event fires in the frame's document whoever starts the navigation (location of
another window, window.open or a link or a form with a target, the src and srcdoc attributes), but for a navigation to another
document started from another origin. The document that leaves gets beforeunload, then pagehide and unload, and starts no
navigation while they run. A 204, a 205 or an attachment leaves the document as it is.
The tab's history is one for its frames and itself, HTML's joint session history (src/page/history_joint.odin). The tab's session
(its tree, history.odin) keeps, beside its entries, the steps of its frames: a push in a frame (another document, a fragment,
pushState, an intercepted navigation) is a step, filed after the entry of the tab that was current then, with the entry it made
current in the frame's session and the one it left. Flat arrays (Session_History.steps, #soa), navigables named by id: the web
sees one line, each entry of the tab followed by its steps.
history.lengthin any document of the tab counts that line;history.go(delta),back(),forward()in any document, and the browser's back and forward buttons, movedeltapositions: each frame then shows the entry of its last step at or before the position (before its first step, the entry that step left), traversed in its document or loaded again, and the tab its entry there: one of its own document's (popstate), or another document, which the shell loads (from the back/forward cache its frames move to their entries of that position);- a frame whose document left without the back/forward cache (navigated away, evicted) is kept with its entries (
Kept_Frame: the document it was in, its container's element path); when that document is loaded again in its entry (back, forward, a reload), the frame at the same place takes them back and loads its entry of the current position instead of itssrc, and so do the frames inside it; alocation.replace()puts another document in the entry, whose frames start from their attributes; navigation.traverseTo()(back(),forward()) in a frame goes to the nearest position where that frame shows the entry;- a push drops the positions ahead, a frame's push the tab's entries ahead too (another branch of the tree,
History_Entry.cut), and the documents whose entries went get theirdisposeevents; - a sandboxed document's traversal moves nothing when it would move a navigable it may not navigate (its
navigation.traverseTo()rejects with aSecurityError); - a step of a frame that was removed no longer counts.
What is not there:
precommitHandlerofNavigateEvent.intercept().
Windows that scripts open
window.open() to _blank or to a name nothing has, and a script's click on a link to one, open a window: a top-level navigable
of the same kind (src/page/auxiliary.odin), whose embedder makes the window around it (the browser a tab, the WPT runner's
headless host a window without a shell, with a back/forward cache of its own: src/tools/headless) and whose first document is the initial about:blank the page package makes (the
opener's origin, or an opaque one under noopener; the opener's agent cluster, cross-origin isolated as the opener is). The
window keeps its id across its documents, so the WindowProxy that window.open returned still names it after it navigated.
Its first document keeps the initial about:blank's Window, as a frame's does (above): w = window.open(url); w.onload = ...
and w.foo = 1 are the new document's when it is of the blank's origin and its Cross-Origin-Opener-Policy keeps the browsing
context group (navigable_keep_window, src/page/auxiliary.odin, which the embedder asks with the response). The embedder keeps the
blank document, shown and reachable, until then (the browser's navigate, Doc_Load.blank; the WPT runner's headless host);
a blank no script touched, a document of another origin or a group switch get a Window of their own, and the blank goes.
- The opener.
window.opener, a name the opener's group finds (window.open(url, name), a link's target),postMessageboth ways, the DOM of a same-origin popup (its handles are looked up among the documents of the caller's browsing context group: its tab and the windows related to it by openers).noopener/noreferrer(features, orrel; a link to_blankhas no opener unlessrel=opener) open a window of its own group, andwindow.openreturns null.window.opener = nullends it. - Closing.
close()closes a window a script opened:closedis true at once, the embedder closes it in a task. A tab the user opened stays open. - Cross-Origin-Opener-Policy. A top-level document's policy is matched with that of the document it replaces (an initial
about:blank has its opener's, when the opener is same origin with its top-level document): a mismatch puts the window in a new
browsing context group. It gets a new id, so the opener's
WindowProxysees it closed; itsopeneris null, its name cleared, and the windows it opened lose theirs.same-origin-allow-popupskeeps the popups it opens that have no policy.
- Sandboxing. A window that a sandboxed document opens is sandboxed as that document is, each of its documents
(
Navigable.sandbox, applied bynavigable_attach), unless the sandbox hasallow-popups-to-escape-sandbox.
Not there: a redirect's own policy (only the final response's counts), reports (report-to, Report-Only), the window features
(sizes, popup), and the browser's noreferrer (its navigations send the Referer; the WPT runner's do not).
Policies a frame requires
<iframe csp="..."> (CSP Embedded Enforcement, src/page/frames_csp.odin) is a policy the frame's documents must take; a frame
without one takes its parent's own. Its requests send it (Sec-Required-CSP). A response with Allow-CSP-From: * or the parent's
origin takes it, enforced on the document; one whose own enforced policies are as strict (the standard's subsumption,
src/csp/subsume.odin: each required directive matched in one of them, through the fetch directives' fallbacks, by a source list that
allows nothing more: hosts within its wildcards, ports and paths, no keyword or nonce it lacks; a sandbox with no more tokens) takes it as
it is; a csp attribute less strict than the parent's own requirement gives way to that one; about:blank, srcdoc,
data: and blob: documents take it enforced. Any other is refused, as X-Frame-Options refuses: the frame shows an error
document. The sandbox directive of any enforced policy, a response's header or a required one, sandboxes the document on top of
what its container asks.
Cost
A page without frames pays two comparisons per handle argument of an import call, and one load per call. The tag replaces bits of
the generation: pool_get compares what it compared. A slot reused 65536 times is retired rather than wrapped.