I added Pointer Lock support and custom form‑related elements to my self‑made browser ( Omoikane ).

In the previous post I wrote about Fullscreen API and character‑encoding conversion streams. This time I’m covering the pathway for delivering mouse input to the page and the logic for integrating my custom input components into HTML forms.

Neither of these is a huge undertaking if you only consider the entry‑point APIs. However, when we actually hooked everything up, we ran into issues such as mouse input being delivered twice on X11, iframe callbacks executing in a different runtime environment, and even pages that don’t use forms becoming sluggish. In the end, the macOS tests caused the entire process to crash.

Below is a summary of what features were added and what broke and was fixed along the way. The target is the September 20 19ded7d0 build that incorporated PR #770 for Pointer Lock and PR #775 for ElementInternals.

Pointer Lock PR #770 ElementInternals PR #775

Pointer Lock Is Not Just About Hiding the Cursor

The Pointer Lock API is meant to keep sending mouse movements to a specific element continuously. For example, when manipulating a 3D view, you want the amount of movement since the previous input rather than the cursor’s absolute position on the screen.

With a normal cursor, once it reaches the edge of the screen it can’t move any further. With Pointer Lock, the cursor is hidden, its visible position on the page is fixed, and relative movements are received via movementX and movementY.

What we added this time are requestPointerLock(), exitPointerLock(), pointerLockElement, as well as events for state changes and errors. We connected these to the GUI’s cursor control and the relative input arriving from the host.

Here, setting the JavaScript‑side flag as already locked in advance causes a problem. Even if the OS refuses to acquire the cursor, the page will see it as if the operation succeeded.

Therefore, on the native‑GUI side we made it wait for a response from the host.

The page calls requestPointerLock()
    -> Save the fetch request with a request ID attached
    -> The GUI attempts to acquire the OS cursor
    -> Return the success/failure status together with the request ID
    -> Reconfirm that the target document and element are still valid
    -> Reflect the DOM state, events, and Promises

The values passed to the GUI from Rust take the following form. Below is an excerpt of the implementation with comments omitted.

pub enum PointerLockTransition {
    Acquire {
        request_id: u64,
        unadjusted_movement: bool,
    },
    Release,
}

The request ID is not reused across page navigations. If the focus is lost or the page switches while waiting for a response, we do not lock the new page with an old successful reply.

In the Pointer Lock state‑management code (Pointer Lock state management), we keep separate queues for the pending requests, the current owner, and whether a request has already been sent to the host. Even if an acquisition succeeds and returns, if the target has become invalid in the meantime we route it to a release.

Pointer Lock state management

We also added the ability to release the lock via Esc, loss of focus, removal of the target element, or navigation. The tests include a case where a late‑arriving response is ignored.

Note that in headless mode the virtual host accepts normal acquisition requests. This is a different pathway from the actual acquisition verification performed by the native GUI. Also, the request for input that excludes OS‑level acceleration, unadjustedMovement: true, is currently implemented as NotSupportedError in the built‑in host. The ability to handle relative movement and the guarantee of this option are kept separate.

Mouse Input Arrived Twice on X11

After connecting the API to the host, we found that relative mouse movements and wheel events were duplicated under Linux’s X11.

When we used Winit’s standard X11 confinement path, there were cases where raw motion events were duplicated. Adding up the values received on the page resulted in movement larger than what was actually applied.

It might seem that we could simply discard one of the duplicates when the same amount of movement occurs consecutively, but that would eliminate even genuine inputs that were moved by exactly the same amount in succession. We did not implement a post‑hoc deduplication based on the values or timestamps.

On X11 we created a dedicated path, set XGrabPointer’s owner_events flag to false, cleared the core mouse‑event mask to zero, and switched to using Winit’s XI2 raw input. Not only relative movement but also button and wheel events are routed through this path.

The X11 acquisition code (X11 acquisition processing) uses the existing Winit window and connection and runs in the event‑loop thread. For non‑X11 platforms, it delegates to Winit’s acquisition API.

X11 acquisition code

Since this problem does not appear in DOM‑only tests, we also added tests that launch the GUI under Xvfb and simulate input with XTest.

Input givenResult observed on the page
One relative movement via (5000, -4000)One event, movement amount unchanged
Two consecutive relative movements via (12, -9)Three events total, cumulative (5024, -4018)
Click and wheel while lockedOne event each
Release with EscOS releases the lock, cursor reappears, returns to its pre‑lock position

We confirmed that even large movements keep the page’s coordinate system fixed and that consecutive identical inputs are not dropped. In addition to hiding the cursor, we also tried acquiring the lock from a separate X11 connection and verified whether the OS truly holds the lock.

This GUI test (GUI test) runs on the Linux x86_64 and ARM64 CI pipelines. However, it uses synthesized input to Xvfb and does not cover physical mice, Wayland, or macOS desktop interactions.

GUI tests

Sending Input to iframes Also Affects the Executing Realm

Next, we fixed the logic that delivers host input to event listeners registered inside an iframe.

A Realm is the execution environment that bundles a JavaScript global scope and its built‑in objects. Even iframes that share the same origin and can interoperate do not share a single Realm between parent and child.

If the target DOM node for input resides in a child Document, the input must be routed through the child’s input‑dispatch mechanism. At the same time, focus state, listener ordering, and cancellation flags must remain consistent between parent and child.

In this input bridge we register a dispatch callback for each Document, and keep a separate state for any input that needs to be shared. When a Document is destroyed, we also remove its dispatch registration.

However, when this pathway was exercised, it also exposed a bug in Boa’s native continuation.

A native continuation is a mechanism that lets Rust invoke a JavaScript callback and then return to the point after the call. Because input listeners may be paused by host calls, we do not use a simple nested synchronous invocation.

The problem was that the native function, after pushing a frame for the callback, unconditionally returned the continuation to the original Realm, overwriting even the execution environment of the callback that was about to run.

Native call from the parent side
    -> Prepare the callback frame for the child realm
    -> Clean up after the native call
    -> Return to the parent realm
    -> The child callback is supposed to run, but the environment is different

In the fix, we determine whether a callback frame has been added by checking the frame depth. If a frame was added, we preserve that Realm and restore the original caller when the callback returns.

We also corrected the cleanup of the shadow stack. Simply truncating the stack to pop() would remove the callback entries that were pushed after the native call. Instead, we retain the position where the entries were added and delete only the entries belonging to native calls that have completed.

In Issue #768 for Realm and continuation, regression tests have been added not only for normal calls but also for nested calls, exceptions, pauses, and resumptions that involve garbage collection.

Realm and continuation issue #768

Incorporating Custom Input Components into Forms

Another addition is the form‑related custom element.

Form-associated custom elements

Even if you create an input UI with a custom element, merely making it look like an input field does not cause it to participate in form submission or validation. The ElementInternals added this time is used for that connection.

Showing only the entry point, it looks like this. This is an example that demonstrates registering a value, omitting the input UI itself.

<form id="settings">
  <x-value name="answer"></x-value>
</form>

<script>
  customElements.define("x-value", class extends HTMLElement {
    static formAssociated = true;

    constructor() {
      super();
      this.internals = this.attachInternals();
      this.internals.setFormValue("42");
    }
  });

  const form = document.getElementById("settings");
  console.log(new FormData(form).get("answer")); // "42"
</script>

In Omoikane, the submitted values are handled as strings, File, FormData, and null. With FormData, a single component can register multiple entries, and with null, no submission value is produced.

The entry configured with FormData is copied. This ensures that even if the caller later modifies the original FormData, the already‑registered values remain unchanged.

In addition to values, we also connected the validation state via setValidity(), the form’s submit eligibility, and the rendering of :valid and :invalid. The related form and label elements are not fixed values; they track changes to the DOM.

The difficulty lies not when the component is first added to a form, but later. State changes occur when moving to another form, when the ID specified by the form attribute is altered, when entering or exiting a disabled fieldset, or when a reset is performed.

We provide formAssociatedCallback, formDisabledCallback, and formResetCallback, and notify only after DOM modifications have completed. Even if the DOM changes again during the notification, we queue the reaction before processing it so that only a fully updated view is presented.

Separating the Submitted Value from the Restored State

setFormValue() is the second argument, which can be used to set the state for restoration.

// internals was obtained with attachInternals()
internals.setFormValue("2026-09-20", "2026年9月20日");

In this example, the value to be submitted is a machine‑processable date, while the representation needed for restoring the UI is a different one. If the second argument is omitted, the submitted value is also used for restoration.

In this implementation as well, we keep submission and restoration separately, and pass the saved state to formStateRestoreCallback.

If we were to store the old elements or JavaScript objects as they are, they would keep the previous Document and runtime alive. What is needed when navigating back is only the value, not the entire prior execution environment.

Therefore, on the Rust side, FormStateSnapshot is designed to hold serialized data. It serializes strings, file byte sequences and attributes, and FormData entries, without retaining references to the original DOM or JavaScript objects.

When restoring, it matches the document path and element name with name and id. If the custom element definition has not yet been loaded, the restoration is postponed and proceeds once the element is upgraded. This is the current matching mechanism, and it does not guarantee that the same input field can be found on pages whose structure has changed arbitrarily.

In the regression test for restoration, the following sequence is actually exercised:

Restoration regression tests
Set values in the original runtime
    -> Take a snapshot
    -> Drop the original runtime
    -> Force garbage collection
    -> Pass the snapshot to the new runtime
    -> Later call customElements.define()
    -> Advance the event loop
    -> Verify that the restore callback is called exactly once

We do not treat this as merely confirming that the original object happened to remain and was functional. Separate tests also cover file handling, FormData, iframe reloading, and history navigation.

The iframe side also needed to be fixed when actually submitting a form

To run the form’s Web Platform Tests, simply being able to create FormData was not enough. A pathway is required that sends a GET or POST request to a named iframe and then checks the result within that iframe.

Therefore, we added logic that resolves an existing iframe as the submission target and replaces only its Document. In the tests, a local HTTP server receives the method and body, and we also verify that the parent page is not replaced.

In doing so, we also corrected how the HTML parser handles form owners. In particular, when constructing tables, determining the form association solely from the parent‑child relationships after construction can cause the parser’s original association to be lost. Conversely, retaining the old parser‑derived association after DOM manipulations that move elements is also undesirable.

We also fixed the issue where inline classic scripts in the iframe’s initial document were not executed. Additionally, we connected the child’s location and history to the child’s own history rather than to the parent’s.

It is important to note that even if the iframe element itself remains the same, its Document can be replaced.

If the history operation that the old child Document held were transferred directly to the current iframe, it could alter the history of the new document. In the navigation path for child documents, we compare the Document identifiers and re‑verify that the originating operation is still valid even after a previously delayed load has completed.

Child document navigation path

We also added regression tests to ensure that old callbacks and restoration logic are not applied to the new document.

Note that a host API for creating new windows does not yet exist. Sending to _blank or to a non‑existent name does not replace the current page; it is treated as a blocked popup. The additions to the initial script this time are also inline classic scripts, and the iframe‑related functionality is not yet fully complete.

Pages That Do Not Use Forms Became Slower

After adding these features, Acid3 test 26 began hitting its time limit.

This test repeatedly creates, inserts, and removes DOM nodes. The stack at the point of failure included a path from the newly added customFormSubtreeRemoving to customElementTreeWalk.

The traversal needed to invoke form‑related callbacks correctly was running even on pages that do not use any form‑related custom elements. Since this processing was added on top of ordinary DOM mutations, it incurred a cost on pages that do not use the feature.

The fix is to skip the dedicated traversal until the first form‑related custom element is created.

However, if the enablement flag is made independent per Realm, moving an element created in a child Realm to the parent can cause the parent to deem it “unused” and omit the notification. Therefore, this state is shared among Realms within the same browsing context.

When we added tests for moving elements, we discovered further notification omissions and a mismatch with ownerDocument. The issue was that an element created in a child Realm and then moved to the parent, and subsequently detached, caused its owning document to revert back to the original child.

We fixed this by sharing internal state between Realms via weak references and ensuring that detached nodes also reference the most recent owning document. We have verified not only the movement of individual elements but also the relocation of entire parent elements.

The comparison of test 26 on the same local ARM64 environment, as shown in Issue #776, is as follows.

Performance regression issue #776
StateFaithfulDirectDrive
Before form support2,331 ms2,546 ms
After adding form support3,021 ms3,339 ms
After removing unnecessary traversal2,469 ms2,632 ms

Each measurement was taken only once, so they are not presented as typical speed‑up rates. What we want to highlight here is that the extra processing on pages that do not use the new feature was contributing to the increased load.

Faithful follows the path that advances Acid3 via the page’s original load event and a chain of timers, whereas DirectDrive drives the update process directly from the runner side. Even for the same test, the execution flow differs. I documented this difference in a previous GC investigation article.

We did not consider it complete after only the local shortening; we also verified the CI for the final commit. During the intermediate stage, some macOS configurations still had timeouts remaining, but in the final version all required CI checks passed. We did not loosen the fixture’s loop count or time limits. On the other hand, some results were close to the 5‑second limit, so we cannot claim that performance has gained a substantial margin.

The macOS SIGSEGV was a separate issue from the timeout

While verifying the performance fix, Acid3’s DirectDrive caused a SIGSEGV on macOS ARM64 with the baseline JIT configuration.

Even if the same Acid3 test fails, reaching the time limit and the process crashing due to a memory error are distinct phenomena. We investigated this separately in Issue #777 .

We fixed the revisions before and after the change and compared normal execution with execution under LLDB. The crash path was as follows.

Array::from
    -> get_iterator_from_method
    -> JsObject::call
    -> __call__

It crashed when calling a function for the iterator, at the point where it accessed the vtable.

Array.from() first obtains the Symbol.iterator method from the input, then creates the output object and uses that method to obtain an iterator. The specification also follows this order .

GetMethod(items, Symbol.iterator)
    -> Execute the constructor of the output object
    -> Obtain an iterator from the already‑acquired method

A garbage collection may run during this interval.

If Symbol.iterator is an ordinary property, the input object usually owns the function. However, if a getter creates and returns a new function on the spot, that function is not necessarily stored on the input object.

On the Boa side, the retrieved function was placed in a Rust local variable, but the code omitted keeping it alive as a reference reachable by the garbage collector during the execution of the output constructor.

The fact that a Rust variable is in scope and that the JavaScript engine’s GC treats its value as alive are separate concerns. What is needed is to register a root that prevents the collection of this value for the duration of the call.

The core of the fix is this single line before the constructor:

let using_iterator = using_iterator.root();

In the Commit fixing the reference retention we added three lines, including comments. The fix guarantees the lifetime of the temporary function without changing the call order, preserving the existing sequence.

The issue was observed in the macOS JIT configuration, but we did not attribute it specifically to macOS or the JIT. The extracted regression test also failed on Linux ARM64 before the fix.

It could not be reproduced by simply forcing a GC

In the regression test we arranged for the getter to return a new iterator function each time and forced a garbage collection inside the output constructor.

However, the test originally written as a JavaScript getter did not reproduce the issue.

When the getter’s return value is retained in its execution frame, even a genuine reference‑retention leak may go unnoticed because the function can survive thanks to another reference.

Therefore we replaced the test getter with a native Rust getter, avoiding the path where the return value remains in the registers of a discarded JavaScript frame.

The native getter generates a new function
    -> Array.from receives a function
    -> The output constructor calls force_collect()
    -> Invoke the obtained function
    -> Inspect the value, length, output type, and call count

With this change, the regression test failed before the fix and succeeded after adding the root.

After the fix, the macOS diagnostics also succeeded on all runs—four normal runs and four runs with LLDB. Subsequently, the final‑commit PR CI verified the interpreter and baseline JIT across the three environments.

It wasn’t just that the CI happened to pass on a rerun; we were able to resolve the issue after narrowing down the failure conditions, which was the important part. This was also a separate location from the previously fixed shared‑shape‑cache lifetime problem.

Scope of this verification and what remains

Regarding the final head of PR #775, f9a23fbd, the recorded results are as follows. The CI was also verified using the merge revision with main at that time.

TestResult
ElementInternals regression23 passed
Real HTTP requests, iframe history, document boundaries20 passed
Validity rendering8 passed
Full local test suite2,694 passed
Fixed‑revision WPT smoke146 of 147 passed, 1 known failure, 0 regressions
Browser CIAcross 3 environments × interpreter/baseline JIT (6 configurations), both Acid3 paths 100/100
Test262 comparison50,595 cases per environment, no regressions or panics compared with the reference version
PR checks43 passed, 2 skipped as out of scope

The WPT figures are aggregates for the fixed smoke targets and do not imply full compliance with the entire WPT suite. The remaining single failure is the same issue that persisted from the previous run ArrayBuffer transfer and detachment issue .

Test262 also did not achieve 100 % success; each environment reported 47,616 passes, 923 known failures, and 2,056 ignored cases. The absence of regressions in the comparison is distinct from overall conformance to ECMAScript.

The results can be found at Regular CI , Browser 6‑configuration , and Test262 comparison .

ARIA reflection for ElementInternals and CustomStateSet are outside the scope of this change. Restoring form state per history entry is also part of the current Omoikane implementation, and we have not confirmed that it behaves identically to Firefox for the same document history.

We also left as a separate issue the Nested fixed elements shift problem that was discovered during manual inspection. The validity‑rendering test this time only verified state changes in the normal flow, and we have not claimed that this layout issue has been resolved.

Results of adding input and forms

What became available on the page this time are relative mouse input and custom elements that participate in submission, validation, and restoration.

Behind the scenes, we also fixed the handling of waiting for the OS to finish acquisition, delivering input to child realms, restoring only the values without retaining old Documents, and preserving temporary references across garbage‑collection cycles.

Pages that do not use forms also saw a slowdown, which was something we wanted to observe when adding new functionality. If we had only exercised the normal cases of the new API, we would have missed both issues.

For now, these two changes have been merged into main. There remain tasks such as real‑device testing of Pointer Lock and unfinished work around forms, but the added code and the bugs uncovered there are being retained together with the regression tests.