Skip to content

fix(transpiler): preserve non-ASCII in String.raw and RegExp.source - #31394

Closed
kernoeb wants to merge 3 commits into
oven-sh:mainfrom
kernoeb:fix-18115-string-raw-non-ascii
Closed

kernoeb wants to merge 3 commits into
oven-sh:mainfrom
kernoeb:fix-18115-string-raw-non-ascii

Conversation

@kernoeb

@kernoeb kernoeb commented May 25, 2026 •

Copy link
Copy Markdown

Fixes #18115.

Repro

$ bun -e 'console.log(String.raw`╭─╮`.length, /╭─╮/.source.length)'
# before: 18 18
# after:   3  3
$ bun -e 'const html = String.raw; console.log(html`Redémarrage`)'
# before: Red\u00E9marrage   (length 16)
# after:  Redémarrage         (length 11)

Cause

Two layers cooperated to mangle the bytes:

  1. Printer. print_raw_template_literal and print_reg_exp_literal in src/js_printer/lib.rs re-escaped codepoints > 0x7F to \uXXXX. Both TemplateStringsArray.raw (surfaced via String.raw) and RegExp.prototype.source are spec-required to expose source bytes verbatim, so escaping silently changed the runtime string values.

  2. JSC handoff. Five sites cloned the printer output (or raw source.contents) as Latin-1 unconditionally. Once the printer emits UTF-8 bytes verbatim, clone_latin1 mis-tags them (Redémarrage becomes Redémarrage).

Fix

  • print_raw_template_literal / print_reg_exp_literal now write source bytes verbatim.

  • All five clone_latin1 sites that ingest printer output or pre-bundled source contents switch to clone_utf8, which auto-detects all-ASCII → Latin-1 (identical to the old fast path, zero cost) and non-ASCII → UTF-16:

    • RuntimeTranspilerStore::transpile (printer output)
    • RuntimeTranspilerStore already-bundled path
    • AsyncModule::fulfill (async printer output)
    • transpile_source_code_inner (sync printer output, jsc_hooks)
    • transpile_source_code_inner already-bundled path (jsc_hooks)
  • RuntimeTranspilerCache::put() also stored printer output via clone_latin1, so cached entries written before this fix mis-tag UTF-8 as Latin-1. Switched to clone_utf8 and bumped EXPECTED_VERSION 22 → 23 to invalidate stale entries on read.

Test plan

  • bun bd test test/regression/issue/18115.test.ts — 2/2 pass (String.raw and RegExp.source across Latin-1, BMP CJK, box-drawing, astral planes).
  • bun bd test test/bundler/transpiler/transpiler.test.js — 157/157 including the new printer-level assertions covering é, 中, ╭─╮, 🐰.
  • bun bd test test/cli/run/transpiler-cache.test.ts — 9/9.
  • Same tests fail under USE_SYSTEM_BUN=1 — gate confirmed.

Performance

Apples-to-apples, same commit, vanilla vs patched, M-series ARM64, release build:

Workload Δ
2.3 MB realistic French file (no String.raw, lots of accents in strings/comments) +0.2% (noise, σ ≈ 0.2 ms)
450 KB pure-ASCII transpile −0.3% (noise)
510 KB adversarial heavy-String.raw file +3.9%

The +4% on String.raw-heavy files comes from the unavoidable UTF-8 → UTF-16 transcode (correct output cannot be served as Latin-1 when a codepoint > 0xFF appears). Tried a Latin-1-first fast path in BunString__fromUTF8 — measured worse, reverted (simdutf convert_utf8_to_latin1 validates every byte, exceeding the alloc saving).

@claude claude Bot 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

The printer escaped non-ASCII codepoints to `\uXXXX` when emitting raw
template literals and regex literals. Per spec, both
`TemplateStringsArray.raw` (surfaced via `String.raw`) and
`RegExp.prototype.source` must expose source bytes verbatim, so the
escape changed runtime string values:

  bun -e 'console.log(String.raw`╭─╮`.length, /╭─╮/.source.length)'
  # before: 18 18
  # after:   3  3

Two layers cooperated to mangle the bytes:

1. The printer (`print_raw_template_literal`, `print_reg_exp_literal`
   in `src/js_printer/lib.rs`) re-escaped codepoints > 0x7F. Both are
   spec-required to expose source bytes verbatim, so they now write
   them through unchanged.

2. Five sites cloned the printer output (or already-bundled source
   contents) as Latin-1 unconditionally, mis-tagging UTF-8 bytes once
   the printer started emitting them verbatim. Replaced with
   `clone_utf8`, which auto-detects all-ASCII → Latin-1 (zero-cost,
   identical to the old fast path) and non-ASCII → UTF-16.

The runtime transpiler cache also stored printer output via
`clone_latin1` in `RuntimeTranspilerCache::put()`, so on-disk entries
written before this fix mis-tag UTF-8 as Latin-1. Bumped
`EXPECTED_VERSION` 22 → 23 to invalidate stale entries on read.

Fixes oven-sh#18115.
@kernoeb
kernoeb force-pushed the fix-18115-string-raw-non-ascii branch from dbfcb1b to 87f3c98 Compare May 25, 2026 15:38
@coderabbitai

coderabbitai Bot commented May 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8f811498-faa8-489e-b71f-aed644b0ecd7

📥 Commits

Reviewing files that changed from the base of the PR and between 0630a51 and 4ba4238.

📒 Files selected for processing (2)
  • src/jsc/RuntimeTranspilerCache.rs
  • test/regression/issue/18115.test.ts

Walkthrough

Printer now emits raw bytes for template and regex literals; transpiler and runtime paths decode printer output as UTF‑8; runtime transpiler cache format/version updated; tests added to verify non‑ASCII characters are preserved verbatim.

Changes

Non-ASCII Preservation in Raw Literals and Transpiler Pipeline

Layer / File(s) Summary
Printer raw literal output
src/js_printer/lib.rs
print_raw_template_literal and print_reg_exp_literal now emit raw bytes verbatim for all modes and platforms, removing conditional escaping and platform-specific non-ASCII translation.
Transpiler and runtime UTF-8 decoding
src/jsc/AsyncModule.rs, src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs
ResolvedSource.source_code construction switches from Latin-1 to UTF-8 decoding in all paths (async module load, bundling fast-path, printed output, bytecode cache hit, final transpiled output). Comments note UTF-8 is required for raw non-ASCII from String.raw / RegExp.source.
Cache format version and serialization
src/jsc/RuntimeTranspilerCache.rs
Cache version bumped to 23; cached output is cloned as UTF-8 instead of Latin-1/ASCII, and logging reports character counts to reflect UTF-8 handling.
Non-ASCII preservation test coverage
test/bundler/transpiler/transpiler.test.js, test/regression/issue/18115.test.ts
New tests assert String.raw and RegExp.source retain accented Latin, CJK, box-drawing, and emoji characters verbatim, and a disk-backed transpiler-cache test verifies identical non-ASCII output across cache restore.

Suggested reviewers

  • Jarred-Sumner
  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: fixing non-ASCII preservation in String.raw and RegExp.source, which directly addresses the core issue.
Description check ✅ Passed The PR description thoroughly covers the repro steps, root cause analysis, implemented fix with specific file changes, test coverage, and performance impact.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #18115 by fixing String.raw and RegExp.source to expose source bytes verbatim, matching Node and JSC behavior expected in the issue.
Out of Scope Changes check ✅ Passed All code changes are directly scoped to fixing #18115: printer modifications, encoding fixes in five JSC handoff sites, cache version bump, and regression tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/RuntimeTranspilerCache.rs (1)

1105-1116: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix vtable RuntimeTranspilerCache::put() mis-encoding of non-ASCII output

The vtable put (line 1108) builds BunString::ascii(output_code_bytes). BunString::ascii is just ZigString::init (no UTF-8 ptr-tag), so RuntimeTranspilerCache::to_file won’t take the UTF-8 path and Entry::save ends up classifying/storing the cache entry as LATIN1—unlike the normal put path (line 1039) which uses BunString::clone_utf8.

Suggested fix
-            // Borrowed Latin-1 view: `to_file` only reads `byte_slice()` + the encoding
-            // tag (unmarked 8-bit ZigString -> Encoding::LATIN1, same as clone_latin1),
-            // and `output_code_bytes` outlives the synchronous `to_file` call.
-            let output_code = BunString::ascii(output_code_bytes);
+            // Keep cache encoding consistent with the non-ASCII printer output path.
+            let output_code = BunString::clone_utf8(output_code_bytes);
+            let _output_code_guard = scopeguard::guard(output_code, |s| s.deref());
             let result = RuntimeTranspilerCache::to_file(
                 this.input_byte_length.unwrap(),
                 this.input_hash.unwrap(),
                 this.features_hash.unwrap(),
                 sourcemap,
                 esm_record,
                 &output_code,
                 this.exports_kind,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/RuntimeTranspilerCache.rs` around lines 1105 - 1116, The vtable
implementation of RuntimeTranspilerCache::put currently constructs output_code
with BunString::ascii(output_code_bytes), which creates a ZigString without the
UTF-8 tag and causes RuntimeTranspilerCache::to_file / Entry::save to treat
non-ASCII as LATIN1; change the construction to use the UTF-8 variant (e.g.,
BunString::clone_utf8 or any constructor that sets the UTF-8 ptr-tag) so
RuntimeTranspilerCache::to_file follows the same UTF-8 path as the normal put
path and non-ASCII output is encoded/stored correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 3643-3644: Add an astral-plane RegExp.source case to the
regression block by adding an assertion using expectPrinted for an emoji regex,
e.g., call expectPrinted("/🐰/.source", "/🐰/.source"); so the test covers an
astral-plane character for RegExp.source alongside the existing BMP cases; place
the new expectPrinted line near the other RegExp.source assertions in
transpiler.test.js.

In `@test/regression/issue/18115.test.ts`:
- Around line 10-21: The test should capture the spawned process's stderr and
only assert exit code last; update the Bun.spawn usage around proc to await
proc.stderr.text() along with proc.stdout.text() and proc.exited (e.g., gather
stdout, stderr, exitCode), and if stdout or exitCode assertions might fail
include a conditional or explicit expect(stderr).toBe("") (or log stderr)
immediately before expect(exitCode).toBe(0); adjust the checks around
proc.stdout.text(), proc.exited, and expect(exitCode).toBe(0) to ensure stderr
is available for diagnostics on failure.

---

Outside diff comments:
In `@src/jsc/RuntimeTranspilerCache.rs`:
- Around line 1105-1116: The vtable implementation of
RuntimeTranspilerCache::put currently constructs output_code with
BunString::ascii(output_code_bytes), which creates a ZigString without the UTF-8
tag and causes RuntimeTranspilerCache::to_file / Entry::save to treat non-ASCII
as LATIN1; change the construction to use the UTF-8 variant (e.g.,
BunString::clone_utf8 or any constructor that sets the UTF-8 ptr-tag) so
RuntimeTranspilerCache::to_file follows the same UTF-8 path as the normal put
path and non-ASCII output is encoded/stored correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 58c122b5-c4e9-470b-93d4-0edb0e19b674

📥 Commits

Reviewing files that changed from the base of the PR and between a49b308 and dbfcb1b.

📒 Files selected for processing (7)
  • src/js_printer/lib.rs
  • src/jsc/AsyncModule.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/runtime/jsc_hooks.rs
  • test/bundler/transpiler/transpiler.test.js
  • test/regression/issue/18115.test.ts

Comment thread test/bundler/transpiler/transpiler.test.js
Comment thread test/regression/issue/18115.test.ts Outdated
kernoeb added 2 commits May 25, 2026 17:45
…re stderr

- Add `/🐰/.source` to printer test for parity with the String.raw
  astral case.
- Capture and assert stderr in the regression spawn tests for better
  diagnostics on failure.
…gression test

CodeRabbit caught a third site that was missed in the initial fix:
`RuntimeTranspilerCache`'s vtable `put()` (the FFI bridge from the
parser) still used `BunString::ascii`, an unmarked 8-bit ZigString
that `Entry::save` classifies as Latin-1 — mis-tagging non-ASCII
UTF-8 output exactly like the original `clone_latin1` bug.

Switched to `clone_utf8` and added a scopeguard for the WTFStringImpl
refcount, since unlike the non-vtable path the vtable variant does
not transfer ownership of the BunString to `self`.

Added a regression test that:
  - writes a > 4 KiB module containing `String.raw`Redémarrage``
    through the JSC vtable `put()` path (cache miss → write),
  - asserts the cache file was actually created,
  - re-runs with `BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE=1`
    to force read-back from cache (cache hit) and asserts the
    output is identical and well-formed.

Verified the test fails without the fix:
  Expected: "["Redémarrage","╭─╮","🐰"]"
  Received: "["Redémarrage","â­ââ®","ð°"]"
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this, and sorry it sat for so long. Four PRs ended up fixing this bug, so we are consolidating on #33866, which has just been rebased onto current main. It makes the same printer change and the same clone_latin1 -> clone_utf8 changes as this PR, and additionally handles the paths that have appeared or changed since (the --hot ref-counted source path, the transpiler cache vtable put, Bun.build artifact text, the --bytecode / --compile / NODE_COMPILE_CACHE bytecode key, and bun test --coverage). Your transpiler cache round-trip test is included there, and you are credited as a co-author on the commit. Closing this one in favour of #33866.

@robobun robobun closed this Aug 13, 2026
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.

String.raw Iterator encoding error

2 participants