Skip to content

feat: add TextEncoder/TextDecoder and atob/btoa on a lazy-global tier - #2026

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

feat: add TextEncoder/TextDecoder and atob/btoa on a lazy-global tier#2026
edusperoni wants to merge 2 commits into
mainfrom
feat/text-encoding

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Mirrors NativeScript/ios#448.

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.
  • The run goes through a new per-isolate exports cache, BuiltinLoader::GetExports (backed by a RuntimeState slot — the android analog of ios' Caches state slot), which every entry point to a builtin shares: sibling names from one builtin (TextEncoder + TextDecoder) cost one run, and so does a module re-exporting the same interfaces. The ns:/node: registry's private per-specifier exports map is folded into this cache.
  • Constraint documented in test-app/runtime/src/main/cpp/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 — LazyGlobals::Init runs in PrepareV8Runtime for every isolate; assignment-before-first-read correctly replaces a lazy global (V8 gives setter-less API accessors a reconfigure-to-data setter).

Divergence from ios: no LazyGlobals::IsLazyGlobal interceptor hook. On ios the global metadata interceptor must decline these names so an ObjC symbol sharing a name can't shadow a runtime global; android has no global named-property interceptor (top-level Java namespaces are installed eagerly by MetadataNode::CreateTopLevelNamespaces), so there is nothing to decline.

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.)
  • 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.

ns:util / node:util exposure

Node exposes the encoding interfaces on util, so ns:util and node:util re-export them — as the very class objects the globals hold: require("ns:util").TextDecoder === globalThis.TextDecoder, whichever entry point is reached first, main isolate or worker. The members stay lazy end to end (SetLazyDataProperty on the ns:util binding, getters in ns-util.js/node-util.js), so requiring either module still doesn't run the text-encoding builtin.

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, defined in Util.h). Unlike ios (lite/jitless), android runs the optimizing tiers, so the overload is live here once a call site tiers up. 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.

Notes for reviewers

  • docs/text-encoding.md follows the android docs convention (ios#448 has no docs directory to mirror); docs/ns-builtin-modules.md gets the same util-surface updates as ios'.
  • ios' types/ns-util.d.ts update has no android counterpart (no such types directory here).

Tests

  • Shared suite: bumps common-runtime-tests-app to 0f45dc8 (94 feature-detecting specs; pending, not failing, on runtimes without these globals; per-encoding sub-suites probe constructor support). Independently validated against Node 24 (full ICU) as a conformance reference: 94/94. Wired up via shared.runTextEncodingTests() in mainpage.js.
  • Runtime-local specs mirroring ios' NsUtilTests additions: identity of the module exports and the globals (including a fresh-isolate worker probing both access orders), round trips, and the node:util surface.
  • Full android suite on a local emulator: 1174 tests, 0 failures, with the TextEncoder / TextEncoder.encodeInto / TextDecoder construction / per-encoding decoder / atob / btoa / round-trip suites all running live (not pending).

Mirrors NativeScript/ios#448: native WHATWG TextEncoder/TextDecoder
(utf-8, utf-16le, utf-16be, windows-1252 with full label sets, streaming
decode, exact replacement semantics) and forgiving-base64 atob/btoa,
registered through a new lazy-global tier (LazyGlobals): each global is a
SetLazyDataProperty on the global template, so the builtin behind it is
compiled and run only on first read, once per isolate, with sibling names
sharing the run through a RuntimeState slot.

Unlike ios there is no metadata-interceptor decline hook — android has no
global named-property interceptor, so none is needed. encodeInto registers
a V8 Fast API overload (NATIVESCRIPT_ENABLE_FAST_API, default on), live on
android's JIT tiers. Bumps the shared test suite for the 94 TextEncoding
conformance specs and wires it into mainpage.js.
@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.

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