Skip to content

feat(runtime): lazy-global tier with native TextEncoder/TextDecoder and atob/btoa - #448

Draft
edusperoni wants to merge 3 commits into
mainfrom
feat/text-encoding
Draft

feat(runtime): lazy-global tier with native TextEncoder/TextDecoder and atob/btoa#448
edusperoni wants to merge 3 commits into
mainfrom
feat/text-encoding

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Adds native, WHATWG-conformant TextEncoder, TextDecoder, atob and btoa globals — and, more importantly, the lazy-global tier they ride on, which is the foundation for bringing further web globals (Blob, fetch, crypto, DOMException, …) into the runtime with zero cost when unused.

Lazy-global tier (LazyGlobals)

  • Each global is registered on the global template with SetLazyDataProperty before Context::New: the builtin behind it is not compiled, run, or allocated until app code first reads the name, and V8 then replaces the property with a plain data property so later reads cost nothing.
  • Sibling names from one builtin (TextEncoder + TextDecoder) share a single run per isolate via a Caches state slot.
  • The global metadata interceptor declines every name the tier owns (table-driven, same mechanism as the inline-functions whitelist), so an ObjC symbol sharing a name can never shadow a runtime global.
  • Constraint documented in runtime/js/README.md: lazy builtins run at arbitrary times, so they may only consume internals keys published by eager builtins.
  • Workers get the same globals through per-isolate init; assignment-before-first-read correctly replaces a lazy global (V8 gives setter-less API accessors a reconfigure-to-data setter).

TextEncoder / TextDecoder

Node's split: js/text-encoding.js owns the WebIDL surface (brand checks via private fields, enumerable prototype members, Symbol.toStringTag), TextEncoding.cpp owns the bytes.

  • Encodings: utf-8, utf-16le, utf-16be, windows-1252 with their complete WHATWG label sets; unknown labels throw RangeError. (Precedent: Node without ICU ships utf-8/utf-16le; utf-16be and windows-1252 are cheap, and windows-1252 covers the ascii/latin1/iso-8859-1 aliases web code actually uses. More encodings can follow via CFString if ever needed.)
  • Full streaming decode(…, {stream}): incomplete sequences (including split BOMs and split utf-16 code units) carry across calls in a 16-byte Uint8Array the builtin owns — no per-instance native handle, no finalizer.
  • Exact replacement semantics: hand-rolled WHATWG utf-8 state machine with one U+FFFD per maximal invalid subpart; fatal throws TypeError; ignoreBOM honored.
  • encode() / encodeInto() with correct USV conversion and partial-write boundaries (never splits an encoded code point).
  • Fast paths: pure-ASCII utf-8 and C1-free windows-1252 decode straight through String::NewFromOneByte; results downgrade to one-byte strings when possible.

atob / btoa

WHATWG forgiving-base64 in Base64.cpp (whitespace stripping, padding rules, alphabet validation). With no DOMException in the runtime yet, failures throw the name-patched Error (InvalidCharacterError) stand-in the other builtins already use — a follow-up PR will introduce DOMException and upgrade these plus AbortSignal's reasons.

V8 Fast API

encodeInto registers a v8::CFunction fast-call overload behind NATIVESCRIPT_ENABLE_FAST_API (default on). It is inert on iOS, which runs V8 in lite/jitless mode, but positions the runtime for JIT-enabled embeds (macOS/Catalyst). This build's V8 restricts fast returns to scalars, so the string-returning ops (decode, atob, btoa) have no fast overload — current Node makes the same call in its encoding binding.

Tests

  • Shared suite: common-runtime-tests-app 0f45dc8 adds 94 feature-detecting specs (pending, not failing, on runtimes without these globals; per-encoding sub-suites probe constructor support so runtimes with different encoding coverage still pass). Independently validated against Node 24 (full ICU) as a conformance reference: 94/94.
  • Full iOS suite: 1470 tests, 0 failures (baseline before: 1376/0).

ns:util / node:util

Matching Node, both util modules export TextEncoder and TextDecoder — and they are the very objects the globals hold (require('node:util').TextDecoder === globalThis.TextDecoder, whichever is reached first). Guaranteeing that identity unified the two builtin-exports caches (the lazy tier's and the module registry's) into a single BuiltinLoader::GetExports that runs a builtin at most once per isolate, building its native binding only when the run actually happens. The exports stay lazy on the modules too: requiring util does not run the text-encoding builtin; the first read of util.TextEncoder does. (atob/btoa deliberately stay off util — Node keeps those on buffer.)

Adds a lazy-global tier and the first four globals on it. LazyGlobals
registers each name on the global template as a lazy data property, so the
builtin behind it is not compiled, run or allocated until app code first
reads the name; V8 then replaces the property with a plain data property.
Sibling names share one run per isolate through a Caches state slot, and the
metadata interceptor declines every name the tier owns.

TextEncoder/TextDecoder follow Node's split: text-encoding.js owns the WebIDL
shapes and TextEncoding.cpp the bytes — the complete WHATWG label sets for
utf-8, utf-16le, utf-16be and windows-1252, a hand-rolled utf-8 decode state
machine with per-maximal-subpart replacement, the shared utf-16 decoder, BOM
handling and full streaming. Per-decoder state is a Uint8Array the builtin
owns, so no instance needs a native handle. atob/btoa sit on the WHATWG
forgiving-base64 codec in Base64.cpp and, with no DOMException in the
runtime yet, fail with the name-patched Error stand-in the other builtins use.

encodeInto registers a v8::CFunction fast-call overload behind
NATIVESCRIPT_ENABLE_FAST_API. It is inert on iOS, which runs V8 jitless.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Node puts the two encoding interfaces on util, so both the standard module and
its shim carry them, and they are the very objects the globals of those names
hold: require("node:util").TextDecoder === globalThis.TextDecoder, whichever is
reached first.

That identity needs one cache. The lazy-global tier had its own per-isolate
exports slots and the builtin-module registry another one keyed by specifier,
so a builtin reached through both would have run twice and exported two sets of
classes. Both now go through BuiltinLoader::GetExports, which runs a builtin at
most once per isolate — with its binding, built only when the run actually
happens — and hands back that one module.exports. TextEncoding and Base64 own
the accessor for their file; the registry gained a per-specifier binding factory
in place of the switch, which is what let the two schemes converge.

Requiring util still costs nothing extra: ns:util's binding carries the two
names as lazy data properties and both files keep the read inside a getter, so
the text-encoding builtin runs on the first read of util.TextEncoder, not on
the require.
edusperoni added a commit to NativeScript/android that referenced this pull request Aug 24, 2026
Matches the updated NativeScript/ios#448. The lazy tier's private exports
cache generalizes into BuiltinLoader::GetExports, one per-isolate cache
every entry point to a builtin shares — the ns:/node: module registry
(whose per-specifier exports map it replaces), the lazy globals, and any
binding factory. ns:util re-exports TextEncoder/TextDecoder as the very
class objects the globals hold, lazily end to end (SetLazyDataProperty on
the binding, getters in ns-util.js/node-util.js), and node:util forwards
them as Node does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant