Skip to content

Fix four compressions that changed what the program does - #15

Merged
marevol merged 1 commit into
mainfrom
fix/js-correctness
Sep 5, 2026
Merged

Fix four compressions that changed what the program does#15
marevol merged 1 commit into
mainfrom
fix/js-correctness

Conversation

@marevol

@marevol marevol commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Four compressions that produced output which parses cleanly and then behaves differentlynode --check passes, the build is green, and the bug surfaces in a browser.

1. Template literals were re-emitted from their cooked value

TemplateCharacters.getValue() is the interpreted text, so putting it back between backticks interpreted every escape a second time.

source output effect
String.raw`\d+\.\d+` String.raw`d+.d+` re.test("3.14") flips truefalse
`price: \${sum}` `price: ${sum}` escaped text becomes a live substitution
`a\\b` `a\b` .length 3 → 2 (\b is backspace)
`x\`y` `x`y` invalid output

getRawValue() carries the source text and is emitted instead. Substitutions are unaffected — they are visited as expressions either way.

2. A free reference was never reserved

A name resolving to no declaration was dropped, so it never reached ScriptOrFnScope.getUsedSymbols() and the munger handed the same name to a local:

function f(){var container="BODY";return a.init(container);}
// -> function f(){var a="BODY";return a.init(a);}     TypeError at run time

The short-name pool starts at single letters, so every one- or two-character global the file does not itself declareL (Leaflet), $, _, d3 — collided with the first local of any function. ScopeBuilder now reserves such names the same way it already reserves a function's own name (reserveNameUpChain).

The decision waits until the whole tree is built: a var or a function declaration hoists over its own uses, so deciding at visit time would have treated function f(){use(x);var x=1;} as free and stopped x being munged.

3. A destructuring default got no scope

declareVariableIdentifiers walks a pattern to declare its bindings, and the VariableDeclaration branch returns without letting the generic traversal descend — so a default value was never visited at all. A function expression in one had no ScriptOrFnScope, its own vars were never declared, and the enclosing scope was free to munge a local onto them:

var {handler = function(){var a=100; return a+longName;}} = opts; var longName=5;

longName was munged to a, which the inner function's own a then shadowed — 105 became 200. Parameter defaults already worked (the generic traversal descends into a FunctionNode's parameters) and are unchanged.

4. A bare integer lost the space before a member access

1 .toString()  ->  1.toString()      // SyntaxError, exit code 0

1..toString() is the same length and needs no whitespace.

The test is made from the literal's own text. A first attempt scanned the output buffer backwards for digits, and that was wrong: 8e-5 ends in a digit whose preceding character is -, so it was called a bare integer and became 8e-5..toFixed(3), which no engine accepts. That regression appeared on two real bundles in the corpus below — not in the unit tests — and is pinned now. Numeric separators count as digits (1_000. is a literal too); ., an exponent, a radix prefix and BigInt n all end the literal unambiguously and are left alone.

Verification

  • 1,247 real-world scripts compressed, every output run through node --check. 39 do not parse — all 39 identically before this change (they use Rhino-only for each syntax that their sources share, so node rejects the sources too). No new failure, no new second-pass instability, and two files that previously failed to compress now succeed.
  • Size: +709 bytes over 11.4 MB (+0.01%) across the 576 that compress. The cost is concentrated in jquery-1.6.4.js: +686 bytes (0.7%), the price of not munging a local onto a global. Size pins and the gap table are updated.
  • ModernJsTest.lineBreakNeverSplitsAStringLiteralAfterNestedSeparatorInsertions was pinning the collision — its fixture's a is a free global and the inner local was munged onto it. Expectation updated with a note saying so.
  • JsSemanticsTest (new, 16 cases) executes the affected programs under node and compares output, so the tests prove the behaviour rather than the spelling; the string assertions stand alone when node is absent.
  • Downstream check: Fess's nine front-end scripts compress to the same bytes as before, all pass node --check, and a call-trace differential against the original sources is identical.

Full suite: 782 tests, 0 failures, 3 skipped.

Independent of #13 and #14; all three branch from main.

@marevol marevol added this to the 2.4.11 milestone Sep 5, 2026
@marevol marevol self-assigned this Sep 5, 2026
Each of these produced output that parsed cleanly and then behaved differently,
which is the failure mode a minifier must not have - node --check passes, the
build is green, and the bug surfaces in a browser.

Template literals were re-emitted from their cooked value

TemplateCharacters.getValue() is the interpreted text, so putting it back
between backticks interpreted every escape a second time:

  String.raw`\d+\.\d+`   ->  String.raw`d+.d+`     regex stops matching
  `price: \${sum}`       ->  `price: ${sum}`       a live substitution
  `a\\b`                 ->  `a\b`                 length 3 becomes 2
  `x\`y`                 ->  `x`y`                 invalid output

getRawValue() carries the source text and is emitted instead. Substitutions are
unaffected; they are visited as expressions either way.

A free reference was never reserved

A name resolving to no declaration was dropped on the floor, so it never reached
ScriptOrFnScope.getUsedSymbols() and the munger handed the same name to a local:

  function f(){var container="BODY";return a.init(container);}
  -> function f(){var a="BODY";return a.init(a);}          TypeError

The short-name pool starts at single letters, so every one- or two-character
global the file does not itself declare - "L", "$", "_", "d3" - collided with
the first local of any function. ScopeBuilder now reserves such names the same
way it already reserves a function's own name, up the scope chain.

The decision waits until the whole tree is built. A "var" or a function
declaration hoists over its own uses, so deciding at visit time would have
treated "function f(){use(x);var x=1;}" as free and stopped x being munged.

A destructuring default got no scope

declareVariableIdentifiers walks a pattern to declare its bindings, and the
VariableDeclaration branch returns without letting the generic traversal
descend - so a default value was never visited. A function expression in one
therefore had no ScriptOrFnScope, its own vars were never declared, and the
enclosing scope was free to munge a local onto them:

  var {handler = function(){var a=100; return a+longName;}} = opts; var longName=5;

munged longName to "a", which the inner function's own "a" then shadowed: 105
became 200. Parameter defaults already worked - the generic traversal descends
into a FunctionNode's parameters - and are unchanged.

A bare integer lost the space before a member access

  1 .toString()  ->  1.toString()      SyntaxError, exit code 0

"1..toString()" is the same length and needs no whitespace. The test is made
from the literal's own text: a first attempt scanned the output buffer
backwards for digits, which called "8e-5" a bare integer because the character
before its last digit is "-" and produced "8e-5..toFixed(3)". That regression
showed up on two real bundles in the corpus below, not in the unit tests, and
is pinned now. Numeric separators count as digits ("1_000." is a literal too);
"." , an exponent, a radix prefix and BigInt "n" all end the literal
unambiguously and are left alone.

Verification

- 1,247 real-world scripts compressed and every output run through node --check.
  39 do not parse, all 39 identically before this change (they use Rhino-only
  "for each" syntax their sources share). No new failure, no new second-pass
  instability, and two files that previously failed to compress now succeed.
- Total output over the 576 that compress: +709 bytes on 11.4 MB (+0.01%). The
  cost is concentrated in jquery-1.6.4.js, +686 bytes (0.7%), which is the price
  of not munging a local onto a global; the size pins and the gap table are
  updated.
- ModernJsTest.lineBreakNeverSplitsAStringLiteralAfterNestedSeparatorInsertions
  was pinning the collision - its fixture's "a" is a free global and the inner
  local was munged onto it. Expectation updated with a note.
- JsSemanticsTest executes the affected programs under node and compares output,
  so the tests prove the behaviour rather than the spelling.
@marevol
marevol merged commit 0d03d39 into main Sep 5, 2026
6 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.

1 participant