We added DOM operations such as before() and replaceChildren() to Omoikane, along with window.onerror and notifications for unhandled Promise rejections. [PR #1322](https://github.com/ieee0824/omoikane/pull/1322) This is about that pull request. The initial issue was straightforward: the page lacked the APIs it normally calls. However, as we progressed with implementation and testing, we ended up fixing issues such as copying sibling lists when inserting HTML text, rebuilding the entire document after UTF‑16 appends, creating unnecessary iframe realms, and even addressing insufficient GC roots for Promises.

If we were merely adding more named APIs, the article could be shorter. But this change touches not only the values returned by the APIs, but also the user code that runs in between, the timing of event delivery, the owners of retired documents, and even references visible to the garbage collector. The moment replaceChildren() creates a new tree and the moment the unhandledrejection callback triggers garbage collection both interact with the same set of objects in the same browser, just from different angles. Even if we omit those details and simply write “fixed,” the purpose of the implementation won’t be conveyed.

This time I’ve written a fairly extensive record. For each feature I explain what was missing, the order in which inputs are processed, and which observed outcomes were fixed in the tests. In the performance section I separate the actual measured execution times from insights derived from the amount of copying. If you’re interested in the individual fixes, you can start reading from the following sections.

The implementation state described in this article

The reference point for this explanation is the commit merged on October 10 2026, 994f17cc. The main we verified at the time of writing was also this commit. The final head of the PR is 7ad562bf, and the baseline for comparison is e69ee73c; we also cross‑checked the sources at both points for the large files that could not be fully displayed by the API. The links to code and tests in this article are essentially fixed to the commit at the time of merge.

The measured values are based on the main text of the PR (PR #1322), the final comment in the investigation thread (PR #1322), and the reports saved in the related issues. This is not the result of re‑running all browser tests or performance comparisons solely for this article. We avoid mixing together the processing that can be explained from the source code, the reported measurements, and improvement proposals that have not yet been verified.

The missing DOM APIs before the change were documented in Issue #1284, and the page‑level error notifications were organized in Issue #1289. The latter follows a different path from the CDP’s Runtime.exceptionThrown. It is not the same for a client that controls the browser from outside to learn about exceptions as it is for a notification to be sent to the page’s own addEventListener('error', ...). For the page’s monitoring logic to run, the browser must deliver events to the page’s global object. (issue #1284) (issue #1289)

Also, in this context “Boa” refers to the implementation that was integrated into Omoikane’s engine/boa/. We do not intend to present the changes made in Omoikane as if they were the state of any upstream Boa release. While there are connections with earlier issues such as the insufficient root for getter return values (source) and the ephemeron and generational‑GC bugs (source), the problem addressed by Promise.finally this time is a leak of retention for a different value over a different interval.

Why the API additions propagated down to the underlying layer

First, let’s align the terminology. A Realm is a unit of JavaScript execution environment that contains built‑in objects such as global objects, Array, Date, and so on. Realms are separated between inside and outside an iframe, so even constructors with the same name become distinct objects. An origin is a separate boundary that determines which information can be accessed across those realms. In this Realm type, there is an entry point that links this environment with the ownership information on the host side. (source code)

A “wrapper” refers to an object that exposes a Rust‑side native node to JavaScript. Preserving identity means ensuring that the same object is compared as identical in ===, rather than creating a separate object with the same content. A GC root is the starting point from which the garbage collector searches for live values. The fact that a handle remains in Rust’s local storage is distinct from that handle being registered as a root, so the latter part of the discussion examines in detail which intervals retain roots.

The browser’s DOM does not consist solely of a Rust tree structure. The wrappers visible from page JavaScript, the identities of native nodes, their owning Document, their Realm, event listeners, Ranges, MutationObservers, and the reactions of custom elements all overlap. Even if you move the same node within the tree, the observation is not the same as when you remove the node and create a new one. Conversely, even if the same wrapper remains, you cannot arbitrarily reassign its underlying owning Document to a new active Document.

The same considerations apply to exception notifications. By the time an error is received on the Rust side, the original script’s Realm and source location may already have been reconstructed. Simply “dispatching an error event to the current document” can then cause mismatches in the exception’s origin, cross‑origin muted errors, and the Worker’s owner. In the case of Promise rejections, the moment the Promise is rejected and the checkpoint at which it becomes confirmed as unhandled are different. If a handler is attached in between, there may be cases where the notification should not be sent.

The performance fixes were changes that eliminated unnecessary work while preserving the original semantics. You no longer need to clone all children just to obtain the last one. You no longer need to initialize a child Realm merely to read the history URL. You no longer need to keep a processed module’s AST together with its evaluable state indefinitely. However, each of these must be verified to ensure that the retention or creation is truly unnecessary. Eliminating copies can cause needed values to disappear from the garbage collector or change the order in which author getters are called, which could introduce new bugs.

Below, we first explain the DOM and HTML features visible from the page, then proceed to notifications, garbage collection and iframes, followed by performance and verification. The short code snippets placed throughout are examples meant to illustrate behavior unless otherwise noted. When they correspond to the fixtures or regression tests actually used for measurement, we provide direct links to those tests.

Even convenient DOM methods must obey all insertion rules

The starting point on the DOM side was Issue #1284. In the October 7 review, we found that the APIs for ChildNode and ParentNode operations, moveBefore, adoptNode, as well as parse and serialize APIs involving shadow trees, were missing. Adding more named APIs might seem simple, but they also affect string conversions, exceptions, MutationObservers, live Ranges, custom elements, and even the lifetime of iframes. Issue #1284 (issue #1284)

First, we organized the public APIs. Methods on the ParentNode side—such as prepend(), replaceChildren(), moveBefore()—are placed on Element, Document, and DocumentFragment. Methods on the ChildNode side—such as before(), after(), replaceWith()—are placed on Element, CharacterData, and DocumentType. If all of these are added to the base Node, they could be invoked even in locations where they shouldn’t exist. The current placement in the prototype (source code) reflects this distinction directly.

Decide Whether It Is a Node or a String Before Manipulating the Tree

Methods such as before() can accept not only Nodes but also multiple strings. The following code is an explanatory example.

const parent = document.createElement('div');
const marker = document.createElement('span');
parent.append(marker);
marker.before('head', null, undefined);

In this case, three Text nodes are created before the marker. Their contents, in order, are head, null, and undefined. null is not an API that treats “nothing inserted” as a special case. On the other hand, values that already are Nodes are not converted into new Nodes; the same object is simply moved. When using marker.before(existing), code that kept a variable named existing to refer to a Node will continue to point to the same Node before and after the operation.

convertMutationArguments leaves arguments unchanged if they can be identified as proper Nodes, and converts everything else to a DOMString. If you simply use the publicly exposed String() as‑is, a Symbol could be turned into a string. Since converting to a DOMString throws a TypeError for Symbols, toDOMString explicitly checks for that condition.

The timing of the conversion also matters. The argument object contains toString(), which can execute arbitrary JavaScript. If the first argument—a Node—is detached from its original parent first, and the second argument’s string conversion fails, the call will raise an exception, yet only the first Node will have been moved. In this implementation, we first complete all string conversions before proceeding to the step of assembling the Nodes.

Even for elements without a parent, the before, after, and replaceWith operations still perform DOMString conversion; they simply do not insert anything. The detached test calls toString once and confirms that a Symbol triggers a TypeError across the three APIs. detached test

mutationArgumentsNode is responsible for consolidating the already‑converted arguments into Nodes. It converts each string into a Text node in the target Document; if there is only one, it returns that Node. If there are multiple, it creates a DocumentFragment and inserts them in order. Even if the same Node is supplied twice, it does not become two sibling nodes. Because later insertions shift nodes from their previous positions, only a single node remains at the final position.

This point is verified in the sibling‑manipulation test using a.replaceWith(a, 'replacement', a). Rather than copying the same a to create duplicates, the Text node from replacement and the original a node retain their identities and appear side by side. An implementation that first converts to an HTML string and then re‑parses it cannot preserve this identity. (source code)

before and after lose their reference siblings midway

before() inserts before the current node, and after() inserts after it. In a simple description this can be expressed in a single line, but when the current node or an adjacent sibling is supplied as an argument, the “before” or “after” position shifts. For illustration, consider a tree where the parent’s children are ordered a, b, c.

b.before(a, b, 'x');

While consolidating multiple arguments into a fragment, both a and b are detached from their original parent. If you then call insertBefore while still using the original b as the reference node, the operation fails because b is no longer a child of its parent. The insertion point must be determined from the tree before the arguments are consolidated.

mutateSibling gathers the IDs of the Nodes contained in the already‑converted arguments. For before it searches backward from the preceding sibling; for after or replaceWith it searches forward from the following sibling, looking for a sibling that is not among the arguments. Those siblings remain unchanged by the fragment creation of the arguments, so they can be used as markers for the insertion point.

In the case of before, the insertion point is taken as the position immediately after the remaining preceding sibling. If no such sibling exists, the insertion occurs at the parent’s beginning. In the case of after, the remaining following sibling is used directly as the reference point. If the reference node itself is the node being inserted, the insertion is shifted to the next sibling. Without this handling, the operation of inserting a node before itself would break.

The viability regression test (source code) applies b.before(a,b,'x') to a, b, and c and verifies the order a, b, x, c. It then applies b.after(c,b,'y') and checks the positions across the moving arguments. A call with no arguments, b.replaceWith(), removes b. This test targets the preservation of reference positions rather than the basic case of inserting a single string before or after.

replaceWith() also checks, while converting the arguments into fragments, whether the receiver has already been moved from its original parent. If it is still attached to the same parent, it is replaced. If it has already been detached, the argument Nodes are inserted before the remaining reference sibling. Even though the method name contains “replace,” it does not always cause the receiver to be removed a second time.

prepend and replaceChildren are also placed on Document

prepend inserts at the beginning of an Element, DocumentFragment, or Document. However, a Text node cannot be placed directly under a Document; an Element may appear only once, and a DOCTYPE must come before any Element.

ensureAppendValidity checks whether the parent is a type that can have children, whether the node to be inserted is allowed in that context, whether the reference node is truly a child of the parent, and whether the operation would create a cycle. For a fragment, it extracts all of its child nodes and validates them before performing the insertion. This is to avoid a partial failure where inserting only the leading Comment of the fragment succeeds, but the subsequent Element insertion fails.

In the case of a Document, simply counting its current children is insufficient. When moving an existing Element within the Document, that Element is already present in the tree before insertion. replaceChildren() excludes all existing Elements from the set of nodes to be replaced. The implementation removes both the nodes to be inserted and the nodes to be replaced from the current list, constructs the “resulting order” by inserting the new nodes among the remaining children, and then validates that order.

If the resulting order contains two Elements or two DOCTYPEs, the operation fails. A DOCTYPE that follows an Element also fails. Comments and ProcessingInstructions are allowed. The validation is performed on the post‑operation ordering, not on the pre‑insertion count.

In the test of Document operations, we attach the existing root to a Document that has a DOCTYPE using prepend, confirming that this causes a HierarchyRequestError because the element ends up before the DOCTYPE. Even after the failure, the positions of the root and the DOCTYPE remain unchanged. Next, we take an HTML element created in a different Document, attach it with replaceChildren, and make that element the new root. The ownerDocument is also switched to the target document. (source code)

replaceChildren('text') fails when attempting to create a Text directly under the Document. The preceding root remains afterward. This isn’t just a test that checks whether the exception type matches; it also verifies that the failure does not corrupt the existing state.

In Element, replaceChildren(parent) is also rejected because it makes the parent become its own child. The replacement regression (source code) confirms that the original <span> remains and that no MutationRecord is emitted. In a normal replacement, the new <b> and the trailing text are inserted, and the addition and removal are consolidated into a single childList record.

Separate the operation that modifies the tree from the operation that notifies the change

Even if you correctly update the DOM’s native tree, compatibility breaks if the records observed by the page differ. For example, with replaceChildren(), simply deleting ten child nodes and then adding ten new ones does not mean you can just line up twenty records. To represent the replacement indicated by the call, a record that combines the removedNodes and addedNodes is required.

The implementation of replaceChildren first stores the nodes marked for deletion and those marked for insertion. It then suppresses observer notifications to the parent node during internal deletions and insertions, and finally queues a consolidated childList record. In cases where neither deletions nor insertions occur—i.e., a no‑op—it does not generate any unnecessary records. (source code)

However, suppressing the observer is limited to the extent needed to consolidate the replacement records into a single entry. If a node comes from a different parent, its removal from the original parent is also subject to observation. The replaceWith() code likewise treats the removal of an individual replacement from its original parent as an event that can be observed independently. If all notifications are cleared at once, you would also lose the observer records for nodes being tracked in other trees.

The DocumentFragment also records the removal of its own children separately from the insertion into the target location. The fragment‑insertion test (source code) checks the same contract across four paths: appendChild, insertBefore, prepend, and replaceChildren. There are two cases in total: one where the fragment’s a and b are removed, and another where a and b are subsequently inserted into the parent. These are not split into separate cases for “a removed” and “b removed.”

The live range’s boundaries shift when elements are deleted.

The range‑update test selects a parent node that contains two Text nodes using selectNodeContents, then replaces them with a new Text node via replaceChildren. Both ends of the Range move to offset 0 of the parent, causing the Range to become collapsed. It also verifies that calling replaceChildren without arguments can remove the new child as well. (source code)

Internally, the pre‑deletion preRemove notifies the NodeIterator and Range of the tree change. Because it records the position of the removed node and then adjusts the boundaries, invoking it after completing the native‑side deletion causes the loss of the necessary original position. removeChildInternal carries out this update, the native tree modification, the connection‑state change, the custom‑element response, the MutationRecord, and the slot update, in that order. The reason we consolidated the common path for insertions and deletions (source code) is to ensure that even with the new convenience API, this series of steps is not omitted.

Prevent custom elements from reacting to intermediate nodes

replaceChildren() creates an array for reactions and passes that array for each individual deletion or insertion. After completing the overall replacement and generating the record, it prepares the upgrade for the inserted custom element, processes the executable script, and then triggers the reaction with finally. This same sequence is also used by the new API that parses HTML and applies changes in bulk.

There is another advantage to consolidating everything on a common path. For example, when implementing replaceChild() by calling the page‑side components this.removeChild() and this.insertBefore(), overriding those methods will also halt the internal operations. The regression test for the legacy API (source code) confirms that even when the Document’s public methods are replaced with functions that throw exceptions, replaceChild() can still replace the root through native operations.

The existing appendChild also validates inserting a node into a DOCTYPE element, objects with a spurious __id, and fragments that become invalid partway through. Even if you override a regular Node’s nodeType or isConnected with exception getters, the internal checks still use the native state. Test of native branding and bulk validation. (source code)

Even when reducing pre-insertion checks, retain the condition that the item has been inspected.

To detect cycles, we trace upward from the insertion point through each parent, checking whether we encounter the same node as the one being inserted. Because this also includes host elements that lie beyond a shadow root, a simple chain of parentNode references is insufficient. This ancestor traversal was performed repeatedly both at the entry point and within the internal logic.

In ensureAppendValidity, the only candidate for a non‑Fragment is a single node. When we applied the same approach—examining a Fragment together with its children—to non‑Fragments as well, we ended up checking the same node twice. After the change, we treat only the Fragment as a sequence of itself and its children, while a non‑Fragment is represented by a single node. This isn’t a reduction in the number of candidates to check; it simply eliminates duplicate candidates.

Furthermore, on paths where appendChild and insertBefore have completed full pre‑insert validation, we now allow validityChecked to be passed to insertNodeBeforeInternal. This eliminates the need for additional internal cycle checks. However, for internal calls that lack this flag, we retain the original guard. This is not a change that removes all checks simply because the function is internal and therefore presumed safe.

moveBefore is not a shortcut for the usual remove‑and‑insert operation.

When you move an existing node under a different parent using appendChild(), it is detached from its original parent and placed under the new parent. In contrast, moveBefore() is an API that performs a move while preserving the node’s state. Although the visual changes to the tree may look similar, the handling required when the connection is broken differs.

The iframe state‑preservation test saves the contentWindow and contentDocument, places moveMarker={value:42} on the child’s global scope, and sets marker=43 on the body. After the move, it verifies that the wrapper remains unchanged and that both values can be read from the child realm’s eval. See the iframe state‑preservation test (source code)

Reject moves that would lose state immediately

The verification of moveBefore receives a regular Node and a possibly‑null reference Node, and checks whether the required two arguments are present. If a null is passed as the reference, it is moved to the end of the list. If the reference is the node being moved itself, it is adjusted to its next sibling. (source code)

Next, it traverses the chain of parents, including the host, to determine whether the destination node and the node being moved share the same root. If they are in different roots, a HierarchyRequestError is raised. Even if they share the same ownerDocument, a node that has been detached and has no parent does not belong to the same root as the connected tree. This API cannot be used for inserting new nodes.

If the destination is a descendant of the node being moved, the operation is rejected because it would create a cycle. If the reference node is not a child of the destination, a NotFoundError is raised. The types of nodes that may be moved are limited to Element, Text, CDATA, ProcessingInstruction, and Comment. Document and DocumentFragment cannot be moved in the same way. We also examine conditions where a Text node appears directly under a Document, as well as cases where the order of the root element and the DOCTYPE becomes invalid.

The root‑and‑reference test (source code) rejects the operation of moving a detached <span> into a connected parent. After inserting the span into its tree with a normal append, we pass document.body—which is not a child of the parent—as the reference and verify that a NotFoundError is raised. Finally, we use the span itself as the reference and confirm that the position of its sole child does not become corrupted.

Differences between connection callbacks and move callbacks

Custom elements have, in addition to connect and disconnect callbacks, a pathway that handles connectedMoveCallback. If the destination is part of a connected tree, the custom element is traversed and any definition that defines this callback is notified of the move. Definitions that lack the callback have their disconnected and connected callbacks invoked in sequence.

The custom‑element test (source code) uses a definition in which each of the three callbacks appends a log entry. After clearing the initial connection log and moving the element from a to b, only the moved log remains. At the same time, two MutationRecords are generated, appearing in the order: removal from the original parent a, followed by addition to the new parent b.

Even for moves that preserve state, adjust the boundaries of Ranges

updateTraversalForMove stores the old parent and the index. It performs pre‑removal processing on the NodeIterator and the Range, then retrieves the index of the reference node. When moving a node forward within the same parent, the index is adjusted to account for the positions removed by the original location. For a Range whose boundary is the destination parent, the offset after the insertion point is increased by one.

The Range‑move test (source code) creates a Range that selects the Text node inside a child <span> of a, and a collapsed Range that points to the end of the child of b. When the span is moved before the second child of b, the Range that was selecting the span’s contents shifts to the boundary of its original parent a, and the offset at the end of b changes from 2 to 3. To avoid over‑generalizing the notion of “state preservation,” we also want to capture this observation concretely.

A Range associated with a Selection is updated while retaining the same object. The regression test for moves involving selection (source code) selects the span’s selected through the input path, saves its Range, and then moves the span to a different parent. It verifies that getRangeAt(0) matches the saved Range and that the boundary collapses to offset 1 of the original parent.

Focus correction is not determined solely by the state immediately after the move

Moving a focused button into a hidden parent can cause it to lose focusability. However, if the element is blurred immediately during the move, then later moving it back to a visible location within the same operation will generate unnecessary blur and focusout events.

The move operation calls queueMovedFocusFixup after the native move, deferring the focus correction. At the moment of the callback, the activeElement remains the button, and no blur or focusout has occurred yet. The correction performed later uses the state at that moment.

The focus‑correction test (source code) compares three scenarios. If the element remains hidden after the move, after the event loop runs the activeElement becomes the body, and blur and focusout events are emitted. If the element is moved back to a visible parent before the correction, the button retains focus and no events are generated. If focus is transferred to another button, the previous correction does not steal that new focus.

adoptNode changes the ownerDocument without copying

importNode() creates a copy. adoptNode() changes the ownerDocument of the node that is passed in. For illustration, it can be used as follows.

const target = document.implementation.createHTMLDocument('target');
const saved = element;
const adopted = target.adoptNode(element);

The adopted node is the same node as the saved one, but it is detached from its former parent. It does not automatically become part of the target’s body. Changing ownership, altering parent‑child relationships, and creating a copy are each distinct operations.

The entry‑point verification (source code) checks that the receiver is a proper Document. The argument must be a canonical native Node or a value that holds the private state of an Attr; otherwise a TypeError is raised. Passing a Document itself yields a NotSupportedError, and attempting to adopt a shadow root independently results in a HierarchyRequestError.

Adopting a shadow root itself is rejected, but when the host is adopted, the shadow tree’s ownerDocument is also changed. Descendants, attributes, and children of a closed root are subject to this as well.

The identity‑and‑owner test (source code) saves a div, its child Text node, the data‑x attribute, and the closed root together with its Text. After adopting the div, it checks that the returned value is the original div, that its firstChild is the original Text node, and that every node’s ownerDocument is the target. Because the closed root is also part of the target, an implementation that only scans the portion visible through the public shadowRoot getter will fail.

Removal from an old parent follows the normal removal path. The observer on the previous parent receives a single removal record. A Range that was selecting an internal Text node is moved to the removal position within the old parent. The same test also verifies these two points. It is not an operation that merely rewrites the owner’s ID and then finishes.

adoptedCallback and the owner of template content

In the reaction test (source code), when oldDoc is the original document and newDoc is the target, adopted is recorded. If the connection log is cleared before adopting, the log becomes disconnected,adopted. Adopting the same target again does not change the Document, so the reaction count does not increase.

In templates, the ownerDocument of an element and the ownerDocument of inert content are distinguished. The template test (source code) adopts the content fragment itself to the target and verifies that its children also have the target as their owner. Meanwhile, the template element remains attached to the original document, and template.content returns the same fragment that was saved.

getHTML selects the shadow tree to output

Even if you obtain the host’s contents as a string via innerHTML, the shadow tree is not included in the ordinary childNodes. getHTML(), in addition to the output of regular children, emits the selected shadow root as a declarative shadow‑DOM template.

For illustration, consider a host whose light tree contains a Text node and that has a <b> element inside a closed root.

host.getHTML();
host.getHTML({ serializableShadowRoots: true });
host.getHTML({ shadowRoots: [savedClosedRoot] });

The first case returns only the light tree. The second case includes the serializable root. The third case explicitly selects the root owned by the caller. It does not reject an explicit selection simply because the root is closed, nor does it unconditionally output the entire closed root.

The selection test (source code) uses a root that is closed, serializable, clonable, and has delegatesFocus. The ordinary getHTML returns only the light tree, but when a selection is made, the template includes shadowrootmode, shadowrootdelegatesfocus, shadowrootserializable, and shadowrootclonable. The root’s own getHTML returns only its contents.

Preserve the root’s settings and traverse only the selected tree

The native‑side Options (source code) contain a boolean indicating serializability and a set of identities for explicitly selected roots. When serializing an element’s contents, the root’s settings are read and only those roots that meet the criteria are turned into templates. Nested roots are processed with the same Options.

Instead of arranging independent HTML fragments from the list of selected roots, this operation inserts shadow trees into the actual host tree. The nested test (source code) explicitly selects the outer open root and verifies the output where the inner closed, serializable root is selected via a boolean flag.

The settings are not regenerated each time from public JavaScript properties; they are retained in the native DOM. Both declaratively parsed roots and roots created with attachShadow hold the same information. The declarative‑flag test (source code) confirms that the closed, serializable, and clonable flags read from HTML are correctly reflected in the output.

Conversion of iterables is also part of the API’s behavior

shadowRoots accept not only arrays but also any iterable sequence. When the Symbol.iterator is accessed, the getter is invoked, and the number of invocations can be observed from the page. The conversion routine (source code) obtains the iterator method exactly once and calls the stored function with the original receiver. Even when an element is not a ShadowRoot and the conversion fails, it performs an IteratorClose.

The sequence test (source code) uses an object that throws an exception when its iterator getter is read twice. It also checks that the options are read in the order serializable followed by roots. Next, it passes an iterator that returns an invalid document.body and verifies that when a TypeError occurs, the return callback is invoked exactly once.

Distinguishing whether something is an HTML element and whether it is raw text

Text nodes such as &, <, >, and non‑breaking spaces are normally escaped, but the raw text inside script and style elements is emitted unchanged. HTML void elements like <br> do not produce child nodes or closing tags. If you decide solely based on the tag name, even XML elements without a namespace such as script or br would be treated as HTML.

HTML detection is performed by checking the namespace. Only the old native HTML constructor that omits the namespace also uses the HTML brand. In the comparison test, an XML <br> emits both its content and a closing tag, while an XML <script> is escaped. An HTML <br> omits its children, and an HTML <script> is rendered as raw text. HTML detection (source code) Comparison test (source code)

The p:item in the custom namespace retains its prefixed qualified name. Attributes use the xml, xmlns, and xlink notations according to the namespace. The name test confirms that even when an alternate:lang is created in an XML namespace, the output becomes xml:lang. The ampersand (&) in attribute values is escaped.

The handling of noscript’s raw text changes depending on whether scripting is enabled in its ownerDocument. The owner‑specific test inserts the same text into three scenarios: an active Document, an inert Document, and a sandbox that does not allow scripts. In the active case the text remains unchanged, while in the inert and sandbox cases it is escaped.

A noscript inside a template uses the inert owner of the content. The template test checks that this escaping occurs. Because the serializer resolves the owner of each context, any ownership changes made through adoption are also reflected in the output. The template test

setHTMLUnsafe separates the entry point for parsing declarative shadow DOM

When getHTML returns a shadow tree as a template, you may want to convert that string back into a DOM. However, using the traditional innerHTML assignment path treats a declarative shadow‑DOM template as an ordinary template. The newly added setHTMLUnsafe and parseHTMLUnsafe provide an explicit entry point for choosing a parse that creates a shadow root.

For illustration, consider passing the following string to a div:

host.setHTMLUnsafe(
  '<template shadowrootmode="open"><span>inside</span></template>outside'
);

The shadow root contains a span, while the light tree contains a Text node with the value “outside”. This differs from the case where a template remains as the host’s first child. The direct‑host‑parsing test distinguishes these two situations. Even when setHTMLUnsafe is called on a ShadowRoot itself, a declarative root can be created inside a section within that shadow root.

When a host that already has an existing root receives a declarative template in a different mode, the existing root is not automatically replaced. In the same test, the open root created initially remains the same root, while a template later specified as closed becomes an ordinary light‑side template. The DOM’s rule that only one root may be attached is preserved during parsing as well.

The setter on a template replaces its content rather than its childNodes. The same test confirms that template.childNodes stays empty while the content can contain a <p> element. It is necessary to differentiate between the tree into which nodes are inserted and the owner.

Filter the parsed tree before proceeding to registration and replacement

The common setter logic validates the receiver, converts the HTML to a DOMString, and reads the options. It then passes the result to the native fragment parser. After stamping the returned Fragment and the owner of the newly created shadow root, it replaces the target’s contents using replaceChildren.

The Rust‑side fragment parser receives a JavaScript string as a sequence of UTF‑16 code units. If the receiver is a ShadowRoot, the host is used as the parsing context, and the TreeBuilder is asked to perform a fragment parse that allows a shadow root. If a sanitizer is present, the newly created Fragment and the context’s new root are filtered before proceeding to DOM registration and resource preparation.

The fragment’s context matters. Passing if (a < b) {} to a script results in a Text node. In a table, foster‑parented Text nodes move outside the artificial context. The TreeBuilder does not take only the children of the context; it expands the artificial root within the context and returns the preceding and following siblings to the Fragment as well. See the TreeBuilder fragment handling.

The existing innerHTML behavior remains unchanged. The comparison with legacy confirms that when a declarative template is placed inside a section via innerHTML, no shadow root is created and a regular template remains. This is not a change that implicitly switches older APIs to a different parser merely because new APIs have been added.

setHTMLUnsafe on an XML Document also uses the HTML parser. The child <p> is in the HTML namespace, and its owner is the original XML Document. The MIME type does not change. In the regression test that uses the three XML MIME types, the broken nesting of <b> and <i> elements is repaired as HTML, and the XML innerHTML includes the appropriate xmlns attribute. The XML test

The Document created by parseHTMLUnsafe has no original window

Document.parseHTMLUnsafe creates an independent Document. Its URL is about:blank, defaultView and location are null, the content type is text/html, and the children of the root and any declarative shadow tree have the new Document as their owner.

The inert Document test confirms that it differs from the original document, that it has no window, and that scripts are not executed. Moreover, even when a script created during parsing is moved to an active document, it does not run in this test. Assuming that, because of the “Unsafe” name, the API executes scripts immediately upon parsing is a mistake.

document_native performs an inert UTF‑16 parse, filters as needed, and registers the tree and style state using the identity of an independent Document. Because the parse is performed with scripting disabled, the contents of <noscript> are treated as ordinary elements. Test of parse mode verifies that each of the two <p> elements inside <noscript> behaves as expected, that a document without a DOCTYPE is treated as BackCompat, and that the initial value of baseURI is about:blank.

Testing the conditions for script execution and execution order

In this revision, setHTMLUnsafe prepares for execution only when runScripts is explicitly specified. By default, scripts are treated as inert. Document.parseHTMLUnsafe does not use a script‑execution flag. The presence of this option does not allow us to infer how other browsers handle it.

Regression of execution order checks the order by logging the sequence of the options getter, the script, the custom‑element constructor, and the connectedCallback. Even a <div> that appears after the script in the source is added to the DOM before the script runs, so the script can obtain a reference to that <div>. document.currentScript indicates the script that was inserted. The resulting log shows the sequence: option, script, constructor, connected.

Test of scripts in shadow trees confirms that scripts intended for execution also reach nested declarative shadow roots. At the same time, if a preceding script removes a subsequent script, that subsequent script is not executed. Even if an already‑executed script is removed and re‑inserted, it will not be run again. Simply maintaining a list of targets does not allow you to skip checking the connection state or execution status right before execution.

The regression for external scripts checks the default inert behavior, explicit execution, one‑time execution, and load notifications. The restoration of nested currentScript and handling within shadow trees are also verified separately. Another test

The Sanitizer filters the parsed tree before it is attached

Providing a sanitizer to the unsafe entry point, as well as offering safe entry points such as setHTML and parseHTML, is within the scope of this PR. It is not merely a process that strips out <script> tags from a string. The HTML parser builds a tree, examines namespace and attribute records, and also traverses template content and closed shadow trees.

For example, for illustration purposes you can pass a configuration that removes <script> elements, strips a secret attribute, and disallows JavaScript URLs.

host.setHTMLUnsafe(markup, {
  sanitizer: {
    removeElements: ['script'],
    removeAttributes: ['secret'],
    javascriptURLs: false
  }
});

When setHTMLUnsafe is called without specifying a configuration, it does not automatically apply all of these filters. In contrast, setHTML and Document.parseHTML process the configuration on the safe side, removing unsafe elements and attributes based on the effective settings. The setter for the safe script context provides a path that returns without altering the original script content.

Read the configuration, validate it, then modify the tree

The JavaScript‑side bridge (source code) reads the author’s dictionary and sequence and converts them into owned configuration data. The Rust side decodes that JSON into a Config and runs canonicalization and validation. The filter is designed so that it does not invoke the author’s getters, and it maintains a borrow of the active Document without returning to JavaScript.

Configuration validation rejects cases such as specifying both allow lists and remove lists simultaneously, duplicate elements, duplicate attributes, conflicting local attribute settings, and so on. It also disallows designating html, svg, or math as targets for replaceWithChildren. It checks for redundant or invalid configurations, such as granting permission for an attribute globally and then re‑granting it locally.

Test of invalid configurations supplies six kinds of malformed configurations. Each call fails with a TypeError, but the original <b> element remains unchanged and no new declarative shadow root is attached. If the HTML is parsed first, a root is attached to the host, and then the configuration error is detected, this condition cannot be satisfied.

Avoid retaining the object used for the configuration. Even if you later change the element name that was passed to new Sanitizer, or modify the snapshot returned by the getter, the internal configuration remains unchanged. The owned‑state test checks both of these aspects. The API does not use the caller’s mutable object references to verify the validity of the filter.

The Sanitizer also provides methods for modifying its configuration. allowElement and removeElement update the appropriate list depending on whether they are used in the allow or remove mode, and replaceElementWithChildren switches the policy to only remove the outer element. allowAttribute and removeAttribute resolve conflicts and redundancies between global and local specifications. For processing instructions, allow and remove operate on the target. setComments, setDataAttributes, and setJavascriptURLs toggle their respective boolean flags and indicate whether any changes were made. The implementation of the modifiers (source code) returns false when the same value is re‑specified.

The regression for the owned state changes the policy to retain only the children of <b>, removes the class attribute from <i> while preserving its title. The same change to <html> is rejected with false. removeUnsafe updates the settings for unsafe elements, event attributes, and JavaScript URLs; a second call makes no further changes. Because a safe parse applies removeUnsafe to a clone of the configuration, it does not arbitrarily modify the internal settings of a shared Sanitizer.

Use the private configuration without invoking the public getter

Sanitizer instance configurations are stored in a shared WeakMap. Even a properly formed Sanitizer passed from another Realm is identified by its private brand. When the public method sanitizer.get() is called to pass data to the filter, the page can overwrite that method. The internal logic that retrieves the configuration does not go through the public get method.

Regression for conversion replaces the Sanitizer’s get method—normally used to remove scripts—with a function that throws an exception. Even so, when scripts are inserted with runScripts set to true, the scripts are removed and only the element b remains. This shows that the override of the public method does not alter how the internal configuration is retrieved.

The same test also checks that the iterator getter for the configuration sequence is read only once, and that the IteratorClose is triggered when an element name is a Symbol that fails to convert. As with getHTML’s shadowRoots, there is a separate observation point in the Web IDL conversion that occurs before the configuration is handed to the native side.

Templates and the closed root are also passed through the same filter

The filtering implementation (source code) processes Visit and Unwrap tasks on a stack. For permitted elements, attributes are filtered, and in addition to ordinary children, shadow roots and template content are also added to the task. Elements that are to be removed are taken out together with their entire subtree. For elements that are the target of replaceWithChildren, the children are filtered first, then moved to the parent, and the outer element is removed.

The regression test for templates and shadows (source code) includes SVG attributes within a single markup inside closed, serializable roots as well as within ordinary template content. Even with runScripts set to true, the number of script executions remains zero, the secret attribute disappears, and the SVG xlink:href javascript URL is removed. In contrast, ordinary https URLs in href attributes remain.

When comments are stripped, adjacent text nodes are created, but this filter does not automatically normalize them. The adjacent‑text test (source code) confirms that even after removing comments from a <!-- comment --> b, two text nodes—one on the a side and one on the b side—remain. This change does not merge the text nodes just because their output strings are identical.

Separate checks for namespace, ASCII case, and URL

In the configuration, the Name uses the namespace and localName as the key for equality comparison. The prefix is not used as a key. Even if an href in the same xlink namespace is written with a different prefix, the attribute’s meaning is the same. Conversely, if you remove attributes that share only the same localName but belong to different namespaces, you may inadvertently delete unrelated attributes.

Checking for JavaScript URLs targets the pairs of navigating elements and attributes specified as constants, as well as cases such as MathML’s href. The URL parser examines the scheme. For SVG’s animate, animateTransform, and set elements, we also handle cases where attributeName refers to href or xlink:href. The foreign‑attribute test (source code) specifically verifies that viewBox, xml:space, xlink:href, and xmlns:foo are retained, and that attributeName is removed on the safe side.

Case conversion of event‑handler names uses only ASCII. If the general Unicode case‑folding treats the Kelvin sign K as equivalent to k, it could turn an alias such as onKeydown into an executable handler. The regression test for that (source code) shows that, with a safe configuration, onkeydown is excluded while onKeydown remains as a distinct attribute and does not execute even when the event is dispatched. The ordinary ASCII ONKEYDOWN works as a handler when inserted in an unsafe context.

The ordering of the get snapshot also uses UTF‑16. U+10000 and U+E000 have different orderings between Unicode scalar values and UTF‑16 code units. The ordering test (source code) confirms this sequence and that the public name is javascriptURLs. If we rely on the internal serde’s camelCase to produce javascriptUrls, even the API name changes.

The constants are generated from a snapshot of the HTML Standard rather than from WPT’s expectations. The README records the retrieval date, the SHA‑256 of the source, the generation method, and the counts: 122 default elements, 58 global attributes, 8 unsafe elements, and 7 navigating pairs. These constitute the scope of the snapshot for this revision.

Do not round UTF‑16 strings to Rust’s String

When HTML is received from JavaScript, the string is not necessarily composed solely of Unicode scalar values. JavaScript strings are sequences of UTF‑16 code units and can contain isolated high or low surrogates. Rust’s String cannot represent those values directly. Ignoring this difference at the boundary can cause the original value to change after a round‑trip of parsing and serialization.

For illustration, the following four are distinct strings.

'\ud800'    // an unpaired high surrogate
'\ufffd'    // the actual replacement character
'\\uD800'   // a backslash followed by the characters uD800
'😀'         // a valid surrogate pair

If the first value is converted with String::from_utf16_lossy, it becomes the same replacement character as the second one. Once both have been turned into the same Rust String, you can no longer tell which was the original input. Even using JSON’s escaped representation carries the risk of confusing a literal backslash in the string with an actual surrogate.

In this implementation, from the point where the data is handed to the native side we extract the UTF‑16 code‑unit sequence and retain the original units. Values that can be expressed as ordinary scalar strings are stored using the existing Rust String; those that cannot are also kept as a UTF‑16 sequence. This approach connects Text, Comment, ProcessingInstruction, CDATA, attribute values, parse tokens, and serializer output without stopping at just one side of the bridge.

Separating the tokenizer’s state machine from the original code‑point types

InputCodePoint has both a Scalar and a Surrogate. It keeps the character that the state machine compares separate from the original type that came from the input. Even when a surrogate is presented to the state machine as a replacement character, the original unit can still be used when inserting it into the TextBuffer.

The original type is retained even during tokenizer reconsume. The dedicated test places a surrogate (0xD800) next to a scalar (U+FFFD), confirming that although the char visible after consume is the same, the consumed_code_point differs, and that surrogate information remains after reconsume. Tokenizer test

InputDecoder keeps a trailing high surrogate pending. If the next chunk starts with a low surrogate, the two are paired and converted into a scalar. Otherwise, the pending high surrogate is emitted as a standalone surrogate, and the next unit is processed anew. At the end of the input, the pending surrogate is emitted as a standalone surrogate. Calling finish again does not emit the same value twice.

The input module’s test uses a sequence containing 0xD800, <, U+FFFD, a standalone low surrogate, a correct emoji pair, consecutive high surrogates, and the literal \uD800, and splits it into two pushes at every possible split point. Empty chunks are also interleaved between them. Finally, the sequence is restored to its original units to verify that it matches the input exactly. Simply being able to read a single correct character does not reveal bugs that cross stream boundaries.

The path that temporarily extracts pending input and then restores it also treats the data as UTF‑16. When parser input is saved—e.g., during script re‑entry—if it is first converted to a String, any standalone surrogate is lost at that point. The same tokenizer test also checks that, after the save and restore, the original sequence remains intact and that a low surrogate can be added later.

Connecting destinations for data, raw text, attributes, and comments

Even if only plain data is handled, the API cannot be consistent when values change in RCDATA for textarea or title, raw text for style or script, plaintext, attribute values, or comments. The tokenizer’s TextBuffer retains UTF‑16 data owned internally and passes tokens to the tree builder, using scalars where possible and exact units when required. Attributes also have a value_utf16 pathway.

UTF‑16 DOM regression checks createTextNode’s data, textContent, length, and clone. It verifies that the children of a Text node can be split into multiple Element nodes, and that textContent remains correct even when a Comment is interleaved. Even when a standalone high surrogate and a standalone low surrogate are placed in separate nodes, the parent’s textContent is the concatenation of the two code units.

We also examine cloning after changing data from a surrogate to an ordinary character. If only the scalar String is altered and the exact buffer is not cleared, the old value reappears. Attributes are also tested with surrogates, literal escapes, emojis, and ordinary strings in sequence, confirming that the latest value is returned.

The parse regression mixes high surrogates, U+FFFD, low surrogates, emojis, and literal escapes, feeding them to both the Document and fragments. It also checks the six text contexts, SVG, and table foster parenting. Attributes are tested with the three quote formats and the xlink namespace.

Comments and ProcessingInstructions are also round‑tripped through input, data retrieval, deep cloning, and getHTML output. The XMLSerializer and XML’s innerHTML and outerHTML also include CDATA. This test ensures that fixing only the HTML side does not create a gap where the XML serializer would lose the value.

Performing escape processing itself in UTF‑16

HtmlOutput uses a Vec as the output buffer. Scalar strings for tag names and fixed markup are encoded with encode_utf16 and appended. For data and attribute values that originate from the DOM, the units are read, only ASCII characters that need escaping are replaced, and all other units are added unchanged.

For example, if a Text node contains a standalone high surrogate followed by <&>, it is converted to &lt;&amp;&gt; while preserving the surrogate. In raw text, this conversion is not performed. In attributes, &, quotation marks, angle brackets, and non‑breaking spaces are escaped. The angle‑bracket regression confirms that a standalone surrogate in an attribute value and <> are both emitted correctly.

document.write can also observe Text nodes created mid‑call

The write test first writes only the opening <p> tag and a high surrogate. At that point, the p element has a firstChild, and we verify that its data can be observed as a standalone high surrogate. When the next write adds a low surrogate and another high surrogate, the firstChild’s identity remains the same, and its data becomes an emoji followed by a standalone high surrogate.

It is necessary to ensure that the result of the first write can be observed while also allowing the next write to connect them as a proper pair. An implementation that re‑parses and recreates children each time will fail the identity condition even if the values are correct. After a subsequent re‑entrant script adds a <b> element, the same test also confirms that the original units remain in the Text node, attributes, and comments of the following <p>. This checks both preserving the incremental parser’s state and appending to the Text node that has already been committed to the DOM.

Distinguishing ProcessingInstructions from legacy bogus comments

In HTML, <?processing data?> is treated as a ProcessingInstruction by the tokenizer state in the HTML Standard we are targeting. Older HTML parser implementations and fixed WPT tests still expect it to be treated as a bogus comment. This discrepancy actually showed up in the current CI run.

Even for the same string, a Comment has nodeType 8 and its data contains ?processing data?. A ProcessingInstruction has nodeType 7, with target set to “processing” and data to “data”. The same difference appears in the oldValue of a MutationRecord that later rewrites the data.

The tokenizer’s specialized processing (source code) reads the target after <?. The first character must be an ASCII letter or underscore, and subsequent characters may be ASCII letters, digits, hyphens, or underscores. If the delimiter is invalid, it is treated as a bogus comment. The strings “xml” and “xml-stylesheet” are specially rejected regardless of case, and are also treated as bogus comments.

Whitespace following the target is skipped, and the data is stored as a literal. The sequence &amp; inside the data is not expanded as a character reference. It must end with ?> or >, and if the input ends prematurely, an UnexpectedEof error is recorded so that an incomplete instruction is not emitted. The dedicated tests verify targets that start with “9”, targets that contain a colon, XML, xml‑stylesheet, mid‑stream EOF, and all possible write split positions.

The MutationObserver regression test (source code) pairs a parsed instruction with a regular comment and checks the instruction type, target, and data. It then changes the data to CHANGED, applies the same CHANGED again, and marks the operation as DONE via replaceData. The oldValue fields of the three recorded changes are data, CHANGED, and CHANGED, and assignments to the same value are also recorded.

The Sanitizer’s ProcessingInstruction handling distinguishes the case of the target. Even when configured to allow “keep,” the keep is not retained. It also ensures that ampersands in the data are not automatically expanded into HTML, which is verified in the filter test (source code). Not only does it introduce a new node type, but it also links changes to CharacterData, the end of serialization, and the filter’s name‑matching logic.

The fixed WPT test MutationObserver‑characterData.html expected data from an old bogus comment. The PR investigation record (PR #1322) cross‑checked the specification, the fixed‑revision fixtures, and the implementation, distinguishing any discrepancies from expectations. It does not simply rewrite the fixtures or assertions to match the new expectations and treat them as passed. The investigation concluded that a failure in the fixed test should not be hastily labeled as an implementation regression, nor should it be dismissed outright as a regression caused by a specification update.

Do not infer encoding and compatMode from the DOM

The Document’s characterSet, charset, and inputEncoding do not return the string currently written in the meta element. They indicate which encoding was used to decode the input bytes. Changing the meta element’s charset later does not alter the encoding of the already‑decoded document body.

The HTML decoder (source code) returns the decoded text together with the canonical encoding actually used as a DecodedHtml. It prioritizes the charset specified in the transport’s Content‑Type header; if none is present, it selects a candidate from the meta element. If encoding_rs’s decode uses a different encoding because of a BOM, that returned encoding name is recorded.

The TreeBuilder passes that metadata to the Document. It does not later determine the encoding by searching the rendered tree. The encoding test (source code) creates an “é” from a windows‑1252 meta element and the byte 0xE9, and verifies that even when that meta element is rewritten to UTF‑8, the characterSet remains windows‑1252.

Another test confirms that a transport‑specified windows‑1252 takes precedence over a meta‑specified UTF‑8 and that this preference persists in cloned Documents. The decoder test checks that BOMs correctly change the encoding to UTF‑8 and that an ISO‑8859‑1 label is reported using the canonical name windows‑1252.

The JavaScript string passed to Document.parseHTMLUnsafe is not the raw byte stream before decoding. Even if the meta charset is latin2 or an XML declaration specifies an encoding, it is not re‑decoded. The document encoding for a string input is treated as UTF‑8. This does not conflict with the earlier exact UTF‑16 storage. The processing that preserves the unit of DOM strings and the resource‑encoding metadata reported by the Document serve different purposes.

compatMode is determined from the initial DOCTYPE classification

Simply checking whether a DOCTYPE exists—assigning CSS1Compat if it does and BackCompat if it does not—is insufficient. If the DOCTYPE name is not “html”, if the tokenizer forces quirks mode, or if an old public identifier or system identifier triggers full quirks, the mode may differ.

The quirks classification (source code) consolidates the conditions used in the initial insertion mode into a dedicated predicate. It checks for old public‑identifier prefixes, specific system identifiers, and cases such as HTML 4.01 Transitional or Frameset that lack a system identifier. Since limited quirks also return CSS1Compat for compatMode, this predicate targets the conditions for full quirks.

The DOCTYPE test (source code) places a DOCTYPE named “foo”, a public identifier of “HTML”, an HTML 4.01 Transitional without a system identifier, and invalid PUBLIC specifications into the BackCompat category. It verifies that a normal HTML DOCTYPE, an input that contains a second invalid DOCTYPE later, XHTML Transitional, and an HTML 4.01 Transitional with a system identifier are classified as CSS1Compat.

Document type and HTML namespace should be treated as separate native information

Just because an element belongs to the HTML namespace does not necessarily mean its ownerDocument is an HTML Document. Documents of type application/xhtml+xml can also contain elements in the HTML namespace. Even when setHTMLUnsafe is applied to an XML Document, children in the HTML namespace can be created. Conversely, in an HTML Document, calling createElementNS(null, ‘Plain’) creates an element without a namespace.

This time, DocumentMetadata stores the encoding and content type. is_html_document checks whether the native content type is text/html. The conversion of attribute names considers both whether the element is HTML and whether its owner is an HTML Document. The native detection (source code) does not invoke the author’s ownerDocument or the content‑type getter.

The regression for attribute normalization distinguishes HTML, namespace‑less XML, and XHTML. For XHTML, the HTML interface and the namespace are retained, and names and attributes are case‑sensitive. createElement and createElementNS also differentiate prefix/localName, and a colon alone is not enough to infer a prefix. The test suite for attribute normalization can be found at: (source code)

Case conversion of HTML names is performed using only ASCII; it does not employ Unicode‑wide upper‑ or lower‑case mappings. Cloning preserves the localName, prefix, and namespace unchanged. Deep cloning, importing, and adopting XHTML elements that carry UTF‑16 attribute values are also verified, ensuring that neither the case nor the unit information is lost.

Even if the expando for __contentType is altered, the native MIME type and attribute rules remain unchanged. The tests make ownerDocument, contentType, and Symbol.hasInstance into exception getters, and even when the Element prototype is set to null, they confirm that the stored attribute API continues to operate according to native rules.

Implementing document.body with querySelector(‘body’) would also pick up bodies inside the head, bodies in other namespaces, or deeply nested bodies. In practice, you need to search directly among the children of the HTML root in the HTML namespace, looking for body or frameset in order. A prefix may be present, but the localName must be matched exactly in case.

The native body getter (source code) reads the document’s native root and its children. Even if the returned body is a native insertion that has not yet been registered in the wrapper registry, it still passes through the existing owner registration from the query result. It does not queue a MutationRecord merely by reading it.

The test suite for body (source code) compares multiple bodies, the order of framesets, nested bodies, foreign bodies, and bodies without a namespace. In XHTML, h:html and h:body are accepted, while uppercase local names such as BODY or HTML, root elements without a namespace, and SVG roots are excluded.

If a body is replaced via replaceChild from another Document, the old Document’s body becomes null, while the new side returns the same Node. Replacing the root returns the frameset of the new root. Even if the public children or localName are made into exception getters, the native getter still works. Fake Documents or Elements raise a TypeError, whereas a properly formed Document from another Realm is accepted while preserving its identity.

Use the HTML namespace for XHTML’s UA styles as well

The default margin applied to the body in CSS and the hidden attribute should not depend solely on the indication that the element was created with the HTML constructor. Elements created by the XHTML parser also have the HTML namespace, so the same UA rules apply to them. The processing of UA candidates (source code) determines whether the element is in the HTML namespace or is an old native HTML element with an omitted namespace.

If the body’s localName is exactly “body”, an 8 px margin is applied to all four sides as a UA‑origin candidate. XHTML’s x:body is also considered, but XML’s BODY, body in the SVG namespace, body in a custom namespace, and body in namespace‑less XML are excluded. The condition for being in an HTML Document is not the same as the condition for UA styles in the HTML namespace.

For the hidden attribute, everything except “until‑found” uses display: none, while “until‑found” uses content‑visibility: hidden as the candidate. If this is treated as a special forced value in layout, it breaks the cascade—author CSS could overwrite it, or a revert could restore the UA value. Because it is inserted as a UA‑origin rule, ordinary author rules take precedence, and a revert reveals the UA value.

The style tests (source code) verify the margin and hidden behavior of XHTML bodies. The additional regression for hidden compares an empty string, “hidden”, invalid values, and “UNTIL‑FOUND”, confirming that author styles such as display:block or content‑visibility:visible take precedence, and that an inline revert restores the UA value. This verification concerns only the values in the cascade and does not imply that the entire UI functionality related to “until‑found” has been implemented.

Restoring a selection from the rendered combined text back to the original DOM

Finally, the change that ties back to the DOM is mouse selection. In rendering, adjacent Text nodes or Comments may be merged into a single shaped text run. If you insert the index of that merged display string directly into a Range, you cannot determine which original Text node and offset it corresponds to.

For example, suppose there is a Text node containing “Alpha”, followed by a Comment, and then another Text node containing “β😀”. The rendered string appears as “Alphaβ😀”, but there are two distinct Text nodes in the original DOM. The length of the latter Text node is three in UTF‑16. Counting the emoji as a single scalar and treating it as two, or assigning the total length to the offset of the first Text node, would produce an invalid selection.

The test for this selection (source code) sends mousedown, mousemove, and mouseup events to a layouted paragraph via the native input path. It checks that the anchor is at position 0 in the first Text node, the focus is at position 3 in the second Text node, toString yields “Alphaβ😀”, and that the Range intersects both Text nodes. Unlike tests that set the selection directly from the DOM structure, this test ensures that the hit‑test result on the rendered position correctly maps back to the original nodes.

The TextSource implementation retains the original UTF‑16 range even for normalized characters. The mouse‑side native caret hit test returns the node and offset using the current document’s layout and style. This applies not only to the top‑level document but also to child documents, which use their own layout, scroll, and viewport. (source code)

Distinguishing the default behavior of synthetic events from real input

Simply having dispatchEvent(new MouseEvent(...)) on the page does not initiate an actual drag selection. The cancellation and synthetic regression (source code) places the existing Selection at Text offset 2 and verifies that a synthetic mousedown does not change it.

Even with native input, if mousedown’s default action is prevented, the selection remains unchanged. The same holds for right‑button input. The selectstart event is dispatched at the Text boundary and has bubbles, cancelable, and isTrusted flags. If that event’s default action is prevented, the original Selection and its associated Range are preserved unchanged. The selectstart test (source code) checks this condition.

When dragging backward, the anchor and focus are swapped. The ordering of a Range’s start/end and the orientation of a Selection must be tracked separately. The backward regression confirms that, for a Text node labeled “backward,” the anchor offset is 8 and the focus offset is 0.

Do not invoke the author’s Range methods inside the default behavior

If the mouse’s default behavior directly calls the public Range.prototype.setStart or Selection.prototype.setBaseAndExtent methods, the page can override them and halt the internal selection processing. In this regression, those calls are replaced with exception‑handling functions, and the Text node’s dispatchEvent is also overridden. Even with this setup, we verify that native input can still select the entire string.

Range offset validation also uses the length from native CharacterData and the count of native child nodes. The boundary regression (source code) makes the Text node’s length, nodeType, parentNode, and the host’s childNodes for the a😀b node into exception getters. Offsets 1 through 3 are accepted as valid ranges, even when they contain emojis, while offset 5 triggers an IndexSizeError. CDATA is also treated as having the same UTF‑16 boundaries.

Dragging inside a shadow tree handles composed boundaries, while input from an iframe updates only the iframe’s own Selection. The 83 DOM regressions include these selections, Ranges, and cross‑realm cases. The number 83 does not refer to the count of additional method names verified.

Path for the page itself to observe exceptions

In addition to adding DOM manipulations, this PR implements a mechanism for JavaScript running inside the page to observe errors. The initial check in Issue #1289 (issue #1289) confirmed that ErrorEvent, PromiseRejectionEvent, and reportError did not exist, and window.onerror and window.onunhandledrejection were not provided as IDL attributes. The ability to record execution failures on the Rust side is separate from the page being able to receive those failures through its own listeners. Even if an exception is visible from outside, the page lacks the necessary API unless the test harness or monitoring code inside the page receives the notification it is waiting for.

In addition to deciding which global object receives what, we must preserve the callback’s Realm, handle CORS at the time of acquisition, and maintain the worker boundary. A string intended for the console alone cannot reconstruct the notification target or the original value.

The core of the implementation is in src/js/script_errors.rs (source code). Here it connects Boa errors, the document’s identity, the origin of the script fetch, and a private reporter registered with the Realm. The side that constructs DOM events places the reporter in the dom_bootstrap.js reporter (source code). The Rust side detects execution failures, and the JavaScript side notifies via an event interface belonging to that Realm.

Even if a script fails, do not stop subsequent scripts

A script’s exception halts its own evaluation, but following scripts run in a separate execution context. document_script_exception_reports_to_global_and_keeps_following_script (source code) confirms this boundary.

<script>
  globalThis.log = [];
  globalThis.thrown = { message: "script" };
  onerror = (message, filename, line, column, error) => {
    log.push(error === thrown ? "error" : "wrong");
    return true;
  };
  throw thrown;
</script>
<script>
  log.push("next");
</script>

The expected result is error,next. The first script is treated as an exception, a notification is sent to the global object, and then the next script is executed. Because the event error preserves the original value, error === thrown also succeeds.

The script execution pathways in a document include scripts that appear in HTML from the start, scripts such as defer that are evaluated later, and scripts that are added to the DOM dynamically. This PR adds a single private reporter and routes all of these execution entry points to the same notification handling. It also passes the muted information discovered at fetch time for each entry point, avoiding a situation where only synchronous evaluation is fixed while asynchronous evaluation leaks details.

Even after a module evaluation fails, subsequent classic scripts continue to run, as verified by the error_reporting_worker_module.rs test (source code). This test primarily targets the confidentiality of continuations and diagnostics, rather than asserting coverage of every possible module exception notification.

In timers, only the current callback is allowed to fail

In a timer, even if one callback fails, the subsequent task is still executed.

const log = [];
const thrown = { message: "timer" };
addEventListener("error", event => {
  log.push(event.error === thrown ? "error" : "wrong");
  event.preventDefault();
});
setTimeout(() => {
  log.push("timer");
  throw thrown;
}, 0);
setTimeout(() => log.push("next"), 0);

The expected value for [timer_callback_exception_reports_original_value_and_keeps_next_task] is timer,error,next. The exception notification is performed within the first callback’s error handling, and the subsequent task proceeds afterward. isTrusted is also being verified, allowing us to distinguish between events dispatched by the page itself—new ErrorEvent(...)—and events generated by the host due to a failed execution.

On the Rust side, [record_error_from] receives a timer failure and forwards it to script_errors::report_exception. Whether the notification was cancelled is also used when deciding how to forward it to the worker owner later. At the same time, the raw task diagnostics are retained as a separate record. If these two are processed together in the same array under the same conditions, the page’s cancellation of a notification can cause the diagnostics to disappear, or the same worker exception may be sent again through a different path.

There is a limit on the number of raw task diagnostics to prevent unbounded accumulation caused by broken setInterval. Excess entries are recorded as counts, but the execution of subsequent tasks is not halted.

The target of an event listener is not determined solely by the event target

In an event listener, the location where the event was dispatched may not match the Realm that created the callback. A callback created in a child iframe’s Function can be registered on a button in the parent document. When access is possible from the same origin, the following combination can be formed.

const child = document.querySelector("iframe").contentWindow;
const target = document.querySelector("button");
child.addEventListener("error", event => {
  console.log("child", event.error);
  event.preventDefault();
});
target.addEventListener("probe", child.Function("throw 42;"));
target.dispatchEvent(new Event("probe"));

This exception is reported not to the parent global by looking at the button’s owner Document, but to the global belonging to the callback’s Realm. [listener_exception_reports_to_the_callbacks_realm_global] places error listeners on both the parent and the child, confirming that the recorded result is only child.

When Boa returns from the callback invocation, the current Realm may also revert to the caller. To avoid losing the notification target, the callback’s Document is saved before invoking it.

[call_event_listener_native] obtains the listener’s Realm before invoking it. If the target is a function, it uses get_function_realm; if it is a callback object, it uses the associated Realm. It then extracts the Document identity from the host metadata of that Realm and passes the same identity in the notification after a failure. The existence of an entry point called report_exception_for_document is to prevent re‑fetching the Document from the Context at the time the notification processing began.

It also handles cases where the listener is not a function.

const listener = {
  get handleEvent() {
    throw { message: "getter failed" };
  }
};
target.addEventListener("probe", listener);

Failures to retrieve handleEvent are also reported as listener failures. The native continuation that receives the result of the callback carries the Document identity along with the previous window.event; it restores them before reporting when the continuation returns. Both the retrieval and the invocation are handled.

Do not convert a queueMicrotask throw into a Promise rejection

queueMicrotask and Promise reactions enter the same microtask processing order. However, the destination of exceptions thrown inside a callback is not the same. The value thrown by a function passed to a Promise’s then normally becomes the rejection of the Promise returned by that then. Exceptions thrown by a function passed to queueMicrotask are reported as exceptions to the global.

Omoikane’s queueMicrotask originally uses Promise.resolve().then(...) to establish ordering. If the callback is passed directly to then at this point, even exceptions get converted into Promise failures. In this instance, the queueMicrotask wrapper calls the native microtask callback execution function within the reaction, and that function catches ordinary exceptions and reports them.

queueMicrotask(() => {
  log.push("first");
  throw sentinel;
});
Promise.resolve().then(() => log.push("promise"));
queueMicrotask(() => log.push("last"));

[microtask_exception_reports_error_and_preserves_remaining_job_order] records, including the error listener, as first,error,promise,last. Even if an exception occurs in the first microtask, the remaining jobs are not lost. The ordering also differs from the case where error tasks are emitted later in a batch; the notification is issued at the point where the first callback’s failure is handled.

On the native side, [__omoikane_run_microtask_callback] invokes the callback without arguments. For ordinary exceptions, after passing them to report_exception it returns undefined. Consequently, the internal Promise used to enforce queue ordering does not receive the page‑side callback’s exceptions directly.

The runtime limit is not converted into a regular exception report; instead, it is returned to the job executor via Err, so that the host’s interruption is not treated as a success.

What to pass to ErrorEvent

JavaScript can throw, in addition to Error, 42, undefined, and any arbitrary object. The processing that preserves the original value is separated from the processing that creates a display‑friendly message.

[event_interfaces.js] defines the five fields of ErrorEvent. error holds the original JavaScript value. The string conversions for the message and filename are not identical; the message may retain standalone surrogate characters. This distinction also applies across worker boundaries.

Even if the message getter is broken, report the original value

Reading the message property of the thrown object can itself execute page‑side code. If message is a getter, that getter can also throw an exception. The same applies to toString.

const original = {
  get message() { throw 99; },
  toString() { throw 100; }
};
addEventListener("error", event => {
  console.log(event.error === original);
  event.preventDefault();
});
reportError(original);

If we simply treat the failure to create message in this situation as a “failure of the original error notification,” the initial value will never be delivered. exceptionMessage converts error?.message ?? error using the saved intrinsic string; if that conversion throws, it falls back to an empty string. In contrast, the event’s error receives the original value. Even if the message is empty, the original value does not become null.

hostile_error_message_does_not_prevent_reporting_original_value passes this object directly and asserts its identity. An implementation that puts the getter‑thrown 99 or the conversion‑thrown 100 into the event will not work.

When retrieving the message, the getter may be invoked once. Even if it fails, the original value is retained, and during worker transfers the already‑resolved message is reused to avoid re‑execution.

onerror handles five arguments and a true return value

The callback for addEventListener("error", ...) receives a single event. window.onerror historically has a different calling convention, taking five arguments: message, filename, line, column, and error. Moreover, while a typical event handler may treat false as a cancellation signal, in this special case onerror is the one that indicates cancellation.

onerror = function(message, filename, line, column, error) {
  console.log(arguments.length); // 5 on this path
  return true;
};
addEventListener("error", event => {
  console.log(event.error);
});

event_handlers.js’s invokeHandler selects a special invocation by checking whether the error is of type “error,” whether the target is the global object itself, and whether a message field is present. Only when reporting to the global via ErrorEvent does it construct the five arguments. The return value is not merely any truthy value; it is strictly checked against true, and preventDefault is performed accordingly.

In other words, as with worker.onerror = event => ..., you must not pass five arguments to a handler attached to a Worker object on the page. Since a Worker object is not the global itself, what it receives is an event. Also, a plain load‑failure event (Event("error")) lacks a message field. If you were to convert even that event into the five‑argument exception format, the distinction between load failures and runtime exceptions would be lost.

Cancellation is not the same as stopping the remaining event listeners. preventDefault suppresses the default handling, whereas stopImmediatePropagation does not. In report_error_dispatches_synchronously_with_original_value_and_onerror_arguments, even when onerror returns true, the registered error listener is still invoked, confirming that two records are retained. It is important not to reinterpret cancellation as “no one will observe anything after this.”

reportError is not a substitute for throw

reportError(value) is a function that reports a value to the global. It is not a function that takes the value and re‑throws it from the caller. The notification is performed synchronously and normally returns undefined.

const log = [];
addEventListener("error", event => {
  log.push("reported");
  event.preventDefault();
});
log.push("before");
reportError({ message: "explicit" });
log.push("after");
// In the implementation discussed here: before,reported,after

The implementation wraps the passed value in JsError::from_opaque via the native callable of register and forwards it to the standard reporter. Here, “opaque” does not refer to a “message‑collapsed string” but to an error representation that retains the original JavaScript value. On the DOM‑bootstrap side, this native callable is exposed as globalThis.reportError, with the function name matching as well. (source code)

We also distinguish between the no‑argument form and the explicit undefined form. reportError() has no required arguments, so it becomes TypeError. In contrast, reportError(undefined) reports a value of undefined. The Rust side checks for the presence of args.first() in order to preserve this distinction. report_error_requires_an_argument_but_accepts_undefined tests both cases in the same test.

Prevent rewriting the notification path mid‑notification

If the code that dispatches an event to the global is implemented as a property lookup called window.dispatchEvent(event), then merely overriding that function on the page will break the host’s error notification.

window.dispatchEvent = () => {
  throw new Error("author override");
};
reportError(42);

The reporter uses the saved platform constructor and an internal dispatcher. In host_error_reporting_ignores_author_dispatch_override, even after the public dispatchEvent has been replaced, 42 still arrives as a trusted event.

Also, if an error listener itself throws an exception, it could cause an infinite loop of reporting. The guard called reportingEventListenerError detects re‑entry while the reporter is already active. In that case, it does not create additional events recursively; instead, it attempts to log to the console and returns. The guard is cleared via finally, so even if this notification fails, it does not remain blocked until the next independent exception is reported.

Do not infer an exception’s location from Error object properties

Extracting the filename, line, or column from an Error’s properties cannot handle arbitrary thrown values or getters. Since throw 42 also has an execution location, we use engine information.

The addition we made to Boa, JsError::source_location, searches the engine’s backtrace for the nearest JavaScript bytecode frame and maps the program counter to a source map. It then returns the resolved source path together with a reference line and column. It does not read the Error properties on the page.

There are also cases where the location is missing, so the return value is set to Option. On the Omoikane side, if no location is available, the row and column are set to zero, and the corresponding Document URL is used for the filename. The base URL used for resolving the URL and the document’s own URL are kept separate. Even when <base href="https://other.example/"> is present, the filename of a timer exception created inside the document should not be changed to point to an external base URL. timer_error_filename_uses_document_url_instead_of_base_element verifies that a Document URL with a fragment is preserved.

Preserve the throw location, not the time the callback is invoked

The callback is called after the initial script evaluation has finished. If the caller’s source at that instant is used as the location, the timer’s throw may appear to occur on the line of the internal script that handles the timer, or it may only show the call site of later(). Regression tests separate the normal throw case, the case that reads an undefined identifier (ReferenceError), and callbacks that are called directly from Rust.

In the test that reads missing_identifier; after two blank lines, the location is reported as line 3, column 1, and for a throw inside a timer callback it reports line 3. The VM’s execution position is used instead of the error’s property.

In inline scripts, parsing the extracted JavaScript starting from line 1 causes the loss of the line numbers relative to the whole HTML document. The current Source::with_start_position provides an entry point where the embedder can supply the start position. In a test where the script’s initial position is set to line 30, column 7, the callback is compiled, and a throw occurs on the second line of that callback, the location is reported as line 31.

inline_script_timer_error_keeps_absolute_html_line_and_document_url also includes a spurious <script> inside an HTML comment. If a simple string search is used to locate the script’s start position, it will fail here. We use the start position that corresponds to the actual script node, confirming that even timers that run later retain the absolute line number in the HTML and the Document URL.

The location corresponds to the compiled source. The external source‑map process that maps a bundle back to the original TypeScript is not affected by this change.

We have also corrected not only the entry point that reads the location but also the internal engine logic that builds the source map. When expressions become deeply nested, it is necessary to revert to the outer location after the inner expression finishes. Before the fix, the range at the end of a child scope and the start position of the child scope as a whole were treated identically, causing overlapping ranges in cases where a grandchild scope existed, which could result in losing the throw’s location.

SourceMapBuilder now keeps the overall start of the scope separate from the currently open entry. When a child scope closes, the parent’s range is closed at the child’s overall start position, and the parent’s position resumes from the child’s end position. In a test where three scopes start at the same program counter, we verify that PC 0 corresponds to the innermost scope, PC 5 to the next outer scope, and PC 10 to the outermost scope. In a test where the start PCs are offset to 0, 2, and 4, we also verify that the ranges before and after each do not overlap. If the loss of the throw location stems from the way source maps are built, merely adding extra properties to the reporter will not fix it.

Preserve the script’s origin before unwinding

Even for callbacks registered by scripts fetched without CORS, the original origin of the script must be retained. This requirement remains in place even after the script’s initial evaluation has completed.

Boa has added Script::parse_with_host_defined. During parsing, the script’s own host metadata is passed, allowing functions created from that script to be retrieved as the active script even when they are invoked later. Omoikane places ClassicScriptMetadata { muted_errors } there.

However, when a callback’s execution terminates with an exception and returns to the host side, the active script may already have been detached. Therefore, handle_throw snapshots the script’s diagnostic metadata into JsError when the backtrace is first attached. report_exception_for_document prioritizes this snapshot, falling back to the current active script if none is available.

If a snapshot tightly owns the script itself, merely preserving the error could retain a large graph that includes the Realm and the DOM. ScriptErrorMetadata places immutable host diagnostic information into Arc<dyn Any + Send + Sync>. We impose a restriction that Boa’s thread‑local GC handles are not included in the payload, retaining only the provenance needed for the error.

We verify that the metadata of the saved error can still be read even after the script and Context are dropped and a forced garbage collection is performed. Conversely, the cycle that reconstructs the error from its metadata is reclaimed after dropping the external owner. We also verify that retaining diagnostics does not become a permanent root for the entire script.

How to report classic scripts fetched without CORS

Exceptions from external scripts are not all presented to the page with identical details. In the muted‑errors path, exceptions from classic scripts fetched cross‑origin without CORS are reported as message = "Script error.", with an empty filename, line and column set to zero, and error = null. Uncaught rejections from the same script are excluded from reporting.

Determine whether to mute based on the fetch response type

fetch_classic_source returns not only the source code string but also the effective URL, the number of redirects, and a boolean indicating whether it is muted. If the crossorigin attribute is present, CORS mode is chosen; otherwise, no‑CORS mode is used. The credentials are also distinguished based on use-credentials, other crossorigin values, or the absence of an attribute.

If the response type resulting from this fetch is Opaque, it is treated as muted. The origin used for comparison is derived from the Document’s immutable security origin. Changing the resource’s base URL or setting <base> does not alter the Document’s security origin. The effective URL is taken from the response after redirects, and the URL passed to the source’s location information is also aligned with it.

The regression test uses a loopback HTTP fixture, placing the page and the script at different origins. On the script side, an exception is thrown after Promise.reject('private rejection'). The page side monitors both error and unhandledrejection events, asserting that the result is only Script error.||0|0|true. The final true is event.error === null. If a private reason leaks from the rejection event, an additional record is created, so this assertion cannot hide it.

In contrast, a script that includes crossorigin but lacks CORS permission is not executed, being treated as a fetch failure. cross_origin_cors_script_without_permission_does_not_execute verifies that the global property embedded in the source has not been created. It does not automatically downgrade a CORS failure to no‑CORS and execute it.

Even for the same URL, the provenance of the callback differs

Simply recording “this script is muted” using the URL as a key will be overwritten when the same URL is fetched with different settings. same_script_url_keeps_distinct_cors_metadata_in_retained_callbacks loads the same same.js once without any attribute and once with the crossorigin="anonymous" attribute.

Both returned sources add a callback to an array that later rejects the Promise. The fixture also records the request headers for both requests. It confirms that the initial no‑CORS request lacks an Origin header, while the subsequent CORS request includes the page’s Origin. The response includes CORS permissions, and when the two callbacks are called after the initial evaluation, only one rejection notification is emitted.

Each script record carries its own metadata. The earlier callback retains the provenance that it is muted, while the later callback retains the provenance that it is CORS‑permitted. They are not merged into a single entry in the URL cache.

The paths after the initial evaluation are also tested separately. Exceptions thrown by timers, event listeners, microtasks, defer, and dynamically added scripts all arrive in a muted form. The same provenance is applied to reportError. However, whether muting is applied to reportError is still subject to browser differences, as noted in the current HTML specification (documentation). What is being described here is Omoikane’s choice and its regression tests, and it does not claim to be a universally consistent behavior across all browsers.

In the Promise tracker, both the rejection side and the handling side suppress actions based on the muted status of the currently executing script. A function that belongs to a muted script may later attach catch to a Promise created by a visible script. If one decides solely from the URL where the Promise was created without looking at the script that performed the operation, this boundary can be misinterpreted. The test at the end of the script_errors.rs file (source code) performs both a rejection and a handling using the stored muted callback, confirming that the handled notification from the latter is not missed.

Synchronous exceptions from importScripts are also tracked by their provenance

The Worker’s importScripts executes the imported classic script synchronously. In normal same‑origin paths, if that script throws its original value, the caller’s try/catch receives the same value. It is not an API that uniformly converts such exceptions into asynchronous owner events.

worker_imports.rs first converts all arguments to strings, then evaluates them in order while owning the retrieved source. Because each source is parsed with the previously attached muted metadata, even if the imported script later creates a callback, its provenance is retained.

Conversely, a value thrown by a muted imported script is not passed directly to the caller of importScripts. The native side returns a false indicating failure, and the Worker’s wrapper creates a NetworkError. This prevents a gap where only ErrorEvents are hidden while a private value could be extracted via a synchronous catch. The runtime limit is also propagated unchanged at this point.

When importing via a data URL, the identity of the original sentinel is verified. For HTTP non‑CORS imports, it is confirmed that the stored callback’s rejection is not reported and that the private throw value cannot be extracted.

Separating the Worker’s interior from the side that created the Worker

In a Worker, exceptions are first reported to the WorkerGlobalScope itself. If an exception from a dedicated Worker is not canceled there, it is sent to the Worker object in the creator’s context. If that event is also not canceled, it proceeds to the creator’s global scope. When the exception reaches the page, the actual object that was thrown inside the Worker is not passed.

Sending a runtime error directly to the owner bypasses any cancellation that occurred inside the Worker. If an exception always causes the Worker to terminate, any registered listeners would be lost, so the startup stage and cancellation are handled separately.

Parse failures and SyntaxErrors thrown during execution are distinct

evaluate_worker_initial_script separates parsing from evaluation and returns Completed, ParseFailure, and Exception(JsError). The host’s runtime limit is not included in these three; instead, it is returned as an outer Err.

// The source itself cannot be parsed
postMessage("must not execute");
const = ;

// The source parses but throws during execution
onmessage = event => postMessage(event.data);
throw new SyntaxError("runtime syntax");

The former fails during grammar parsing, so even the initial postMessage is not executed. A trusted plain Event("error") is sent to the owner, and ErrorEvent is not used. This event is not cancelable, and even if preventDefault is called, defaultPrevented remains false. This load‑failure path is also used when fetch or script loading conditions fail.

The latter is a normal runtime exception. Since the message listener has already been registered, the message loop remains active even after the exception is reported. The test at worker_runtime_thrown_syntax_error_keeps_the_message_loop asserts both the cancelable ErrorEvent runtime syntax and the reply to the sent alive. An implementation that infers a parse failure solely from error.name === "SyntaxError" cannot distinguish between these two cases.

Report ordinary startup exceptions, return to Some(()), and retain the event loop of any registered listeners. This does not restart the initial script after the throw. Parsing failures and host aborts are treated as termination conditions.

Only the finalized UTF-16 fields should be handed over to the owner.

If you directly push the Error object from a Worker onto another runtime’s task queue, the object—and its associated Realm—will persist across the boundary until the Realm’s lifetime ends. In this case, the WorkerErrorReport only retains the owned message and filename (JsString) as well as the line and column numbers. The owner‑side ErrorEvent.error should be changed to null.

The message must remain in UTF-16. If the lone surrogate in String.fromCharCode(0xd800) is coerced into a Rust UTF‑8 String, the original single code unit may be replaced with the replacement character.

[worker_owner_receives_original_utf16_message_without_repeating_getter] checks both the number of times the message getter has been invoked and this code unit. The getter inside the Worker is called only once, and the owner receives a slice of length 1 whose first element is 55296, along with the original filename. Implementations that read the getter a second time when sending to the owner, as well as those that attempt to coerce the data to UTF‑8, also fail.

Before dispatch, we take a snapshot of the finalized message and use it for forwarding. Even if a listener modifies the event’s message, the original value is sent. In contrast, defaultPrevented reads the result after dispatch. This separates the determination of the content from the decision on whether to forward it.

Proceed with the uncancelled report to the owner’s global.

[report_worker_owner_error] invokes the reporter registered in the owner’s Realm, first targeting the Worker object. If that event is not canceled, it reports to the owner’s global using the same snapshot. If the owner is another dedicated worker, this flow is passed up to the higher‑level owner.

This transfer is not handled by a normal DOM‑tree bubble. Events for the Worker object and global events are created separately during internal processing. The regression test verifies that when preventDefault is invoked on the Worker side, the event does not reach the global scope, and that, unless it is cancelled, the original field does reach the global scope. Additionally, we confirm that exceptions thrown by a Worker created by a child iframe are delivered to that iframe’s global context and do not propagate directly to the top‑level global.

Both the constructor and the dispatcher should use the platform interface saved by the owner Realm. Even if the page replaces ErrorEvent, EventTarget.prototype.dispatchEvent, and worker.dispatchEvent entirely, the host’s reports will still be delivered via the original interface. Additionally, events created inside an iframe remain associated with the iframe’s ErrorEvent.

At startup there is a gap in which the JavaScript Worker object is bound only after the native runtime has been created. Even if reportError(...); close(); runs during that interval, it does not discard the reports that have already been placed in the queue. Task::WorkerError becomes a form that can have an owner and a Realm, and bind_worker_error_owner later attaches a recipient to unbound tasks. The fact that the runtime’s registry entry has been finalized is separate from the ability to deliver the already‑created notification task. worker_error_queued_before_startup_close_reaches_its_owner verifies that, in this order, the original message is delivered exactly once.

Do not propagate runtime exceptions from a SharedWorker to all its owners.

In this implementation, runtime exceptions from SharedWorker are reported in the SharedWorkerGlobalScope and are not forwarded to the onerror SharedWorker object on the page. This is handled separately from load failures in fetch or parse. shared_worker_startup_exception_uses_global_handler_and_keeps_connect_listener (source code) verifies that the throw from the initial script reaches the global five‑argument onerror handler, that the subsequent connect and port responses also occur, and that the owner side reports zero errors.

For runtime exceptions that are not cancelled, record diagnostics such as SHARED_WORKER_STARTUP_FAILED and SHARED_WORKER_RUNTIME_FAILED in addition to the page‑specific events. Rather than copying the page’s message or source directly into the diagnostic database, log them using category, code, and stage. error_reporting_worker_module.rs tests throws originating from explicit reporting, microtasks, timers, and message listeners, verifying that global cancellations are respected and that secret strings are not recorded.

At this point, the SharedWorker is implemented to pump the registry—located on the same Boa thread—between the owner tasks. We also test that multiple connections do not cause the timer’s clock to advance twice. This is not a verification of concurrent execution on separate threads.

Unprocessed rejections are not notified at the moment of rejection.

When a Promise is rejected and unhandledrejection is emitted at that instant, even the regular code that later attaches catch—whether immediately afterward or within the same microtask checkpoint—gets reported as unhandled. In this PR, we separated the stage where Boa’s HostPromiseRejectionTracker records the reject and handle events, the stage where the checkpoint extracts candidate notifications, and the stage where the DOM manipulation task dispatches the event.

const promise = Promise.reject(123);
queueMicrotask(() => promise.catch(() => {}));

In this case, the notification count becomes zero. rejection_handled_in_same_checkpoint_never_notifies monitors and checks both unhandled and handled cases. Rather than reporting an unhandled item once and then canceling it, it removes any pending rejection before issuing the notification.

Three stages: pending, queued, outstanding

In the JavaScript side at Promise reporter, there are pending Map, queued WeakSet where a notification task has been created, and outstanding WeakSet that have been notified but not yet processed. A pending entry holds a Promise and a reason, and upon receiving a definitive notification from the checkpoint, the task is enqueued and then cleared.

Even before a task runs, we check whether the Promise has already been handled. For example, if the toggle task for details is triggered first and attaches catch, any rejection‑notification task that had already been created will not emit anything. The record at details_dom_task_can_handle_rejection_before_notification_task will only contain toggle. We do not decide that a report must be made simply because a checkpoint has been passed.

The task creates a cancelable PromiseRejectionEvent("unhandledrejection"). The Promise and reason passed into the event retain their original values; they are not separate copied objects. After the notification, if it is still unhandled, it is placed in the outstanding queue. Only when a catch handler is attached thereafter is a task for the non‑cancelable rejectionhandled queued.

This “after‑notification” effect only occurs when you attach a catch handler directly on the unhandledrejection listener itself. While the listener is running, the promise is still outstanding, but by the time execution returns from the dispatch, it has already been marked as handled, so it does not become unhandled. As a result, only unhandled appears, and rejectionhandled does not follow. Cancellation and being marked as handled are separate cases. Simply calling preventDefault does not count as having attached a catch to the promise.

Using only run_jobs does not trigger an event, while run_until_idle results in an unhandled exception. Adding a catch block later makes it handled. The ordering with respect to the document’s readiness is also asserted as interactive,rejection,complete,load.

When handling, do not search the entire array.

This notification pathway had not only correctness issues but also performance problems. According to the investigation record up to the merge (PR #1322), the intermediate implementation scanned the entire array for each Promise handle notification, and the CI load caused timeouts to become apparent. When n rejections are accumulated and handled sequentially, searching for roughly n candidates each time causes the total work to grow quadratically.

In the final implementation of PromiseReports, each report is assigned a sequence number, and the Promise marked as handled is recorded with HashMap<JsObject, u64>. The cutoff saved during handling is the sequence number to be used next. When reading a rejected report at a checkpoint, if that report’s sequence number is smaller than the cutoff of the same Promise, the notification is suppressed.

Sequence 0: Rejection of Promise A
Sequence 1: Rejection of Promise B
            Record A's handle. Cutoff is 2
Sequence 2: A's handle report
Sequence 3: Another rejection report for A

In this arrangement for illustration, only sequence 0 of A is suppressed with a cutoff of 2, while sequence 3 is left unsuppressed. Simply using the “set of handled promises” that is cleared up to the later report cannot represent the order of recorded operations. The unit test named handled_cutoff_suppresses_only_earlier_rejections creates reject reports before and after the same object and directly asserts this boundary.

This is not an explanation that a single Promise can be settled multiple times in ordinary JavaScript. It is a test that ensures the internal data structure called the notification log does not excessively discard later entries because of earlier operations. Updating the per‑handle hash map is O(1) on average, while determining a checkpoint requires reading the report once, which is O(n). Rather than eliminating memory usage entirely, we consolidated the repeated full‑scan work into the checkpoint.

The JavaScript reporter call required by Handle should be made at the moment the operation occurs. If the promise has already been notified, this is done so the handled‑notification task can be queued before any subsequent page‑side tasks. That report should be marked with delivered, and it must not be called twice from the checkpoint. This is not merely an optimization that simply postpones all Handle processing to the next checkpoint.

Protect the values moved to the checkpoint from being garbage‑collected within the callback.

On the Rust side, flush extracts the pending and cutoff maps from HostState using std::mem::take. After extraction, those Promises are no longer reachable from HostState’s trace. Even though references remain in Rust’s Vec, this alone does not necessarily make them roots in Boa’s garbage collector.

If the initial reporter callback performs allocations or triggers a forced garbage collection, the second Promise or reporter that has not yet been notified may be reclaimed. To prevent this, we introduce a checkpoint—using PromiseCheckpoint and RootProvider—and register the checkpoint, placed at a stable address via a Box, as an external root during the notification process. We then trace the pending Promise, the keys in the cutoff map, and the reporter callback. (source code)

Since guard is declared after Box, when the scope ends we first unregister the root and then drop the Box. While the callback is executing, we read the checkpoint’s contents without moving the address of the registered target. checkpoint_roots_later_promises_across_reporter_collection creates two rejections, and even when force collect is performed inside the first reporter, it verifies that first,second is properly assembled.

Tracking Realm Ownership and Document Retirement

Each reporter that receives notifications has a callback and a Realm associated with each Document identity. If a child iframe’s Promise is rejected, it notifies the child global and does not propagate to the parent global. Conversely, even when a catch is placed on the parent side, any unnotified rejection of the same Promise must still be suppressed. The cutoff map uses the Promise identity as its key precisely to handle this cross‑boundary case.

When a Document is retired, retire_document removes its reporter and any pending entries. However, if a catch attached to the retiring Document was suppressing a rejection from another valid Document, deleting everything uniformly up to the cutoff would cause the notification to reappear. Only the cutoff that affects the remaining reject reports should be rebuilt, and the unnecessary Promise should be released.

Even if the map’s set, delete, clear, or forEach methods—or its iterators—are replaced with functions that throw, the notification handling still works using the stored intrinsic. Management of notification candidates is not delegated to property look‑ups on the page side.

Host execution interruptions should not be turned into exceptions thrown by the page

Boa’s RuntimeLimit represents a host execution interruption, which is different from a value thrown by JavaScript. If you convert it to an Error object and notify, the page’s error listeners will also run, which is undesirable in a situation where you want to abort.

dispatch_exception_report checks for the runtime limit before converting the error into an opaque value and returns it unchanged. The same distinction is maintained in the listener’s handleEvent getter, the callback body, microtasks, and at each entry point for Worker startup and task execution. In the regression tests for event listeners, microtasks, and Promises, we cap the loop limit at 16 to prevent infinite loops, verify that the page’s error count and catch records are zero, and confirm that the next independent 2 + 3 equals 5.

Even inside a Promise, before converting a throw into a rejection we first check whether it is catchable. In the handling of Promise reactions and thenables, converting a host abort into a rejection causes any subsequent catch to execute, allowing work that exceeds the limit to continue as an ordinary asynchronous failure. In both the synchronous and asynchronous job executors, we test the then callback, the then getter, the executor, Promise.try, and the iterators of each combinator, and we also assert that an ordinary throw will reject using the original sentinel as a control case. (source code)

When an abort occurs, any native continuation that was supposed to be executed after the call must also be discarded. We record the stack depth at the start of the initial evaluation and truncate to that depth when it ends. If the continuation of a previously failed callback is executed in a later, unrelated script, error notifications and the restoration of window.event will be applied to the wrong context. The test confirms that after an abort, an old continuation does not run in an independent function, whereas a newly invoked bridge works correctly.

SIGSEGV of Promise.finally on macOS

The reason this PR took so long was not only performance‑related but also due to a bug. On macOS Release Gate 5, execution that passed through Promise.finally resulted in a SIGSEGV. This is not a failure that would simply disappear by speeding up the whole system; it involved using a value that was invisible to the garbage collector partway through. The final comment (PR #1322) records that, with the same test bytes before the fix, the assertion that a Promise remained pending and the signal 11 were reproduced.

As with the earlier Array.from() retention leak and the incorrect collection of getter return values, merely having a value in Rust’s local scope does not make it a root for Boa’s garbage collector. In this case, the target was the intermediate Promise inside finally and the function passed to it.

finally does not simply return the original value

First, let’s verify what was actually being created. For illustration, consider the following code:

const original = { answer: 42 };
Promise.resolve(original)
  .finally(() => Promise.resolve())
  .then(value => console.log(value === original));

The callback of finally is a cleanup routine that is invoked whether the original Promise is fulfilled or rejected. If the callback returns a normal value or a fulfilled Promise, the value passed downstream is not the cleanup’s return value but the original value or the original rejection reason. In the example above, the final then receives original.

If the cleanup throws, or the Promise it returns is rejected, the downstream result also changes. Therefore, the Boa implementation creates separate closures that wait for the cleanup to finish—one for the fulfilled case and one for the rejected case. (source code)

In the fulfilled case, it calls onFinally without arguments and obtains the return value result. That return value is passed to PromiseResolve of the constructor C. Next, it creates valueThunk, which simply returns the original value, obtains the intermediate Promise then, and invokes it. The rejected side follows the same sequence, and the final small function becomes thrower, which throws the original reason.

Original Promise settles
  → Call onFinally
  → PromiseResolve(C, callback return value)
  → Create a function that returns the original value, or a function that throws the original reason.
  → Obtain the then handler of the intermediate Promise.
  → Pass the created function to then.

A thunk is a function that returns the original value after the cleanup has completed. In the rejected case, it throws the original rejection reason.

The gap after receiving the intermediate Promise

In the fulfilled branch before the fix, the return value of PromiseResolve was stored in a local variable promise, after which a thunk was created with FunctionObjectBuilder. This builder allocates GC‑managed functions and captures. Because allocation occurs, a minor collection may be triggered if the nursery’s threshold is exceeded.

PromiseResolve returns the intermediate Promise.
  → Receive it as a local variable named “promise” in Rust.
  → Allocate to create a thunk.
     → Garbage collection may occur.
  → Use promise.then.

If the intermediate Promise is not strongly referenced anywhere else and only this local edge remains, the garbage collector cannot recognize it as a live value. If it were collected outright, the next time then is accessed, it would end up touching a Promise that has already been freed. Since the GC may not run at that exact moment, the issue passes unnoticed under normal execution, making it hard to detect based on ordinary success alone.

In the changed part (source code), we explicitly root it at the point where it is received from PromiseResolve. This is done in the same way for both the fulfilled and rejected paths.

let promise = Self::promise_resolve(&captures.c, result, context)?.root();

This root covers the thunk creation and the acquisition and invocation of then, and it is released when the scope ends. It is not a change that stops the garbage collector; rather, it explicitly marks the Promise as alive during internal processing.

User code also runs while reading then

Even if we root only the intermediate Promise, there is still a gap. After the thunk has been created, the function is converted into JsValue and placed as an argument, forming the interval where the intermediate Promise is invoke("then", ...).

then is not always a fixed built‑in function. You can define a custom then getter on a Promise instance. invoke first retrieves a property and then invokes its value as a callable. If the getter runs, it can trigger the garbage collector. Before calling the retrieved function, if the thunk passed as an argument has become invisible to the GC, there is a chance it will be collected at that point.

The fix avoids consuming the rooted value itself in value_thunk.into() or thrower.into(); instead, it keeps the root and passes a clone of JsObject contained within it as the argument. Calls for the fulfilled and rejected paths take the following form.

promise.invoke(
    js_string!("then"),
    &[JsObject::clone(&*value_thunk).into()],
    context,
)

The clone retained here serves to pass the GC edge to the argument while keeping the rooted thunk alive until the call completes. Its purpose is not to duplicate large data.

Test that splits the GC insertion point into two parts

The added regression test (source code) is not merely a load test that floods finally with many Promises. It targets separately the points where an intermediate Promise could be reclaimed and where a thunk could be reclaimed. Moreover, it uses Promise.resolve and Promise.reject for each case. The setup checks four conditions: both the fulfilled and rejected paths for each of the two boundaries.

In the first finally_intermediate_promise_survives_thunk_allocation_collection, a native callback cleanupReturningPromise is registered. This callback first performs a collection, then creates the intermediate Promise under test. Next, within NoGcScope, 1,025 disposable allocations each containing 4,096 bytes of padding are created. The payload alone exceeds the usual 4 MiB nursery threshold.

No collection is performed inside NoGcScope. Even when the scope exits, the drop itself does not trigger a collection; it simply returns to an executable state. The callback returns the Promise it created, and the identity path of PromiseResolve returns the same Promise instance from its constructor without performing any GC allocation. Consequently, the next allocation occurs when creating the thunk for finally.

The padding also includes a finalizer counter. The test checks that this counter reaches at least 1,025, so a “success by chance because the GC never ran” is not counted as a passing condition. Afterwards, it verifies that the cleanup callback is invoked exactly once and that the final result is either fulfilled:42 or rejected:42. The original value is an object, and === original also checks its identity. Simply returning a different object with the same content does not satisfy the test.

In the second finally_thunk_survives_collection_in_then_getter, a custom then getter is defined on the intermediate Promise returned by the callback. That getter calls the native forceCollect and returns the original Promise.prototype.then. After the thunk has been created, this test triggers a garbage‑collection moment when invoke obtains then.

Here we verify that exactly one garbage collection occurs and that both the cleanup callback and the getter are each called once, after which we examine the identity of the original value or the rejection reason. This allows us to detect semantic bugs such as an original fulfilled Promise turning into rejected, the rejection reason being swapped, or the callback being invoked twice. We do not consider merely avoiding a crash as sufficient success.

It was also necessary to explicitly indicate the launched process in LLDB

The auxiliary tool for gathering macOS diagnostics has also been modified. scripts/gate5_lldb_capture.py launches the recorded WPT executable from LLDB and saves the stacks, registers, and loaded images of all threads at the moment it stops.

The script command that runs outside LLDB may retain the execution context before launch. After launch, we create lldb.SBExecutionContext(process) from the returned process, pass it to HandleCommand, and align the targets of the diagnostic command.

The auxiliary records the binary’s SHA‑256, argv, current working directory, launch flags, target triple, PID, and state, and also checks that the binary has not changed. This is a diagnostic performed in a separate process and does not constitute an execution that replaces the original Gate 5 failure with a success.

How long to retain the AST after linking a module

It is necessary for JavaScript to retain old documents and realms. We cannot arbitrarily delete those generations to reduce memory. This time we re‑examined the lifetime of data associated with each generation.

One of the items we revisited was the AST of Boa’s source‑text module. Because the bootstrap is executed as a module, each realm parses a large module, links it, and evaluates it. We considered whether it is necessary to keep the syntax tree alive as long as the module record remains. The change in ModuleCode (source code) replaces the always‑present source with RefCell<Option<boa_ast::Module>>, which is derived from boa_ast::Module.

The original source text and the syntax tree serve different purposes

The AST is the syntax tree produced by parsing, and it is required for generating the environment during linking and for compilation. After linking, execution uses the compiled code together with the environment. Meanwhile, the source text is retained for purposes such as Function.prototype.toString() and for locating exceptions. In this case, what we discard is the tree; we keep the source_text, the import/export table, and the executable code.

Discarding the tree immediately after a single module’s compilation finishes is too early

The tricky part is the moment when the AST is removed. If module A imports module B and B also imports A, creating a cycle, we cannot simply treat “right after a module’s environment has been created” as the success boundary.

// a.js
import { missing } from "b.js";
export function answer() { return 42; }

// b.js
import { answer } from "a.js";
export function present() { return answer(); }

With this input, even if B’s environment can be prepared first, A’s missing import cannot be resolved. This does not count as a successful link for the entire cyclic group. If we look only at B and decide “the AST can be discarded because it’s already compiled,” then A will later fail, and when the whole group is rolled back to Unlinked, B will lose the tree it needs for the next attempt.

In the implementation, we extract and drop the tree at the boundary where a strongly connected component (SCC) of a cyclic module group is committed. The comment in the link‑commit section (source code) states that SCCs that have not yet succeeded retain their AST for retry. PreLinked represents the stage where preparation is complete, which is not the same as Linked, the stage where the entire group’s success has been confirmed.

When linking fails, the stack entries Linking and PreLinked are rolled back to Unlinked. We release resources by distinguishing between having prepared each individual module and having successfully linked the entire SCC.

The regression test for cyclic link failures (source code) registers an input equivalent to the A/B case above as MapModuleLoader. The load itself is considered fulfilled. This is not a failure caused by being unable to retrieve a file, but a failure that occurs during linking when trying to import an export that does not exist.

The first a.link results in a SyntaxError, and we verify that the error message contains missing. Afterwards we force a minor GC and a major GC, then link again. The expected result is the same SyntaxError message as before. If the AST had been discarded earlier, the second attempt would lack the tree needed for compilation, leaving room for a panic before returning the same semantic error.

If an independent dependency can be committed first, its AST may be discarded. The tree of a failed parent module should be retained so that the same error can be reported on the next link attempt. Re‑linking a dependency that has already been committed does not require re‑requesting the discarded tree.

Inspecting observations that remain even after the tree is discarded

In the regression test suite after a successful link (source code), we first verify internally that a tree exists after parsing and that no tree remains after linking. Then we insert a collection, evaluate the code, and register the resulting namespace into JavaScript’s global object. After unregistering the loader and removing the Rust‑side module variables, we also check whether the exported functions and values remain usable.

The input includes bump(delta = 1), which updates the module‑scope count; meta, which returns import.meta; load, which dynamically imports the same dependency; fail, which throws an exception; a string containing \uD800; and a top‑level await. bump, when called consecutively, yields 41 and 42, confirming that the closure retains the module’s binding. name is bump, and because the default parameter is used, length is 0; toString() matches the original function’s string.

import.meta, when called twice, returns the same object, and the marker written to it remains. The dynamic import returns the same namespace, and the dependency’s value is 42. The unpaired surrogate retains charCodeAt(0) === 0xD800. The Error thrown by fail() retains the filename linked-source.js that was set and the position at line 5.

In the test for successfully linked cyclic modules, we also verify that a mutable binding exported by A can be read through B, and that after A’s function updates it, B’s read reflects the change. This shows that merely copying the exported value once is insufficient; we must confirm that the live binding continues to be correctly referenced from the compiled environment. Even after the AST has been discarded, we must still be able to observe the change from 42 to 43.

In cases where loading fails and a dependency is still missing, we keep the AST, add the missing dependency to the loader, and reload; after the link succeeds, we then discard the tree. Not only can linking be retried, but there are also inputs that progress from a load‑stage failure onward. Changes that shorten the tree’s lifetime involve not only specifying where it can be discarded but also explicitly noting where it still cannot be discarded.

Which ephemerons to examine during minor GC

The background is explained in the article on WeakMap and generational GC (source). An ephemeron that retains its value only when its key is reachable has a condition where scanning the value can reveal that another key is still alive, so processing must continue until a fixed point is reached.

Minor GC targets young allocations in the nursery, while older keys are handled conservatively. For young keys, we check whether they have become reachable during this marking phase.

The existing mark_minor (source code) uses the root, RootProvider, remembered set, and WeakMap registry as seeds to process the strong queue and unresolved ephemerons. This framework has been retained unchanged from the previous implementation. This time, we have narrowed the set of candidates that can be added to that queue.

Old ephemerons whose value is ()

A WeakMap’s value can reference an arbitrary graph. In contrast, some ephemerons used to check the liveness of weak handles have a value of (). These cases involve only weakly tracking the key, with no child edges attached to the conditionally retained payload.

Therefore, the newly added needs_minor_trace (source code) proceeds as before, sending the ephemeron to minor‑GC processing if the ephemeron itself is young or if its value type is not (). If the ephemeron itself is old and its value type is exactly (), we also read the key’s generation. When the key points to an older object, there is no new edge to expose in the nursery, so such ephemerons are not considered candidates for minor‑GC processing.

The ephemeron itself is young → process it.
The type of value is not () (unit) → process it.
old ephemeron + () + young key → process it.
old ephemeron + () + old key → can be omitted in this minor.

What we skip here is the queue processing for minor collections. In the ephemeron queue of Tracer, if the mode is Minor and needs_minor_trace is false, we continue. Old allocations remain in the major heap. This does not mean we have eliminated the opportunity to determine a key’s liveness during a major collection.

Even an old ephemeron requires minor processing if it is retargeted to a young key. We do not skip cases where the value contains a graph, nor when the ephemeron itself is young.

Do not skip tracing just because the type is zero‑sized

The check uses TypeId::of::<V>() == TypeId::of::<()>(), not size_of::<V>() == 0. Rust’s zero‑sized types can have custom Trace implementations. Having a size of zero is not the same as doing nothing during tracing.

old_non_unit_zero_sized_ephemeron_preserves_its_custom_trace stores a zero‑sized ZeroSizedTrace as its value. When Trace is called, it increments a thread‑local counter. After promoting the key and ephemeron to the old generation, we perform a minor collection and verify that the counter has increased. If we skip this step simply because it has the same size as (), the test will fail.

In addition, there is a regression test for combinations of generations link. An old key and an old unit ephemeron can still be upgraded after a minor collection, but if you remove the key’s root and perform a major collection, they can no longer be upgraded. Even though a minor collection may have omitted them, a weak handle must not promote the key to a major root.

For a pair consisting of a young unit ephemeron and an old key, we verify that after several minor collections the ephemeron allocation remains and that the key can still be accessed. If the root that strongly holds the key is removed and a major collection is performed, the entry disappears. When an old unit ephemeron is retargeted to a dead young key, we make it unupgradable during the minor collection, and then confirm that retargeting it again to live young key restores its usability. The design does not permanently discard candidates solely by looking at the header of an old allocation.

After a finalizer, the original candidate cannot be reused

Another concern is the retargeting performed by a finalizer during a collection. Even if the first mark identifies an old unit ephemeron that points to an old key, a later finalizer may reattach it to a young key. The conditions that caused it to be skipped initially are not necessarily the same up to the second mark.

The added second‑mark test link first creates an old key, a parent, and a weak handle. Then it creates a young target and a young value held by that target, and passes them to an unreachable finalizer. The finalizer retargets the weak handle to the target. Depending on the conditions, it may also write a strong edge from the parent to the target, thereby reviving it.

For the revival case, the second mark must discover and keep the new young key and its value. For the non‑revival case, we must not retain the young key solely because of the retargeted weak edge. Both scenarios are handled by the same helper. We separate writes to the weak handle from writes to strong edges, and we forbid survival based only on the weak‑handle write.

Before reading the pointer, we check whether the target and value are present in the collector’s allocation set. In the liveness case we then perform an upgrade and read the value 99; in the reclamation case we verify that, without following the pointer, the upgrade is impossible.

Even for a target that has been revived, if we later remove the parent’s edge and perform a major collection, the target and its value disappear. The value rescued by the second mark is not turned into a permanent root. It is retained only because a strong reference was added during a minor collection; once that reference is gone, it is reclaimed normally.

Do not clone the value when checking the existence of a WeakMap entry

There are also optimizations for the WeakMap that appear to make the code smaller. The Rust‑side contains_key was previously implemented as get(k).is_some(). Since get is an API that extracts the value, it clones the value for the return value. Even calls that only need to know whether an entry exists were causing a value clone.

The revised contains_key link computes a hash from the key, searches the table for an entry with the same key, and simply checks whether the ephemeron holds a value. Key comparison is based on object identity. Because the value is not retrieved, cloning the value is unnecessary for existence checks.

The cleanup of expired entries was also changed from eph.value().is_some() to EphemeronEdge::has_value. Cleanup does not involve using the value’s contents; it merely removes an entry from the table when its key has been lost and the entry becomes invalid, so there is no need to provide an owned value. Even when checking the liveness of a registry during a GC, instead of upgrading the weak handle to an owned handle, we simply consult is_upgradable() to see whether it is permissible. The change on the WeakMap‑registry side link follows the same principle.

The regression test for clone counts link inserts a value called PresenceCloneSpy. When Clone is called, it increments an atomic counter. After confirming that the entry exists for an alias key identical to the original key but does not exist for another key that also has the same numeric value 7, we verify that the clone count is zero.

Even if the original key is dropped and collected, the entry remains as long as the alias is rooted, and the existence check does not clone the value. When the value is actually retrieved via get, it is cloned once. This change does not eliminate the owned return value of get; rather, it removes the unnecessary clone that contains_key introduced, which is reflected in the counter.

The cleanup test also inserts both a live key and a dead key, drops the dead side, and performs a minor collection. The number of entries drops to one, and the clone count remains at zero. It only increments once when the live side is marked as get, and it does not increase again during the subsequent major cleanup. Removing the live key empties the entry list, and if the map itself is removed and collected, it disappears from the registry. We have also investigated that this change does not involve retaining dead entries for the sake of speed.

Adding a young entry to an old WeakMap

When discarding old unit ephemerons, the combination of the WeakMap registry’s tracker also becomes relevant. The registry is a host‑side list and is not accessed through the same entry point as an ordinary strong heap edge. Therefore, mark_minor explicitly seeds the backing ephemeron of the live WeakMap.

In the tracker regression test (source code), after promoting the map and its first key to the old generation, a new young key and young value are added. In that minor collection the young value remains, get returns 42, and the old entry also returns 11, which is verified. We must not overlook new entries simply because the tracker itself has become old.

When a young key is dropped and the next minor collection runs, its value is reclaimed and the number of entries decreases by one. When an old key is dropped and a major collection is performed, the old value also disappears and the map becomes empty. When the map itself is dropped and a major collection is performed, the WeakMap registry also disappears. Because the root of the unit ephemeron that the registry was holding is removed after the sweep, we also verify that the tracker’s allocation itself is reclaimed during the next major collection.

Do not recreate the DOM bootstrap strings per Realm

Before reducing the GC’s workload, there were already cases where the same large input was being generated repeatedly. Omoikane’s DOM wrappers and event interfaces are initialized by combining Rust host bindings with JavaScript bootstrap code. The investigation notes for this PR (PR #1322) report that about 1.3 MB of DOM bootstrap was being transformed and copied for each Realm.

Each Realm’s constructor and global object are initialized independently. However, because the bootstrap source generated from the same set of bindings is identical, only the strings can be shared.

Because they are private bindings, the original strings cannot be used as‑is

Host capabilities are not exposed to the page global; instead they are passed to a trusted bootstrap module via import.meta.__omoikane_private_bindings. At the beginning of the module, they are destructured and the private property is removed.

Existing JavaScript files also contain references to host functions in the form globalThis.__omoikane_.... As‑is, those host functions that have been moved to private lexical bindings cannot be accessed. Therefore, the string‑assembly routine (source code) rewrites any occurrences that match a registered host‑binding name to use the corresponding lexical‑binding name within the module.

Registered instances of globalThis.__omoikane_... are replaced with their lexical‑binding names, and the old delete statements are changed to void 0. Unregistered names are left unchanged.

Do not make the key consist only of the count of names or the type of Realm

The type of the cache is OnceLock<Mutex<HashMap<Vec<String>, Arc<String>>>>. The key is the full ordered sequence of binding names.

static BOOTSTRAP_MODULE_SOURCES:
    OnceLock<Mutex<HashMap<Vec<String>, Arc<String>>>> = OnceLock::new();

This is not a cache where only two entries—“for main” and “for iframe”—need to be provided. There are differences among the bindings for main, iframe, and auxiliary contexts, and future subsystems may add additional bindings. Using only the count as the key can cause mismatches between sets that have the same size but different names. Omitting parts of the set by passing only a subset to the hash can also lead to reusing a source that lacks the required names.

BootstrapBindings::value does not allow duplicate registrations of names. Even if the value of an existing name is updated, the name is not emitted twice in the source header. When reusing, it clones an Arc rather than a large String.

However, this does not share compiled modules, ASTs, or Realms. evaluate_dom_bootstrap parses the obtained source bytes in its context and then loads, links, and evaluates them. The work of creating each Realm’s environment and constructor still remains. This should be understood as a change that eliminates source generation/modification and the copying of large strings.

Reduce the double buffering of the source builder

There is also a modification to the process that creates the initial source stored in the cache. Previously, the header was written to source, the result of rewriting the common body was accumulated into a separate String called body, and finally the entire body was appended to the source. For each cache miss, the large transformed body had to be copied again from that separate buffer.

After the change, the rewritten body is appended directly to the source that already contains the header. source.reserve(DOM_BOOTSTRAP.len()) first reserves enough capacity for the body, then the untransformed sections and binding names are added in order. This eliminates the step of first assembling the massive body and then copying it wholesale into another String.

On the other hand, when examining the suffix to replace the old delete statement with void 0, you must not look at the header portion. Instead, retain body_start and check whether source[body_start..] ends with delete . The change that consolidates the buffers should not extend the scope of processing beyond the part that previously handled only the body. Even by eliminating a single copy, we must ensure that the original processing’s scope is correctly aligned.

Even with parallel initialization, do not return an unfinished source

When multiple runtimes create Realms in parallel, they may request the same source key simultaneously. OnceLock initializes the cache itself once, and Mutex performs the map lookup, builds the source if necessary, and inserts it—all within the same lock. Thus, while the first thread is still assembling the source, other threads cannot retrieve an incomplete String.

The final comment on the PR also records verification of parallel initialization. The cache shares the source, but the parsing and initialization for each Realm still remain.

After evaluation, we verify that the binding names are not present as own properties of the page global. We also remove the module record that was temporarily registered with the loader and the host‑state binding object. The source cache does not retain the binding objects of individual Documents.

Creating child Realms simply by reading the history URL

Even if the source is shared, as long as there is a lingering path that creates an unnecessary Realm, the work for parsing, compiling, and generating constructors continues. One such case was the history of an iframe snapshot. When creating a WindowProxy for the first time, or when preserving history before moving from an old Document, what is needed is the committed URL and the history information. However, to read that URL we created a Document wrapper, and we even ran the child Realm’s bootstrap as part of that wrapper’s ownership.

Operations that need a wrapper versus those that can be handled with native state alone

The native DOM, URL, and origin are also required for scriptless children, but there is no need to immediately create JavaScript constructors or global objects.

The contentWindow.closed value and the Location’s href can be answered from native state. Bootstrapping the child’s Date or Document constructors just for this purpose triggers a large initialization even for children that will be discarded immediately.

Modified captureHistoryEntry first looks for an existing wrapper in cachedNode. If it is same‑origin and that wrapper has an event state and a private history URL, it uses them. If not, it falls back to the native committed Document URL. The wrapNode that creates a new wrapper is not invoked solely to obtain the URL.

Checking the private history URL first is done to preserve the URL that has changed in pushState or replaceState. If only the original source URL is used, the URL of history entries that moved while staying in the same Document will be lost. On the other hand, directly invoking the document.URL getter exposed to the author runs unnecessary code if the author has replaced that property. The values needed for the internal snapshot are retrieved from either the private history state or the native state.

In the test that does not read the public URL getter (source code), a getter is defined on childDocument.URL. That getter increments a counter and then throws. Even after obtaining contentWindow and recording the history, the counter remains at zero. This not only shows that the getter is no longer called by accident due to optimization, but also confirms that the internal snapshot does not depend on the author’s getter.

Verifying that even the internal state proceeds lazily

iframe_history_snapshot_does_not_initialize_a_scriptless_realm reads contentWindow and closed, then checks the host‑state entry iframe_documents to confirm that every entry’s realm is None. It performs the same verification after reading the Location.

After that, it assigns a data URL to the Location and then modifies srcdoc. We verify that, even when preparing to retire an old scriptless child or when taking a snapshot of a new history generation, no departing Realm is created. Simply having the correct value does not tell us whether the optimization is actually taking effect. By also inspecting the internal state realm.is_none(), we can directly confirm that the same observation is obtained with far less initialization.

Finally, we explicitly access child.document. This operation requires a Document wrapper and the child Realm that provides its prototype, so we create that Realm there. We verify Object.getPrototypeOf(child.document) === child.Document.prototype and child.document.defaultView === child, and confirm that the host‑state Realm is also Some. The lazy approach is not a change that discards even the necessary components.

For opaque‑sandbox children, even if the URL and history can be retained on the native side, it does not mean the Document may be exposed to the parent. Test for opaque children shows that contentWindow.closed can be read while contentDocument is null, indicating that the Realm has not yet been created. Attempting to read child.document raises a SecurityError. The distinction between being lazy and eliminating the origin boundary is maintained.

Separating the search for an existing Realm from the initialization process

Even on the native side, a lookup that does not create a new Realm is now required. iframe_global_native refers only to the existing iframe Document and Realm when its second argument is false. It does not start a new load or ensure_iframe_realm. Likewise, existing_realm_for_document is a path used to check whether an existing owned Realm is present during a resource event.

Additionally, we provide a same‑origin‑only argument for the existing Document lookup. When traversing descendants during cleanup preparation, we look only at children that have already been loaded, and we wrap only those that are accessible from the parent. We do not start new communications for cleanup, nor do we wrap cross‑origin Documents and pass them to the parent Realm.

WindowProxy identity and retired Window identity

During navigation, the WindowProxy’s identity is preserved while the Document and the backing Window’s generation are changed. On detach, the context is closed; on reconnect, a new context and proxy are created.

Do not retain a strong reference to an old Window through a live proxy

getActiveWindow, when a Realm exists, holds its global in IntrinsicWeakRef. On the next property read the proxy checks whether the weak reference is still alive; if so, it uses it, otherwise it re‑fetches the value from the current native state. When the navigation generation changes, it switches to the state of the new backing Window. Maintaining a stable proxy is not the same as strongly retaining every past Window.

Before detaching, the final Window is strongly retained on the retained side so that it can be read from the saved same‑origin proxy. The weak reference used by the live proxy and the retention after retirement are kept separate.

In the detach and reconnect test (source code), the Document, WindowProxy, custom element registry, localStorage, sessionStorage, and History are first saved. After the iframe is detached, the element’s contentDocument becomes null, and the saved proxy is marked as closed. The proxy’s document is the same as the original Document, and the object identities of the registry, Storage, and History also match the saved ones.

However, just because the old History is the same object does not mean it remains usable for operations. oldHistory.length results in a SecurityError. The History API requires operations on a fully active Document, and this restriction also applies to read operations. The native activeness check (source code) targets length, state, scroll restoration, their updates, push/replace state, and go. It separates the preservation of object identity from the permissions to manipulate the browsing context.

When reconnecting, the WindowProxy obtained from the element is a different instance from the previous proxy, and the Document is also a separate one. The new proxy is not closed, and the history length becomes 1 for the new context. The old proxy remains closed and continues to hold the original Document. The defaultView of the old Document becomes null. In this test, reusing the same DOM element is not considered reuse of the browsing context.

Do not confuse the listener placeholder with the Window

To enable addEventListener without creating a Realm for a scriptless iframe, the proxy also includes a placeholder that holds only the listener list. This object is auxiliary state created on the parent side and is not the actual Window global of the child Realm.

The placeholder is an object belonging to the creator, not a child Window. If it is used as the target for forwarding, the parent Realm could be mistaken for the child Realm, potentially breaking even opaque‑origin rejection.

New retained_window_global_native temporarily enters the associated Realm of the passed object and retrieves that Realm’s actual global object. It then verifies that the passed object and the global share the same identity. A listener placeholder will fail this check.

Furthermore, it extracts an immutable Document ID from the Realm’s host‑defined data, returns it to the caller’s Realm, and then performs a same‑origin check. If the origin determination is made while still inside the child Realm, the caller being inspected will also be treated as the child. Even checks invoked from a WindowProxy trap must retain the author’s actual caller.

A Realm also retains a snapshot of its origin at creation time. Even after native metadata has been reclaimed by a function or wrapper that still holds a reference to the Realm, it does not borrow the current Document’s origin, nor does it interpret missing metadata as a same‑origin condition.

Move listeners to the actual Window when needed

Listeners registered on the proxy before a Realm is created must be transferred to the actual Window to receive the event after the Realm is created. The listener transfer for getActiveWindow (source code) moves the entries for each placeholder type from the placeholder into the live Window’s listener list.

This preserves the fact that listeners could be registered on the proxy beforehand, while ensuring that listeners later registered by the child script on the global end up attached to the same target. If a Realm already exists, synchronize with getActiveWindow(false) before dispatching pagehide on the departing page. If no Realm exists, do not bootstrap a child Realm solely for that event notification.

The fallback for departure also looks for an existing wrapper instead of creating a new Document wrapper. When an actual Realm is present, the native side dispatches the lifecycle event on that Realm. In the case of a scriptless child that has only a wrapper, the notification can be sent to that wrapper. If neither exists, a large global is created for the notification, and the process avoids the path that would discard it immediately.

Internal WeakMap lookups use captured intrinsic methods, so even if the author replaces a prototype, a different code path is not invoked. The referencing of existing state and its creation are also kept separate.

Test that retains a lazy Window before detaching it

The regression test for a retired lazy Window (source code) sets up a normal same‑origin child, a allow-same-origin sandbox child, and an opaque‑sandbox child. Initially, only the WindowProxy is saved, and it is verified that all Realms are still None.

After that, the iframe is detached, and for the two same‑origin cases the Document is retrieved from the saved Window, preserving the old node and text. A marker is attached to the node, the process is allowed to run until idle, and after performing two garbage‑collection cycles it is confirmed that the old Document, parent node, text, and expando remain usable unchanged. The contentWindow and contentDocument properties of the iframe element become null, and even though the saved Window is closed, the retained DOM relationships persist.

For the opaque third case, even when closed, document is rejected with a SecurityError. The origin boundary must not be erased by detach. Likewise, the Realm intrinsics test (source code) confirms that after committing a navigation to a data URL, the child’s Date cannot be read, and that it remains inaccessible even after detach. In contrast, the oldDate constructor that was obtained while same‑origin remains valid later, and new oldDate(123).getTime() evaluates to 123.

Even if the ownerDocument of an adopted node changes, the creation Realm of the wrapper does not change. The regression test (source code) checks the new owner and the marker, and also verifies that the old Document can still be retrieved via the constructor.

The garbage collection is performed after the microtask checkpoint. Pending Promise reactions are also part of the Realm’s root, so they are not treated as leaks if they remain before the checkpoint. Retaining nodes of active Documents is a separate outstanding issue.

The limited fallback for the cross‑origin WindowProxy’s then and the two well‑known symbols remains in Issue #1326. This PR does not resolve all specification differences. (issue #1326)

Fetching on every read of pending navigation

Another major change concerning iframes is the separation between the currently active Document and the navigation that will be committed. Previously, when refreshing the WindowProxy’s state, the process would re‑fetch the Document that corresponds to the current src. If its attributes had changed, that would trigger a synchronous fetch and parse.

There are cases where a script repeatedly rewrites the URL, as shown below.

const child = frame.contentWindow;
child.location.href = "href.html";
frame.src = "/frame/assign-base.html";
child.location.assign("assign.html");
frame.src = "/frame/replace-base.html";
child.location.replace("replace.html");
frame.src = "/frame/final.html";

Separating Location display from Document commit

The native state lookup after the change (source code) uses the active Document of an existing iframe entry, if one exists. It does not fetch a new src simply because there is a queued resource navigation. If the initial Document does not yet exist, it is created, but once an active Document has been established, a resource task or an explicit contentDocument read performs the commit.

The native state returns, in addition to access, context ID, and generation, a pending‑resource flag, separating the current Document from the queued task.

On the JavaScript side, pendingLocationURL is kept separately. The href returns the latest pending URL, but the native active Document and its URL are not changed until a commit occurs.

An explicit contentDocument read performs a synchronous commit when necessary. What has been omitted this time are the intermediate commits caused by checking the proxy’s state or preparing navigation.

Even after six rewrites, fetches occur only at the start and the end

superseded_iframe_location_navigations_fetch_only_the_committed_resource records the actual path received by the local HTTP server. The initial iframe points to /frame/child.html, and the parent Document resides at /caller/parent.html. The test script reads the child Document once to create a Realm, then proceeds through a path where an existing Realm is present, rewriting the URL six times.

During the script, Location is read repeatedly. What is returned includes the href resolved from the parent’s caller base, the target of assign/replace, the URL set directly on the frame (frame.src), and the final URL. If Location begins to return an old URL, it is not considered a success even if the number of fetches has decreased.

Immediately after the script finishes, the host state shows that the Document’s URL remains the initial /frame/child.html, and there is only one HTTP request. After advancing the resource task at run_until_idle(), the active Document becomes a different identity, and its URL changes to /frame/final.html. The list of requests then consists of just the initial and final entries.

The record showing a reduction from seven to two (PR #1322) corresponds to this case of six rewrites within the same script plus the initial URL. It does not constitute a change that collapses everything into two when, for example, contentDocument is read midway or a commit is performed in a separate task.

Returning to the active URL does not change the generation

There are cases where a pending navigation ultimately returns to the active resource. In the test, after committing the final Document, the same script changes the URL to /frame/discarded.html once, then switches back to /frame/final.html. The resource task is already queued, but when it is executed, the target to fetch ends up being the same as the current Document.

In this scenario no new Document is created and no additional requests are made. Therefore, merely waiting for the generation to change and clearing the pending state is insufficient. Without a new generation arriving, pendingHistoryAction and pendingLocationURL remain lingering.

WindowProxy’s refresh clears the pending history action and the Location URL even if the generation has not changed, provided there is a pending history action and the native side’s pending‑resource flag has become false. The completion of a queued task as a no‑op also serves as a boundary that resolves the pending state.

After that, a fragment navigation is performed that attaches #settled to the same Document. This does not require fetching a new Document. The test confirms that the Document’s identity remains the final one, only the native URL acquires a fragment, and the number of requests does not increase beyond two. If a pending state remains, there is a possibility that a fragment‑only change could be treated as a new resource navigation, so the check extends to operations immediately after the no‑op.

Even if intermediate navigations within the same script are consolidated, the Documents of navigations that were actually committed in separate tasks must not be discarded together. The history test for direct src navigation (source code) sets /a.html and advances the task, then sets /b.html and advances the task again. Even without reading the WindowProxy’s properties in between, it records three entries—initial, A, and B—and verifies that navigating back returns to A.

Reloads and traversals require swapping the Document even when the current resource attributes are identical. Consequently, the native side maintains a set of forced navigations. Simply comparing resource attributes must not treat reload() or history.go(0) as a no‑op. The context ID is preserved, the WindowProxy remains the same, and the backing Document and Window state are refreshed.

The process of reassigning multiple pushState entries that belong to the same Document to the newly restored generation via traversal also remains. State entry restoration test creates A’s state‑one and state‑two, then moves to B, and restores A’s generation with back. Even after returning to the adjacent same‑document entry, the Document’s identity does not change and the state becomes 1.

If only the selected entry is placed in the new generation, the neighboring same‑document entry gets reloaded. Instead, the entire set of entries from the old generation is reassigned.

The reference bases for Location and History differ

When the parent is /caller/parent.html and the child is /frame/child.html, navigation via Location from the parent uses the caller as the base, whereas the child’s History state URL uses the target Document as the base.

iframe_location_navigation_resolves_relative_to_caller_document incorporates this difference into a single input. The Location’s href, assign, and replace point to /caller/, while the History’s state URL becomes /frame/. When partitioning into the pending state, using the currently active URL as the base for all operations breaks this expectation.

In about:blank and about:srcdoc, the creator’s base is used. Nested Documents use the effective base of their owner and do not borrow unrelated top‑level URLs.

When preparing to discard a parent, do not fetch the child to be discarded

The parent iframe’s Document contains another child iframe. If the child’s navigation remains pending while the parent switches to a different Document, the child’s browsing context is discarded. In this scenario, the recursive cleanup scan that prepares for discarding reads the child’s contentDocument, which ends up fetching the resource that is about to be discarded in order to cancel it.

discarding_parent_iframe_does_not_fetch_a_pending_child_navigation sets up separate local HTTP servers for the outer and inner contexts, recording all requests in the same vector. The outer’s initial document contains an inner iframe; the inner’s initial document is loaded, and location.href = '/inner/pending.html' is executed within that realm.

At this point, there are only two requests: the outer initial and the inner initial. Next, the outer’s src is changed to final, and the outer’s contentDocument is explicitly read and committed. The expected list therefore consists of just three items: the two original ones plus the outer final. The inner pending request is not fetched.

The revised cleanup preparation looks only for existing Documents via native lookup, and reuses the existing wrapper if it is accessible. Native teardown cancels pending resource tasks, as well as Document‑owned timers and animation frames. Not initiating a new load when examining descendants is a prerequisite for cancellation.

Reducing copies of resource attributes and HTTP bodies

After eliminating the major inefficiency in navigation, copies that were made solely for equivalence checking still remained. If srcdoc is valid, its string is used; otherwise src is used, and for objects data is read. Previously, each time a check was performed, the attribute map was fetched and the necessary values were cloned for comparison.

The resource‑load scheduling logic (source code) borrows the tag to determine whether it is an iframe or should accept srcdoc, and then decides the effective attribute. If an entry has already been loaded, it compares the borrowed string from with_attribute with loaded_resource. srcdoc compares the content directly, while src compares the trimmed values.

A borrow is sufficient for comparison alone. When actually committing a navigation and storing the loaded resource in an entry, owned data must be prepared.

The same applies to HTTP response bodies. By adding HttpResponse::into_body, we made it possible to consume the response and return the owned Vec<u8>. When fetching an iframe resource, we first extract the effective URL, Content‑Type, and CSP header, then consume the response to transfer the body. We skip the step of creating a new vector from the borrowed body().

Duplicating the list just to read a single entry

From here we examine the performance fixes for the ordinary path in more detail. The sections on garbage collection and iframes have already discussed copying and retention, but HTML text insertion has an additional pathway whose workload grows with the amount of input.

The HTML parser appends adjacent characters to the same Text node whenever possible. Instead of creating a new node for each character of hello, it adds the character to the existing Text node that immediately precedes the insertion point, if such a node exists. What is needed is the “immediately preceding entry” at the insertion location. For ordinary appends at the end, the last child suffices.

Before the fix, this check was performed using child_nodes(). In the pre‑change tree builder (source code), the parent’s entire list of children was extracted into an owned collection, after which the last child or the one just before a reference was selected. Although the node’s contents are not deeply copied, all handles in the list are cloned. When there are many children, selecting a single Text node triggers work proportional to the number of siblings.

For illustration, we model a case where one child is added each time and all existing children are examined each step.

First run: see 0 items.
Second run: see 1 item.
Third run: see 2 items.
...
Nth run: see N‑1 items.

Total = 0 + 1 + … + (N‑1) = N(N‑1)/2.

This expression does not represent the total number of instructions executed by the entire parser. In HTML, a single input fragment can generate multiple nodes, and there are other traversals as well. However, repeatedly applying the pattern “create the full list because a single entry is needed” to a growing list results in quadratic work relative to the input size. Doubling N would cause this portion alone to require roughly four times as much work.

The revised insert_text uses parent.last_child() for ordinary end positions. The native DOM’s last_child only looks at the tail of the borrowed children and returns that handle. The operation of extracting a single handle remains, but a full snapshot of all handles is not created.

The boundary of document.write and characters moved outside a table

If it were only an ordinary end position, the situation would be simple, but the parser’s insertion point is not always at the end. document.write has an active insertion boundary. Even when moving improper characters from inside a table to outside—a form of “foster parenting”—you need to look at the position just before the table.

In insert_text, we first verify whether the write‑boundary reference belongs to the current parent; if it does, we read previous_sibling(), otherwise we read last_child(). This branching ensures that even when the input is split into separate fragments, we continue using the same Text node for the insertion point. If the boundary becomes stale and the reference has moved to a different parent, we do not use it unconditionally.

foster_parent_text also looks only at the table’s previous_sibling(). If it is a Text node, we append to it; otherwise we insert a new Text node before the table. In HTML tree construction, the position where something appears in the source does not always match the position where it ends up in the DOM. If we replace that with a simple end‑append to eliminate a copy, we would break the tree‑construction rules.

We cannot simply state that “the adjacent sibling became O(1)” here. The current adjacent_sibling borrows the parent’s children and searches for its own position among them. It does not clone every handle, but the scan to locate the position remains. The operation of referencing the end and the operation of searching for an arbitrary reference’s neighbor do not become the same‑cost operation as a result of this change.

This distinction was noted in Issue #1330. In cases where many insertions are made before a fixed reference—such as document.write or foster parenting—each position lookup must traverse the ever‑growing siblings, causing the total scan to become quadratic again. The change removed in PR #1322 was the explicit full‑list copy that existed during ordinary end‑text insertions. It does not mean that all linear searches along limited boundary paths have been eliminated.

What the 512 regression tests verify

The newly added ordinary_text_insertion_does_not_snapshot_existing_siblings places x<br> after <body> a total of 512 times. Because Text nodes and elements alternate, an implementation that clones the existing siblings each time makes the problem more apparent than with a single continuous Text input.

The test first resets the native DOM’s test‑only counter, then parses, and finally reads the number of handles cloned in the children snapshot. It imposes an upper bound that this count must be less than ITEMS * 16, and then checks that the body’s children count is ITEMS * 2—that is, 1024 nodes.

This assertion does not mean “there are no handle clones at all during parsing.” Since tree construction also involves other snapshots and handle acquisitions, we set an upper bound with some margin on the total. Moreover, the counter is read before the final verification performed by child_nodes(). The snapshot used to inspect the resulting child list is counted as an unnecessary copy made during parsing, so it is not included in the tally.

Separating the exact UTF‑16 value from the displayed string

Another quadratic copy occurred inside the Text content. JavaScript strings can hold UTF‑16 code units, allowing values such as a high surrogate alone or a low surrogate alone—values that cannot be expressed as Unicode scalar values. Rust’s String is valid UTF‑8, so it cannot represent those values exactly.

The current Text node, in addition to the data: String used for display and scalar processing, holds an exact UTF‑16 buffer when needed and a count of unpaired surrogates. For ordinary valid strings, it does not always maintain two separate full‑text buffers. It only enters the path that creates the exact units when they are required. This data structure can be seen in Text and set_data_utf16.

The problem of rebuilding the entire text on append

In the original implementation, each time a character was added to a Text node that contained unpaired surrogates, the exact units were extracted—including the already accumulated content—and the entire text after the append was re‑decoded. As the Text node grew longer, the existing prefix touched by a single append also grew. Inserting a short input fragment N times produced a triangular amount of work, just like with the siblings earlier.

Retrieve the current full text.
  → Append a suffix.
  → Decode the full text after the append.
  → Update data and exact units.

The revised append_text_utf16 decodes only the suffix. If the underlying Text already has an exact buffer, it takes that buffer without cloning it, appends the suffix, and returns it. If it does not, it promotes the scalar prefix to UTF‑16 only the first time it is needed. After that, it no longer re‑encodes the existing prefix on each append.

For ordinary scalar suffixes, we append to the display side via data.push_str(), and we also increase the suffix in the exact buffer only when one exists. In the case where a single surrogate is involved, we also avoid the path that would create a typical decoded‑result buffer. Rather than changing what is precisely retained, we have reduced the work of rebuilding the prefix that is already stored.

Vec and String may reallocate when their capacity is insufficient. Therefore, a single append cannot be guaranteed to run in strict constant time. The “amortized linear” claim here means that, aside from the path that explicitly reconstructs the accumulated full text each time, the operation can be treated as a simple buffer append.

Surrogate pairs can be completed at the boundary between input fragments

In UTF‑16, a single scalar value may be represented by two code units—a high surrogate and a low surrogate. It is possible to receive a high surrogate first, with the next input fragment beginning with a low surrogate. If each fragment is decoded as an independent string and the results are simply concatenated, the two code units become two separate replacement characters.

For illustration, consider the case where 0xde00 follows 0xd83d. Initially, the exact buffer contains only a high segment, and the displayed side includes a replacement character. When a low segment is appended, the high segment at the end of exact buffer pairs with the low segment at the beginning of the suffix. As a result, the final displayed side becomes 😀, and the exact units become [0xd83d, 0xde00].

First segment.
  exact:  D83D
  scalar: replacement character.

The second segment starts with DE00.
  exact:  D83D DE00
  scalar: 😀

The implementation examines the high value at the end of the existing exact buffer and the low value at the start of the suffix. Once a pair is formed, it removes the replacement character at the end of the display side and inserts the scalar decoded from those two units. It also avoids adding the replacement character that the leading low of the suffix would have generated, preventing duplication. Rather than re‑decoding the entire existing prefix, only the joining boundary is corrected.

The unpaired count must also be aligned with the boundaries. The existing high entry and the low entry in the suffix will both cease to be unpaired once a pair is formed. Any other unpaired entries within the suffix remain. Simply adding the suffix’s invalid character count would cause this state to become inconsistent. The retained count must be made consistent with both the display and the exact units.

Do not discard the exact buffer immediately after the pair is completed

At first glance, it seems unnecessary to keep an exact buffer once the number of unpaired characters reaches zero. Since the scalar side can be converted back to UTF‑16, you might be tempted to discard it to save memory. However, if you do that every time, the next time a high‑surrogate character arrives you’ll have to re‑encode the entire accumulated scalar text into UTF‑16 again.

Consider an input that alternates finely between high and low. Each time a pair is completed, we discard the buffer, and when the next high occurs we promote the entire string; even though the final result consists entirely of valid Unicode, we end up repeatedly re‑encoding the prefix. The supposedly fixed secondary copy is revived solely by how the input boundaries are handled.

Therefore, the current implementation retains the exact buffer that has been allocated, even if a pair is later completed. The comment also notes that discarding this buffer would cause a full re‑encoding of the text for the next lone surrogate. Whether the current content can be represented solely with scalars and whether the buffer kept for the next append is useful are separate considerations.

Of course, maintaining a buffer incurs a memory cost. Unlike Text, which originally consisted only of valid scalar inputs, once Text follows this path it may retain exact units even after processing is complete. This change is essentially a trade‑off between that memory cost and the cost of repeatedly converting the entire text. It does not mean that every Text will always be duplicated in memory, nor does it imply that the exact buffer will inevitably be zero.

With N and 2N, focus on the copy-work rather than the clock.

The regression test is divided into two scenarios. The first scenario passes only high surrogates to the parser, with 256 or 512 instances. The second scenario repeatedly appends high and low surrogates separately to the Text, creating 256 pairs or 512 pairs. In the parser-side test and the split‑pair test, verify the resulting values and the copy‑work for each case.

Both assertions check that, when the input size is doubled, the counter’s work grows by less than three times. In a linear case the work would ideally be about twice as much, whereas in a quadratic approach that rebuilds the entire output each time it would be roughly four times as much. The “three‑times” threshold allows us to tolerate some fixed overhead while still distinguishing this particular failure mode.

This counter does not represent the total memory traffic observed by the OS nor the total number of allocations. It is a test‑only metric that is incremented for operations such as copying UTF‑16 data, promoting buffers, acquiring owned references, and so on. The final check that obtains exact units also counts as copy work in that path. It does not fully capture, in a single number, everything such as Vec’s internal reallocations or the entire upstream processing performed by the tokenizer.

We also revised the route for retrieving a single native node.

Not only at the parser’s lowest level but also at the entry point where JavaScript reads the native DOM, there were unnecessary copies. Even though we only needed a single instance for firstChild or lastChild, there were paths that built a full list of all IDs or a JavaScript array, and paths that fetched the attribute string as owned just to compare IDs. Although they returned the same value, the granularity of the queries didn’t match the granularity of the objects created internally.

The change now reads only the necessary nodes from the native side. However, even when a native node is already part of the tree, its JavaScript wrapper may not yet have been created. Even when accessing a node through a single path, we must locate the node, register it in the appropriate Document, and return the same wrapper each time that node is accessed.

This is verified in node_copy_tests.rs. By following paths such as reading the text inserted natively from firstChild, reading elements from lastChild, and reading the adjacent node, we examine not only data but also parentNode and ownerDocument. The getter, which reduces copying, is checked to ensure it does not overlook unregistered wrappers or create them as wrappers for a different document.

Furthermore, there is an example where a getter that throws to the parent’s public childNodes was added. If the processing that looks up native relationships ends up invoking the author’s childNodes, the internal single‑record retrieval becomes arbitrary code on the page. This regression test ensures that the relationship getter does not depend on its public override. It’s not just a performance matter about having one fewer getter; it also concerns which author code is executed by the internal processing.

If the ID lookup only needs to verify whether the target’s attribute values match, there’s no need to clone the string each time. However, even when borrowing the value for comparison, the lookup contract must still be upheld.

The test places two nodes with the same ID and verifies that the node appearing earlier in the tree order is returned; after removing that node, the remaining node is returned; and after inserting a new node before it again, the original node is returned. It also checks that using an ID such as name.with[selector] #punctuation does not result in a match when converted to a CSS selector. Empty strings and case differences are treated as separate conditions.

Trees that are acceptable to collect using textContent

The process of gathering descendant characters also tends to generate large numbers of lists and intermediate UTF‑16 buffers. Here I steered the implementation toward the path that appends characters directly, but simply “concatenating all visible characters” does not produce the DOM’s textContent.

In the test, we mix ordinary text nodes, comments, processing instructions, nested elements, shadow trees, template content, and XML CDATA. The text that ends up in the fragment’s textContent is the text from the target’s normal tree; comment or processing‑instruction data are not included. When reading the host’s light tree, characters from shadow trees are not automatically pulled in. Since template content resides in a separate tree, you must not traverse it as a regular descendant from the host.

Furthermore, even when the high and low portions are split across different Text instances, concatenating the results as a JavaScript string requires that the code units be aligned precisely. If only the display scalars of each Text are collected, the original units may be replaced by replacement characters. Even in the read path used to reduce copying, the obligation to handle exact units remains.

For this reason, the native DOM provides a short‑lived borrowing entry point such as with_character_data. It allows access to the scalar and the required exact units only while the callback is running. Because this entry point borrows the node immutably, it imposes the restriction that the callback must not perform DOM mutations or invoke author code. The design does not intend to propagate the borrow into a long‑lived HostState.

The font’s internal notification did not require a publicly exposed MutationRecord.

The copy audit also uncovered issues with font maintenance. When styles, links, characters, or related attributes in the document change, the fonts used and the CSS class @font-face need to be revisited. Prior to the changes, we had implemented an internal MutationObserver to handle this.

However, this observer’s callback does not make use of the received MutationRecord. Instead of processing each item—such as the full list of changed nodes, the preceding and following siblings, and the oldValue—it runs syncCSS and requestUsedFonts based on the current document. For that purpose, a simple notification like “a change occurred, so perform maintenance at the checkpoint” would suffice. Creating records for internal observers that mirror those for the public observer turned out to be unnecessary work of assembling data that isn’t used.

The updated font_loading.js registers a private mutation hook. It uses the current Document received from the notification to synchronize the FontFaceSet. It does not replace the public MutationObserver’s record, oldValue, or callback order. We removed the path that created a record for internal use that wasn’t actually needed, eliminating that unnecessary internal pathway.

During this change, the document’s lifetime test regressed. In the candidate verification for Issue #1167, even though the local comparison for copy reduction had improved, the existing document‑lifetime test still failed. The old binary succeeded in the isolated test, while the candidate binary failed in the isolated test. The fact that it produced good performance numbers does not mean we simply grouped the remaining failures under known issues.

When examined in detail, it turned out that font maintenance was being applied to the Document of an already‑retired iframe, which caused a CSS exception to be routed into the Promise‑rejection notification task. Because a new exception notification was generated, the failure of internal maintenance created an additional entry in the notification queue. In this way, the performance investigation and the notification functionality became linked at the point where the document’s lifecycle ends. In the record of Candidate 5, we report the fix for this regression and a reconfirmation of the existing contract.

The current callback reads the private native binding is-live before maintenance begins. Its predicate is based on the immutable owner associated with the callback’s Realm. It does not decide based on the document‑like value supplied by the author, nor does it borrow the state of another currently active Realm. If the callback has been retired, it returns before constructing the payload or entering the resource/style processing phase.

Expose the Promise from FontFace.loaded only when it becomes necessary.

The Promise associated with a FontFace has also changed. Previously, the Promise was created inside the FontFace constructor. If the source is invalid and the loading state becomes error, the Promise will be rejected even if the page never actually retrieves it. Implementing notifications for unhandled rejections makes the internal Promise creation—previously hidden—visible as a page event.

The current fontStatusPromise creates a Promise when fetching loaded or invoking load(). If it is already loaded, it resolves; if it is already error, it rejects with the previously stored error. It does not erase a failed state and turn it into a success. Instead, it shifts the moment at which the publicly exposed Promise is created to the point when the value is requested.

Once a Promise is created, it is stored in its state. Because entries such as face.loaded === face.loaded and face.loaded === face.load() are retained, the implementation does not generate a new Promise each time it is read. Even after the operation has already completed, you can attach a handler to the same Promise.

[invalid_font_promise_is_exposed_lazily_and_keeps_its_rejection] creates a FontFace from an invalid string source and an invalid binary source. When it proceeds to the idle stage, both have a status of error, but unhandledrejection has not yet appeared. Later, when reading loaded from one side, a rejection of SyntaxError is observed while preserving the same Promise’s identity. Furthermore, attaching catch confirms that the error can be read.

Read the local measurement values for each target individually.

The values presented here are the reported figures from the local measurements in PR #1322. They represent a comparison with a separate process using the same ext4 filesystem and build flags, not values obtained by applying successive deltas to the same process. The comparison of old‑unit ephemerons follows the B‑A‑A‑B pattern, and the reduction of module AST retention uses a different baseline for comparison. These improvement rates should not be summed to obtain an overall improvement rate.

MetricBeforeAfterReported Reduction
old-unit ephemeron, median wall time for 80 generations retained46.471 s34.585 s25.58 %
Same comparison, median user CPU time44.079 s33.892 s23.11 %
Same comparison, median maximum RSS2,395,764 KiB2,309,724 KiB3.59 %
Same comparison, fixed‑point processing18.000 s6.395 s64.47 %
Module AST retention reduction, median maximum RSS for 80 generations retained4.70 GiB2.27 GiB51.72 %
Same AST comparison, median wall time32.72 s31.72 s3.05 %
iframe location transition, maximum RSS≈510.15 MiB≈289.28 MiB43.30 %
Same iframe comparison, wall time1.5825 s1.4388 s9.08 %

Wall time, user CPU time, and maximum RSS measure different things

“Wall” refers to the actual elapsed time of the process. User CPU is the amount of time the CPU was spent in user space, and it does not equal the wall time, which includes waiting periods and contention with other processes. Maximum RSS is the highest amount of resident memory observed for the process; it is neither the memory remaining at termination nor the total amount of memory allocated.

In the comparison with reduced AST retention, the maximum RSS dropped by roughly half, while the wall‑clock time decreased by only about 3%. These two observations are not contradictory. Releasing structures that were retained for a long time lowers peak memory usage, but the CPU was not actively processing during the entire period those structures were held. Even if the amount of retained data is significantly reduced, if another factor dominates the execution time, the change in wall‑clock time remains small.

Conversely, the fixed‑point optimization reduces the runtime far more than the overall wall time. While that specific optimization alone shows a 64.47% reduction, the overall reduction is only 25.58%. Outside the target operation there are parsing, compilation, DOM work, other garbage‑collection steps, and initializations. Applying the local improvement rate directly to the whole would ignore this discrepancy.

What B‑A‑A‑B suppresses and what it does not

Mixing the execution order as in B‑A‑A‑B is done to prevent the effects of changing CPU state or background load over time from being concentrated in a single revision. If the entire first half were treated as “before the change” and the entire second half as “after the change,” the mere fact that the machine in the latter half was cooler—or warmer—could be mistakenly interpreted as the effect of the change.

The 5‑second limit in Acid3 could not be resolved by a local success

The longest‑standing issue in the PR verification was the wall‑clock time limit of Acid3 test 26. On ARM64 interpreters, baseline JITs, and x86_64 baseline JITs, the test reached the 5‑second limit. In addition, the iframe relative‑URL navigation test also timed out. It was necessary to distinguish, from logs and inputs, whether the functional assertion was incorrect or whether correct processing simply exceeded the allowed budget.

In the comment posted in the early hours of October 9 (PR #1322), we recorded that while the ARM64 interpreter’s Faithful mode achieved a perfect score of 100, the Direct mode stalled on test 26. By contrast, the same ARM64 interpreter job in the reference main branch succeeded, with Direct taking 3408 ms and Faithful 2915 ms. At that point, the required checks for the current PR head were failing. Even if a single local run yields a perfect score of 100, that fact cannot be rewritten as a success.

The first thing we revisited was the redundant ancestor checks during DOM insertion. In the normal path of append/insert after completing a full pre‑insert verification, the same ancestor traversal was being performed again. Rather than eliminating the verification, we split the flow so that already‑completed verifications are not rerun. This change preserves the public exception and the internal trust boundary, as described in the previous DOM chapter.

The local per‑run comparison at that time showed Direct improving from 3079 ms to 2783 ms, and Faithful from 2277 ms to 2129 ms, with both achieving a perfect score of 100. The numbers provide a clue for improvement, but both the iteration count and the environment are limited. The comment also notes that this does not constitute proof of CI recovery. The difference observed in a single run is not directly translated to the difference in CI load.

After that, we introduced changes that avoid creating a child Realm when reading history URLs, and another change that reads the MIME state of the native Document when evaluating HTML attribute names. In the October 9 evening report (PR #1322), the added attribute checks, history, and node‑lifetime focused tests succeeded, and Acid3 recorded Faithful at 1751.59 ms and Direct at 2296.34 ms. However, at that point these were only candidate changes that had not yet been committed or pushed, and we did not report that the failures currently present in the PR had been resolved.

Even after the update, the x86_64 baseline JIT browser job (CI run) still failed due to the 5‑second limit. Although Faithful achieved a perfect score of 100, test 26 took 4799.61 ms, and Direct hit the limit, ending with a score of 26. The comment (PR #1322) notes that the execution was interrupted while processing MutationRecord, queueMutation, and insertNodeBeforeInternal.

Investigating the normal iframe path with parallel execution across eight processes

The timeout on the relative iframe URL navigation was reproduced and confirmed by running the same test in parallel across eight processes. The report showed that before the change, 1 out of 8 runs failed, with execution times ranging from 5.15 s to 5.59 s. After the prior diff, all four batches—32 runs total—succeeded, with times ranging from 3.65 s to 4.09 s, averaging 3.92 s. The conditions and results of the comparison are documented in the PR body (PR #1322).

The conditions verified by a single‑process success differ from those examined in this experiment. Running the tests in parallel increases CPU and memory pressure, bringing into view extra initialization and retention that would be negligible when run alone and pushing the workload closer to the limits. Although we did not recreate an environment identical to the CI itself, this provided material to place the problematic operation under the same inputs but in conditions where contention could arise.

Furthermore, this time we examined the path that rewrites an iframe’s URL six times consecutively within the same script. Before the fix, seven synchronous fetches were triggered, including the initial URL, and two of those fetched resources were subsequently discarded. This occurred because the WindowProxy’s state lookup fetched and parsed a pending resource on the spot.

After the fix, the active Document is retained until the final generation is committed in the resource task. Intermediate URL rewrites are batched, so only the initial URL and the final URL are fetched—two fetches in total. The reduction from seven to two corresponds to the number of fetches observed in that fixture. This is not a measure of wall‑time improvement. From just this count, we cannot determine what percentage of the work is accounted for by network, parsing, or Realm creation.

Do not equate the proportion of copied data with the proportion of CPU time

The issue #1167 copy investigation (issue #1167) also includes DHAT copy records. It covered 691,074,813 bytes of runtime, bootstrap, and earlier stages, and was captured as a sample that was interrupted by the timeout of Acid3 test 26. The breakdown recorded was about 74.1% for the Boa parser/compiler and roughly 1.38% for the DOM helper. (issue #1167)

We should not treat this as “74.1% of the CPU time for test 26 is spent in the Boa parser/compiler.” The measured quantity is copied bytes. The cost per copied byte also varies depending on the state of the memory being copied and the fixed overhead of the call. Computations, allocations, garbage collection, and waiting times that are not copy operations are not included in the denominator of this ratio. Moreover, because this sample was cut off by a timeout and did not complete, it cannot be used to represent the overall total amount of work.

In another fixed‑128‑iteration sample, we reported that the amount of DOM‑related memcpy/memmove dropped from 254,918 B to 124,372 B, and the number of copy calls decreased from 23,197 to 3,113. The main factor was the reduction of saved‑origin clones, which are not normally used. This sample should also be interpreted separately from the total copy volume and CPU share across the entire runtime, including bootstrap. In the same‑condition comparison for candidate 1 (issue #1167), we recorded both the improvement values and the failures in document lifetime.

In later candidate 5, using the original fixture, the 5‑second limit, the normal release/default feature set, with Cargo disabled, and a separate‑process B‑A‑A‑B execution, all four runs achieved a perfect score of 100 for both Direct and Faithful. The average for test 26 improved to Faithful 1710.01 → 1465.41 ms and Direct 2328.34 → 1915.89 ms. However, these were only two local measurements per revision, and at that point the full test suite, build, and new CI pipeline had not yet completed. The record (issue #1167) also notes these limitations.

A case where the fixed WPT and the current specification diverged

There was also one discrepancy in WPT that could not be explained solely by an implementation error. It involved a data mutation of a ProcessingInstruction contained in the fixed revision MutationObserver-characterData.html. Issue #1323 (issue #1323) includes the fixture’s hash, the upstream history, the name of the failing subtest, and the reference where the specification was verified.

The input is <?processing data?>. When handling legacy HTML, this is read as a bogus comment, and the data becomes ?processing data?. In the processing‑instruction handling we are implementing this time, it becomes a ProcessingInstruction with target processing and data data. When the CharacterData is later modified, the oldValue in the MutationRecord will not match, because it reflects the parsed result.

The current HTML Standard tokenizer (documentation) includes a state for processing instructions. I confirmed that section while writing this article. On the other hand, the fixed WPT revision is dc97e7bed3096ac9e0e591ab5fa22e7fb8844ead, and the issue investigation revealed that the corresponding upstream file still retained an expectation from 2014. The latest specification and the fixed, older input/expected values do not necessarily represent the same point in time.

In this handling, the fixture was not edited. Only a local copy that replaced the expected value with a new one was run, and it was not counted as a passing fixed WPT. The original input and assertion were retained, and in the manifest’s known‑failure list a single subtest name, characterData ProcessingInstruction: data mutations, was explicitly noted.

Do not treat known failures as a blanket allowance for the entire test case

The entry for manifest.json includes a status, a reason, an issue reference, a deadline, and the list of failed subtests. The deadline for this entry is November 8 2026, and the tracking reference is #1323. Specifying a deadline does not automatically resolve the specification discrepancy, but it provides tracking information so that the issue is not left as an unexplained exception indefinitely.

The classification for classify_with_subtests does not treat something as a known failure solely because its status is FAIL. It reads the results of the subtests, gathers the failure names, and checks whether they match the set of failure names that have been registered. If the results are not an array, if some failure names are missing, or if unexpected statuses appear, those cases are also classified as regressions.

Therefore, if a different subtest in the same WPT file fails, it is distinct from the single failure we registered this time. Timeouts and script errors are not considered part of the specification discrepancies that were registered as FAIL. When everything passes, it is identified as an improvement. Even when progressing toward success, this provides material for keeping the known‑failure registration from being left unchanged.

This classification process itself was not built from scratch for this PR. It is a change that leverages the existing mechanism for tracking down to the subtest level. The direct regression that verifies the new PI handling and the single legacy expectation that remains in the fixed WPT are treated as separate verifications.

The fixed WPT referenced in the PR’s body for the prior head normally had 415 passes out of 420 cases, 5 known failures, and 0 regressions. We would not describe this as “all 420 cases passed.” We also distinguish the detailed case counts obtained from the prior head from the successful remote checks performed at the final head. The success of the WPT, compatibility, and Release checks for the final head are confirmed via GitHub’s check results.

Verification of the final head was not completed with only focused tests

In the final report (PR #1322), we ran CI=1 cargo test --locked --no-fail-fast on the clean target of the final local head 7ad562bf, obtaining 111 groups, 3,871 passed, 0 failed, and 15 ignored. cargo build --locked also succeeded, and both format checks and diff checks passed. This article does not recount re‑running those tests; it records the verification results as they were at the time the head was explicitly identified.

On the other hand, the 15 ignored items are not 15 cases that succeeded when executed. In a normal full‑local run they remain marked as ignored. The suite for Gate 5 uses jit-gate5.py, which employs a different command that includes --include-ignored. The aggregation of the full local run and the remote Gate 5 run should not be combined as if they were under the same execution conditions.

Align the granularity of test counts

In the focused verification, the PR body notes that all cases succeeded for DOM unit 31, HTML tree builder 42, Promise report unit 4, p1_dom_modern 83, and p1_error_reporting 67. However, the numbers 83 and 67 do not represent independent counts of features solely based on the subject indicated in the file names; they also encompass adjacent concerns such as selection scope, document ownership, and Window identity.

A single Rust test function may assert multiple conditions within JavaScript. WPT further breaks tests down to subtest granularity. In CI checks, a job is the unit that runs many tests. “3,871 tests,” “420 WPT cases,” and “63 checks” are numbers measured in different units.

What is included in the 63 successes and 2 skips

At the time of writing, the PR’s status checks retrieved from GitHub showed 63 SUCCESS, 2 SKIPPED, and zero failures. The final CI runs (CI run), Browser behavior (CI run), Gate 5 (CI run), and CodeQL (CI run) are included.

The two skips correspond to the distribution‑release step in the Release workflow and the avoidance of duplication with the native JIT matrix, which is executed in a separate workflow for PRs. The distinction can be made based on the conditions in release.yml (source code). The success of the Gate 5 suite or package is separate from the fact that a new browser version has been released to users. Merging this PR does not automatically mean that the changes have been distributed as a release.

Gate 5 also verifies the native execution and the contents of the package

In Omoikane’s Gate 5 documentation (source code), the actual distribution archive is built and its contents are executed on the corresponding native host. Simply having a green cross‑build does not guarantee that it will run on that CPU and OS. In particular, the JIT’s ABI, code memory, and garbage‑collection boundaries are execution conditions distinct from a successful compile.

The targets are x86_64 Linux, ARM64 Linux, and ARM64 macOS. Historical measurements on Intel macOS are not counted as a success for this support matrix. The suite and the package run in separate jobs, using a shared, resolved Cargo.lock. The aggregation step verifies that each required result is present.

In the final run, the suite took 35 minutes 22 seconds on x86_64 Linux, 27 minutes 22 seconds on ARM64 Linux, and 32 minutes 18 seconds on ARM64 macOS. This was also confirmed by the start and completion timestamps of the finished jobs. It is not the total time spent on the pull request nor the overall elapsed time of the CI workflow; it reflects the duration of each individual suite job.

Even if the debugger succeeds, it does not change the original gate to a success

The diagnose-gate5-wpt.py added to investigate SIGSEGV on macOS runs the recorded binary with LLDB while leaving the original failure intact. It is applied only to WPT executables that recorded a signal 11, after confirming that the normal suite result is marked as failed.

We preserve the source HEAD, the binary hash, the LLDB version, the hash of the normal logs, and the environment used for normal execution. The WPT report generated for the debugger is written to a separate output location; the original report is not overwritten by the debugger’s results. Finally, we verify that the normal files and the binary have not changed.

The regression tests for the auxiliary script itself are located in test_diagnose_gate5_wpt.py. It enforces constraints such as not arbitrarily diagnosing commands or binaries outside the intended target, and not modifying the original artifacts. The mechanism used to inspect failures must also be verified so that it does not erase the original failure records.

Even when CodeQL succeeds, some macros remain unextractable

The final CodeQL checks succeeded. However, the 22 known macro warnings in the Rust extractor continue to be tracked in Issue #1310. The workflow’s exit status, the number of open alerts, and how much of the original source code was successfully extracted are separate pieces of information. (issue #1310)

If macro expansion is partially missing, the AST, types, and data‑flow at that location may not be analyzed sufficiently. Simply suppressing warnings to make the workflow pass does not mean the analysis target has been restored. Conversely, an expression that is invalid within a cfg on one target cannot be definitively blamed for execution failures on another target. In Issue #1310, the platform and location are separated, and minimal reproducible examples along with database comparisons are retained.

Remaining UTF‑16 staging

Even if the end‑point of text appending is fixed, it does not eliminate all copies of the full path from HTML input to JavaScript’s DOMString. Issue #1328 clarified that even ordinary scalar inputs can generate multiple owned UTF‑16 intermediate buffers. (issue #1328)

The current tokenizer’s &str input is first encoded into a UTF‑16 Vec and passed to the InputDecoder. The token’s text, comment, and attribute also follow a path where they are converted back from the UTF‑16 buffer to a String, and then re‑wrapped in a Vec to preserve exact units. Even for ordinary JsStrings that are passed from JavaScript to createTextNode, CharacterData, attributes, or document.write, there remains a path that creates an owned UTF‑16 Vec and decodes it into a String on the native side.

These paths are normally linear and differ from the second‑order issue we fixed—the repeated full‑text copy of the accumulated prefix. However, when the HTML is large, multiple representations of the same payload can coexist simultaneously, increasing peak memory usage and the amount of copying. Even after excluding the second‑order growth, it is worth investigating how many times a buffer is created per instance.

The approach we are considering is to separate the decoder entry point that can accept scalar input directly from the pathway that requires exact UTF‑16 values. Valid owned Strings can be moved, and for cases that contain unpaired surrogates, a value type that retains exact units is also a candidate. However, this is a policy outlined in the issue and is not fully implemented in PR #1322.

The serializer’s snapshot creates a separate peak from the output

Issue #1329 concerns the owned payload snapshot of the HTML/XML serializer. When generating the final output string, extracting the input DOM’s Text nodes and attributes as owned UTF‑16 Vecs causes the DOM payload, the snapshot, and the output to exist simultaneously. Larger subtrees increase the total amount of extra buffer, pushing up the memory peak. (issue #1329)

Ensuring that the current getHTML and XMLSerializer return correct values is distinct from minimizing temporary storage beyond the output. In this serializer addition, we had to handle namespaces, attribute ordering, escaping, shadow/template handling, and exact units. The owned snapshot we created is a safe way to lock the observation, but for intervals that do not re‑enter author code, it is also a candidate to write directly from borrowed values to the writer.

Even with this candidate, merely replacing the entire Vec with a full String only moves the same memory peak into a different representation. We need to investigate whether the final writer can perform escaping/encoding directly, and whether only the necessary information from attribute records can be retained. Not invoking author callbacks or DOM mutations during short borrow periods should also be an API‑boundary condition.

Retaining detached nodes cannot be solved by a simple weak reference

In the investigative comment on Issue #1167, after adding and removing an element a and a Text node 128 times to the active Document, eliminating all strong JavaScript references, clearing pending Promise/observer work, and running the garbage collector twice, all 256 / 256 WeakRefs remained alive. The number of registered nodes progressed from 4 → 260 → 260 → 260.

This result reproduces a situation where detached nodes/wrappers are retained. We have not measured what percentage of Acid3’s runtime this accounts for. Simply noting that retention exists, that memory usage is high, and that test 26 times out does not allow us to conclude that the retention is the primary cause of the timeout.

Structurally, we are looking at retention in Document/Realm groups, DocumentNodes, HostState.nodes, and similar structures. It might seem sufficient to turn all deleted nodes into weak references, but nodes remain attached to the page and usable even after they are removed from the tree. We must not break the contracts involving ownerDocument, parent‑child relationships, shared wrappers, expando properties, or listeners.

The fix we applied this time is not simply the opposite of a contract that correctly retains the document of a retired iframe. Both preserving the old document that should remain alive and reclaiming the detached component that has become unreachable are necessary. The successful final verification of PR #1322 should not be taken as evidence that this known retention issue has been resolved. Issue #1167 originated from an Acid3 performance problem, but later comments also track this DOM‑retention investigation.

Keep cross‑origin fallback and ECMAScript features as separate tasks

Issue #1326 retains a limited fallback for cross‑origin WindowProxy. The three keys then, Symbol.hasInstance, and Symbol.isConcatSpreadable have paths in the current Omoikane that cause a SecurityError, so we need to follow the conditions that return an undefined descriptor. This is not a change that sets all generally prohibited properties to undefined. (issue #1326)

The Intl, Temporal, and iterator helpers from Issue #1295 are also not included in the results of this work. Features that are present on the Boa side and those usable from Omoikane’s runtime are distinct, and enabling a feature can affect binary size, startup time, Test262, and JIT contracts. The increase in DOM and exception notifications does not automatically fill in ECMAScript features that are still unsupported. (issue #1295)

In the end, we kept the original timeout, fixture, and assertions, and merged the head that passed the necessary checks at 994f17cc. The remaining issues are still tracked in their respective issues. When we work on this again, we will do so without breaking the identity, exact units, checkpoint, and owner boundaries we protected this time, and further eliminate unnecessary work from there.