Skip to content

fix(native-preview): preserve lone surrogate string literals - #3518

Merged
Jake Bailey (jakebailey) merged 12 commits into
microsoft:mainfrom
TorinAsakura:fix/wtf8-surrogate-literals
Jun 30, 2026
Merged

fix(native-preview): preserve lone surrogate string literals#3518
Jake Bailey (jakebailey) merged 12 commits into
microsoft:mainfrom
TorinAsakura:fix/wtf8-surrogate-literals

Conversation

@TorinAsakura

@TorinAsakura Andrew Ghostuhin (TorinAsakura) commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #1701
Close torin-asakura/workspace#118.

Summary

  • Retarget this PR after Fix a slew of UTF-8/UTF-16 related issues #4181 to the remaining binary AST/native-preview decode path
  • Keep the binary AST protocol version at 5; the Go side already emits the existing bytes and wraps binary AST responses as base64 for JSON-RPC
  • Document string data as UTF-8 with WTF-8 for JS strings containing lone UTF-16 surrogates
  • Decode binary AST string table and msgpack strings with a WTF-8-aware decoder in native-preview
  • Keep the common UTF-8 path on native TextDecoder when the byte buffer has no WTF-8 surrogate lead byte
  • Add native-preview regression coverage for surrogate pairs and lone high/low surrogates in strings and templates

Tests

  • npx hereby tsgo:build
  • go test ./internal/api/encoder
  • npm run -w @typescript/native-preview node -- --test test/wtf8.test.ts test/encoder.test.ts
  • npm run -w @typescript/native-preview node -- --test --test-name-pattern "unicode escapes|template unicode escapes" test/async/api.test.ts test/sync/api.test.ts
  • npx hereby lint
  • TSGO_HEREBY_NOEMBED=true npx hereby lint

Decoder benchmark

Local Node v25.9.0 microbenchmark, 80 decode iterations per case:

Case Native TextDecoder WTF-8 decoder Overhead
ASCII TS source, 4 MiB 66.91 ms 43.54 ms -34.9%
Valid Unicode source, 4 MiB 292.42 ms 299.45 ms +2.4%
Valid UTF-8 with non-surrogate 0xED, 1 MiB 521.71 ms 816.63 ms +56.5%
Sparse WTF-8 surrogate, 4 MiB 620.79 ms 1731.19 ms +178.9%

The first two rows cover the common path: when there is no surrogate lead byte, the decoder falls back to native TextDecoder. The latter rows cover the uncommon path where the buffer contains 0xED bytes and requires JS-side WTF-8 handling or validation.

Verification logs
$ git rev-parse --short HEAD
94fe912d9

$ npx hereby tsgo:build
Using ./Herebyfile.mjs to run tsgo:build
Starting tsgo:build
[13:49:15.917] [0] $ go build '-tags=noembed' -o ./built/local/ ./cmd/tsgo
[13:49:32.619] [0] ✔ (done in 16.7s)
Finished tsgo:build in 16.7s
Completed tsgo:build in 16.7s

$ go test ./internal/api/encoder
ok  	github.com/microsoft/typescript-go/internal/api/encoder	1.510s

$ npm run -w @typescript/native-preview node -- --test test/wtf8.test.ts test/encoder.test.ts
> @typescript/native-preview@0.0.0 node
> node --experimental-strip-types --no-warnings --conditions @typescript/source --test test/wtf8.test.ts test/encoder.test.ts

▶ Encoder
  ✔ protocol version is 5 (0.151709ms)
✔ Encoder (17.470875ms)
▶ Wtf8Decoder
  ✔ decodes standard UTF-8 (2.23675ms)
  ✔ preserves WTF-8 encoded lone surrogates (1.537459ms)
✔ Wtf8Decoder (5.664541ms)
ℹ tests 18
ℹ pass 18
ℹ fail 0
ℹ duration_ms 836.668708

$ npm run -w @typescript/native-preview node -- --test --test-name-pattern "unicode escapes|template unicode escapes" test/async/api.test.ts test/sync/api.test.ts
> @typescript/native-preview@0.0.0 node
> node --experimental-strip-types --no-warnings --conditions @typescript/source --test --test-name-pattern 'unicode escapes|template unicode escapes' test/async/api.test.ts test/sync/api.test.ts

✔ unicode escapes (1589.288125ms)
✔ template unicode escapes (121.58075ms)
✔ unicode escapes (1586.489417ms)
✔ template unicode escapes (99.352667ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
ℹ duration_ms 2953.122583

$ npx hereby lint
Using ./Herebyfile.mjs to run lint
Starting lint
[13:50:20.350] [0] $ ./_tools/custom-gcl run
0 issues.
[13:50:41.668] [0] ✔ (done in 21.3s)
Linting _tools
[13:50:41.670] [1] $ ./_tools/custom-gcl run
0 issues.
[13:50:42.335] [1] ✔ (done in 666ms)
Finished lint in 22s
Completed lint in 22s

$ TSGO_HEREBY_NOEMBED=true npx hereby lint
Using ./Herebyfile.mjs to run lint
Starting lint
[13:50:20.350] [0] $ ./_tools/custom-gcl run --build-tags noembed
0 issues.
[13:50:38.089] [0] ✔ (done in 17.7s)
Linting _tools
[13:50:38.091] [1] $ ./_tools/custom-gcl run --build-tags noembed
0 issues.
[13:50:38.979] [1] ✔ (done in 888ms)
Finished lint in 18.6s
Completed lint in 18.6s

@TorinAsakura Andrew Ghostuhin (TorinAsakura) changed the title Preserve lone surrogates in native preview string literals Fix native preview lone surrogate string literals Apr 23, 2026
@TorinAsakura Andrew Ghostuhin (TorinAsakura) changed the title Fix native preview lone surrogate string literals fix(native-preview): preserve lone surrogate string literals Apr 23, 2026
@TorinAsakura

This comment was marked as outdated.

Copilot AI review requested due to automatic review settings April 30, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses lossy handling of JS string literals containing lone UTF-16 surrogate escapes by encoding such strings as WTF-8 in the binary AST protocol, updating native-preview decoding accordingly, and bumping the protocol version.

Changes:

  • Add Go-side reconstruction of literal text from raw source and encode lone surrogates as WTF-8 bytes when emitting binary AST strings.
  • Update native-preview to decode protocol/msgpack strings with a WTF-8-aware decoder and bump protocol version from 5 to 6.
  • Add regression tests in both Go and native-preview for surrogate pairs and lone surrogates.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/api/encoder/literal_text.go Adds escape decoding + WTF-8 emission for surrogate code units based on raw literal text.
internal/api/encoder/encoder.go Bumps protocol version to 6, documents WTF-8, and uses new literal-text encoding for string/template literals.
internal/api/encoder/encoder_test.go Adds a Go regression test asserting WTF-8 bytes are preserved in encoded string literal text.
_packages/native-preview/src/api/node/wtf8.ts Introduces a WTF-8-capable decoder to preserve lone surrogates when decoding bytes to JS strings.
_packages/native-preview/src/api/sync/api.ts Switches RemoteSourceFile decoding from TextDecoder to Wtf8Decoder.
_packages/native-preview/src/api/async/api.ts Switches RemoteSourceFile decoding from TextDecoder to Wtf8Decoder.
_packages/native-preview/src/api/node/node.ts Uses Wtf8Decoder when decoding node payloads into RemoteSourceFile.
_packages/native-preview/src/api/node/msgpack.ts Uses Wtf8Decoder for msgpack string decoding.
_packages/native-preview/src/api/node/protocol.ts Updates native-preview protocol version constant to 6.
_packages/native-preview/test/wtf8.test.ts Adds direct unit coverage for WTF-8 decoding behavior.
_packages/native-preview/test/sync/api.test.ts Adds sync API regression coverage for a string literal containing lone surrogate escapes.
_packages/native-preview/test/async/api.test.ts Adds async API regression coverage for a string literal containing lone surrogate escapes.
_packages/native-preview/test/encoder.test.ts Updates tests to expect protocol version 6.
Comments suppressed due to low confidence (1)

_packages/native-preview/src/api/node/msgpack.ts:114

  • MsgpackReader now decodes strings with Wtf8Decoder, but MsgpackWriter still encodes strings with TextEncoder, which replaces lone surrogates with U+FFFD. If any msgpack string payloads can contain lone surrogates (or if callers round-trip data that now preserves them), this will be lossy. Consider introducing a WTF-8 encoder (paired with Wtf8Decoder) and using it in MsgpackWriter.writeString (and other protocol string encoders like the AST StringTable) to keep the protocol symmetric.
const encoder = new TextEncoder();
const decoder = new Wtf8Decoder();

export class MsgpackWriter {
    private buf: Uint8Array;
    private view: DataView;
    private pos: number;

    constructor(initialSize = 256) {
        this.buf = new Uint8Array(initialSize);
        this.view = new DataView(this.buf.buffer);
        this.pos = 0;
    }

    private ensure(n: number): void {
        if (this.pos + n > this.buf.length) {
            let newSize = this.buf.length * 2;
            while (newSize < this.pos + n) newSize *= 2;
            const next = new Uint8Array(newSize);
            next.set(this.buf);
            this.buf = next;
            this.view = new DataView(this.buf.buffer);
        }
    }

    writeArrayHeader(length: number): void {
        if (length <= 0x0f) {
            this.ensure(1);
            this.buf[this.pos++] = 0x90 | length;
        }
        else if (length <= 0xffff) {
            this.ensure(3);
            this.buf[this.pos++] = 0xdc;
            this.view.setUint16(this.pos, length, false);
            this.pos += 2;
        }
        else {
            this.ensure(5);
            this.buf[this.pos++] = 0xdd;
            this.view.setUint32(this.pos, length, false);
            this.pos += 4;
        }
    }

    writeUint(value: number): void {
        if (value <= 0x7f) {
            this.ensure(1);
            this.buf[this.pos++] = value;
        }
        else if (value <= 0xff) {
            this.ensure(2);
            this.buf[this.pos++] = 0xcc;
            this.buf[this.pos++] = value;
        }
        else if (value <= 0xffff) {
            this.ensure(3);
            this.buf[this.pos++] = 0xcd;
            this.view.setUint16(this.pos, value, false);
            this.pos += 2;
        }
        else {
            this.ensure(5);
            this.buf[this.pos++] = 0xce;
            this.view.setUint32(this.pos, value, false);
            this.pos += 4;
        }
    }

    writeString(str: string): void {
        const encoded = encoder.encode(str);
        const len = encoded.length;
        if (len <= 0x1f) {

Comment thread internal/api/encoder/literal_text.go Outdated
Comment thread internal/api/encoder/encoder.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Comment thread internal/api/encoder/literal_text.go Outdated
Comment thread internal/api/encoder/literal_text.go Outdated
Comment thread _packages/native-preview/src/api/node/wtf8.ts Outdated
Comment thread _packages/native-preview/src/api/node/wtf8.ts Outdated
Comment thread _packages/native-preview/src/api/node/wtf8.ts Outdated
Comment thread _packages/native-preview/src/api/node/wtf8.ts Outdated
Comment thread _packages/native-preview/src/api/node/wtf8.ts Outdated
@jakebailey

Copy link
Copy Markdown
Member

Just so I confirm I'm reading this correctly... all that is changing is what text decoder is used? I don't see any Go side changes, which implies we are already in Go doing the right thing, and also base64 encoding. In which case, I don't think we even need a version bump, as this is arguably just a client bugfix?

A perf comparison would be helpful, since you are theoretically replacing the native text decoder with one written in JS, which means it could be slower to transfer data.

@TorinAsakura

Copy link
Copy Markdown
Contributor Author

Just so I confirm I'm reading this correctly... all that is changing is what text decoder is used? I don't see any Go side changes, which implies we are already in Go doing the right thing, and also base64 encoding. In which case, I don't think we even need a version bump, as this is arguably just a client bugfix?

A perf comparison would be helpful, since you are theoretically replacing the native text decoder with one written in JS, which means it could be slower to transfer data.

You’re reading it correctly. After #4181, the Go side is already producing the bytes we need, and async JSON-RPC wraps the binary AST response as base64, so this PR is now just a native-preview client decode fix.

I removed the protocol version bump and kept protocol version 5.

I also added a fast path so buffers without the WTF-8 surrogate lead byte stay on native TextDecoder. Local Node v25.9.0 microbenchmark, 80 decode iterations per case:

Case Native TextDecoder WTF-8 decoder Overhead
ASCII TS source, 4 MiB 66.91 ms 43.54 ms -34.9%
Valid Unicode source, 4 MiB 292.42 ms 299.45 ms +2.4%
Valid UTF-8 with non-surrogate 0xED, 1 MiB 521.71 ms 816.63 ms +56.5%
Sparse WTF-8 surrogate, 4 MiB 620.79 ms 1731.19 ms +178.9%

So the common path remains effectively native TextDecoder speed. The slower path is limited to buffers containing 0xED, where we either need WTF-8 handling or need to validate that the byte sequence is not a surrogate triplet.

Given that, I think this PR is correctly scoped as a client-side native-preview bugfix rather than a protocol change.

@jakebailey

Copy link
Copy Markdown
Member

I do wonder a bit if that can be sped up by basically splitting the file whenever these characters are seen and just concatting as needed, but that does sound pretty annoying.

Andrew Branch (@andrewbranch) Do you have any concerns about this? I think I had thought this was intractable, but did not realize that binary data was already sent over as non-text.

@andrewbranch

Copy link
Copy Markdown
Member

This looks right to me, but Copilot points out that 0xED is also common in Korean Hangul syllables without being a surrogate, so suggests looking for the second byte before taking the slow path 😄

@jakebailey

Copy link
Copy Markdown
Member

We already use surrogateUTF8Lead in Go to see if we need to decode. If that's the case then we have a wider problem and should probably fix it in both places? Maybe not in this PR unless it's easy

@andrewbranch

Copy link
Copy Markdown
Member

I think it would be as easy as checking the range of the byte after surrogateUTF8Lead when it's present, but it's not a blocker for me.

@jakebailey
Jake Bailey (jakebailey) added this pull request to the merge queue Jun 30, 2026
@jakebailey

Copy link
Copy Markdown
Member

Yes, later would be good, both at the same time

Merged via the queue into microsoft:main with commit 90c2724 Jun 30, 2026
21 checks passed
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.

surrogate pair and lone surrogate support in stringLiteral

5 participants