I added the Fullscreen API and TextEncoderStream/TextDecoderStream to my custom browser,
Omoikane
.
Previously, I implemented the Popover API and a top layer (see /blog/2026-09-14/). This time, I connected that rendering pipeline to the Fullscreen API and, as a separate task, made character‑encoding conversion usable via the Streams API. (source)
Fullscreen display and character‑encoding conversion are fairly unrelated features. However, neither could be completed simply by adding a JavaScript method.
For Fullscreen, the DOM, rendering, and the OS window state must all be synchronized. For character‑encoding conversion, the state must be preserved even when the input is split mid‑stream, and termination or errors must be communicated to both ends of the stream.
I’ll summarize the implementation details of PR #765 for Fullscreen and PR #764 for the character‑encoding conversion stream. Below is a discussion, as of September 19, about the implementation of fd29659e.
Fullscreen Is Not Just About Enlarging an Element
The entry point to the Fullscreen API used on a page is modest.
<button id="enter">Enter fullscreen</button>
<main id="screen">Displayed content</main>
<script>
const screen = document.getElementById("screen");
document.getElementById("enter").addEventListener("click", async () => {
try {
await screen.requestFullscreen();
} catch (error) {
console.error(error);
}
});
</script>
However, merely adding width: 100% and height: 100% does not achieve fullscreen display.
On the DOM side, you need to track which element is currently in fullscreen mode. The rendering side must remove it from the normal stacking order, and the host side must switch the window to fullscreen. Moreover, when fullscreen is exited—whether by pressing Esc or navigating away—the respective states must be restored.
The main entry points added this time are Element.requestFullscreen(), Document.exitFullscreen(), fullscreenElement, fullscreenEnabled, fullscreenchange, and fullscreenerror. The CSS rule :fullscreen was also made to inspect the DOM state.
Maintaining a State That Represents Ongoing User Interaction
We don’t want the page to automatically switch to fullscreen just by being opened, so the request requires a temporary permission that originates from a user action.
In Omoikane, this state is attached at the input stage and is consumed by a call to requestFullscreen() that can grant permission. It also checks for elements that are not connected, documents that are not permitted, open popovers, and so on.
If, at the moment the event handler exits, the permission is immediately revoked, the request made in a subsequent microtask from the same input processing will be denied.
button.addEventListener("click", () => {
Promise.resolve()
.then(() => target.requestFullscreen())
.catch(console.error);
});
In the current implementation, the revocation logic is delegated to a timer, and when new input arrives, the older revocation is cancelled using a generation number. We verified that a request can be made in a microtask that follows the same input task, and that it will be rejected after the revocation takes effect. This reflects the current implementation approach, but it does not mean that all browsers handle the timer in the same way.
We verify not only at the moment a request is received, but also after it proceeds to asynchronous processing, that the element remains attached to the same Document. This is because the page may delete the element immediately after the call.
In the tests, we examined the results of promises and events for rejection without input, success after input, and host‑side rejection. The tests do not merely check whether the API exists.
Placing on the Top Layer and Changing Rendering Constraints
Elements in fullscreen are handled on a top layer that sits above the normal stacking order governed by z-index. This uses the common pathway prepared for popovers.
However, simply moving an element to the top layer is not enough. Even if an element that originally has a small width or height, or a transform style, is rendered at the very front, it will not become fullscreen.
In the current style processing, we apply the following override to elements that are in fullscreen:
/* Key overrides in the implementation; this is not CSS written by the page */
position: fixed;
inset: 0;
margin: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
transform: none;
We also set the default background for ::backdrop and have confirmed that not only rendering but also hit‑testing treats elements in fullscreen correctly.
For the rendering regression test, we created a 160 × 120 fixed fixture. We apply z-index: 2147483647 to a normal element to cover the entire screen, and then display the fullscreen target—originally sized 12 × 12—on top of it.
The target also has a transform for positioning. After entering fullscreen, that positioning is disabled to fill the viewport, and the internal marker must appear at its designated location.
This fixture compares every pixel against the saved baseline. The PR showed zero differences. However, this only confirms agreement with the fixed regression image inside Omoikane; it does not verify pixel‑perfect matches with Firefox or confirm that the OS‑level fullscreen display behaves correctly.
Connecting DOM State with the Native Window
On the Rust side, each Document maintains a stack of fullscreen elements and keeps a separate record of state changes to be sent to the host.
Currently, the values transmitted to the host are two:
pub enum FullscreenTransition {
Enter,
Exit,
}
Instead of manipulating the window directly from JavaScript, the request is passed through the JS runtime to a CDP session, from which the GUI retrieves it.
requestFullscreen()
-> Update the DOM's fullscreen state
-> Maintain FullscreenTransition::Enter
-> GUI retrieves the state change
-> Apply to the winit window
On the GUI side, when it receives Enter, it forwards the request to the next call.
window.set_fullscreen(Some(WindowFullscreen::Borderless(None)));
The reverse direction is also required. If the OS reports that fullscreen has ended but only document.fullscreenElement remains, the page’s view of the state will be inconsistent.
Therefore, we added a pathway for the host to notify termination, which clears the DOM state and connects to fullscreenchange. Termination handling also runs for Esc, removal of the target element, and navigation via CDP.
We would like to keep the verification scope separate. The test environment for this run is a Docker container, and we have not performed any checks that actually display the native GUI. We have passed regression tests for state transitions and cargo check --locked --features gui, but we cannot claim from this implementation that the Promise will wait until the OS‑level display switch is completed. Currently, the architecture separates the DOM‑side acceptance from the request that the GUI applies later.
There Are Boundaries for iframe and Shadow DOM as Well
When an element inside an iframe enters fullscreen, the operation cannot be confined to the child Document alone. It must traverse up to the parent, and the relationship between each Document and its containing iframe must be managed.
In Omoikane, we walk up the ancestor iframes to check permissions, and we add both the child element that is the target and the parent iframe that contains it to the fullscreen management system.
We handle same‑origin cases, allowfullscreen, allow, and fullscreen specifications, and we have also tested rejections caused by fullscreen 'none'. However, this does not mean that the entire Permissions Policy has been fully implemented.
In Shadow DOM, instead of exposing the internal elements directly, the target is remapped according to the requester.
In the regression test this time, we confirmed that when an element inside a ShadowRoot enters fullscreen, document.fullscreenElement returns the host element, while shadowRoot.fullscreenElement returns the internal target element.
At this point, a single fullscreen flag is no longer sufficient. Note that the current termination mechanism simply clears the entire fullscreen stack, and we have not verified complete conformance with the standard for cases where multiple elements are exited fullscreen in stages.
Chunk Boundaries Become an Issue in Character‑Encoding Conversion
The other side is the TextEncoderStream/TextDecoderStream specification.
TextEncoderStream and TextDecoderStreamTextEncoderStream converts a string to a UTF‑8 byte sequence, while TextDecoderStream converts a byte sequence of a given character encoding back to a string. Instead of feeding the entire input at once, the data can be supplied incrementally as a stream.
In this context, the boundaries of input chunks do not necessarily align with character boundaries.
For example, the UTF‑8 encoding of “水” is represented in hexadecimal as E6 B0 B4.
chunk 1: E6 B0
chunk 2: B4
The first chunk alone does not contain a complete character. If an independent decoder were created for each chunk, it would be treated as a malformed string that was cut off mid‑character.
What is needed is to retain the first two bytes and process the remainder in the subsequent chunk. It is also necessary to distinguish between a state where the input is still ongoing and a state where it has truly ended.
Advancing Read and Write in Parallel
An example of actually splitting and passing chunks looks like this:
async function collect(stream, chunks) {
const reader = stream.readable.getReader();
const reading = (async () => {
const output = [];
for (;;) {
const { value, done } = await reader.read();
if (done) return output;
output.push(value);
}
})();
const writer = stream.writable.getWriter();
for (const chunk of chunks) await writer.write(chunk);
await writer.close();
return reading;
}
const decoded = await collect(new TextDecoderStream(), [
new Uint8Array([0xe6, 0xb0]),
new Uint8Array([0xb4]),
]);
console.log(decoded.join("")); // 水
It is a bit lengthy, but the important point is that the read loop starts before the write operation.
Backpressure in TransformStream is a mechanism that makes the writer wait until the read side demands data. By default, the high‑water mark of the readable side is set to 0, so the transformation does not proceed unless a read request is made.
If you wait until writer.write() has completed before starting to read, the two sides can end up waiting for each other.
In this release, we also modified Omoikane’s TransformStream to wait for a read request before performing the conversion. Even for chunks of incomplete characters whose output has not yet been produced, this waiting is applied.
In the tests, we write an object with side effects to toString() and verify that the stringification itself does not occur until a read request is made. Relying only on the length of the output queue would cause us to overlook the problem of the conversion proceeding prematurely.
There Is Also State Carried Over on the Encoder Side
Even when the input is a JavaScript string, boundary issues can still arise.
In JavaScript strings, a UTF‑16 surrogate pair can be split across different chunks.
const encoded = await collect(new TextEncoderStream(), [
"\uD83D",
"\uDE00",
"\uD800",
]);
console.log(encoded.flatMap(chunk => Array.from(chunk)));
// [240, 159, 152, 128, 239, 191, 189]
This example uses the previous collect(). The first two strings, when combined, form U+1F600. The trailing \uD800 has no corresponding continuation.
In Omoikane, a high surrogate at the end of a chunk is temporarily held over and passed to the next chunk. If the stream closes while the surrogate is still pending, it is output as the replacement character U+FFFD.
The first four bytes of the output become the UTF‑8 encoding of U+1F600, and the final three bytes become the UTF‑8 encoding of U+FFFD.
Even when an empty‑string chunk is interleaved, we ensure that the pending surrogate is not lost. Simply passing each chunk to TextEncoder.encode() would not achieve this behavior.
Who Holds the Decoder State in Rust
The decoding side uses the existing dependency encoding_rs. Not only UTF‑8 but also Shift_JIS, UTF‑16, ISO‑2022‑JP, and other encodings are processed with a stateful decoder.
The Rust‑side state is small, and its essential structure is as follows.
#[derive(Trace, Finalize, JsData)]
struct DecoderState {
#[unsafe_ignore_trace]
decoder: Option<Decoder>,
fatal: bool,
}
Decoder is a native state that does not reference any JavaScript objects, so it is excluded from the internal traversal by Boa’s garbage collector. The JsObject that holds this state is referenced from the stream’s closure.
Instead of placing the decoder in a host‑side persistent ID table, we keep it alive only while it is reachable from the stream. On termination, error, cancel, or abort, the retained reference is released.
If this is handled incorrectly, the decoder may disappear before the next chunk arrives, or the state of a discarded stream may linger.
In the regression tests, we write the first half of a 4‑byte UTF‑8 character, invoke boa_gc::force_collect() in the middle, then write the second half and verify that it is correctly decoded. This cannot be confirmed using only short, non‑GC‑inducing normal cases.
In the
Decoder implementation
, the handling of input chunks and the flush at termination are distinguished by last.
Handling Flush, Errors, and Cancellation Separately
For example, if a UTF‑8 character arrives only partially and then the input ends, the default behavior replaces the remaining incomplete character with U+FFFD, but with fatal: true it raises an error instead.
A conversion error such as fatal cannot be handled by failing only the writer side. The reader would be left waiting for new data, so the error must be reported to both the read and write sides of closed.
Conversely, on a normal termination, even if the input has finished, the output may not yet have been fully read. Replacement characters for surrogate pairs, for instance, can be emitted during the flush.
Close the input side
-> Output the remaining data with a flush
-> Drain the output queue
-> Completion of the readable side's closed state
Therefore, ReadableStream distinguishes between the close request and the close completion, preventing the reader from finalizing until the output queue is empty.
This change affects not only character‑encoding conversion but also existing Fetch and WebTransport implementations. Those APIs have paths that discard any remaining queue and terminate, so the consumer side was also updated to ensure that no pending waits remain after the queue is dropped.
For cancel and abort operations, the reason provided is passed through unchanged. Since the reason may be undefined, we do not determine the error state solely by whether a value is present. The regression tests also use both undefined and Error objects.
Do Not Use Page‑Visible Properties as Internal State
When implementing browser‑built‑in APIs, you must also consider cases where the page may replace the built‑in functions.
Even if Array.prototype[Symbol.iterator], String.prototype.slice, or the global TextDecoder have been overwritten, we do not want the internal processing to depend on those functions.
In this implementation, the functions used for string processing are captured at initialization, and the array of internal handles returned by Rust is accessed without relying on the iterator produced by destructuring assignment.
The input buffer is also read not by inferring it from the page‑visible buffer or byteLength getters, but by using Boa’s typed arrays and DataView internal information. The view’s offset and length are also reflected here.
Cases where the page replaces functions, shares buffers, or provides already‑detached input are handled in the Regression tests for the character‑encoding conversion stream .
The Remaining Failures in WPT Were Due to a Different Feature
In this character‑encoding conversion work, we added 11 files and 114 sub‑tests from the Web Platform Tests’ encoding streams.
Among them, a test that used MessagePort.postMessage(buffer, [buffer]) to detach a buffer failed.
This was not because the decoder mis‑interpreted characters, but because the preceding ArrayBuffer transfer was unsupported. In the current MessagePort implementation, an array‑style transfer list is not treated as a transfer, leaving the sender’s buffer intact.
If the input that was supposed to be detached remains, the decoder will emit characters from that leftover data.
This issue has been separated into Issue #763 about ArrayBuffer transfer and detachment . For the decoder itself, we create a truly detached buffer using the engine’s detach API and verify it separately.
In WPT we do not simply exclude the entire target file; instead, we register the names of the two failing sub‑tests exactly as they appear. We also detect any increase in other failures or timeouts, as well as any resolution of the registered failures, so that known failures are not left unattended.
Note that some parts of the helper for the shared buffer include a smoke‑test adapter that connects WebAssembly‑generated code to an existing SharedArrayBuffer. This result is not treated as confirmation of WebAssembly support.
What has been verified this time
The verification results recorded in the PR at the time Fullscreen was integrated are as follows.
| Test | Result and Scope |
|---|---|
| Full test suite equivalent to CI | All suites succeeded, including 2,517 libraries |
| Fixed‑revision WPT smoke | 133 of 134 files succeeded, 1 known failure, 0 regressions |
| Additional WPT for Fullscreen | 4 files succeeded |
| Web API surface probe | 106 of 108 items supported, 2 existing items unsupported, 0 errors/regressions |
| Fullscreen rendering fixture | Pixel difference of 0 compared with the saved baseline |
| GUI configuration | cargo check --locked --features gui succeeded. Visual inspection of the actual window has not been performed |
The number of WPT cases refers to the fixed set of smoke‑test targets and does not represent the entire WPT suite. The 106/108 figure for the Web API surface is also an aggregation from the probe prepared by Omoikane, not the overall support rate for the Web API.
In addition to the latest main build CI , the Browser behavior, Native JIT targets, and JIT release gate have also succeeded.
On September 19 we also organized the development procedures, but the two major features this time were these: Fullscreen connects from the DOM to a native window, and character‑encoding conversion links JavaScript streams to a Rust decoder.
Beyond the point where the method can be called and the normal flow runs once, there is considerable handling of state persistence and cleanup. This time we addressed those aspects together with the tests. Transfer of ArrayBuffer and visual verification of the actual GUI screen are still pending.