From d2e3cd202048243ecff52a3d6049396e74df3250 Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Thu, 10 Sep 2026 12:19:14 -0400 Subject: [PATCH] fix(babel-plugin): insert constant values verbatim, not as replace patterns A `defineConsts` value was substituted with a string replacement, so `String.prototype.replace`/`replaceAll` expanded the `$` sequences in it: `$&` re-inserted the matched text, `$$` collapsed to a single `$`, and `` $` ``/`$'` inserted the text surrounding the match. Values are data, never replacement patterns. `$&` was the worst case. In the alias pre-collapse, replacing `var(--b)` with `$&` re-inserted `var(--b)` over itself, so the loop made no progress and -- having already cleared `visited` and reset `lastIndex` -- rescanned from the start forever. That hangs the build with no error; the circular reference guard can't catch it, because it detects a cycle within one recursive descent, not a substitution that fails to advance. Two sites needed it: the alias pre-collapse in resolveConstant, and the per-rule substitution. Switching both to function replacers, which are immune to `$` expansion, fixes them without changing anything else. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/transform-process-test.js | 69 +++++++++++++++++++ packages/@stylexjs/babel-plugin/src/index.js | 4 +- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 67c514514..4799ccf16 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -944,6 +944,75 @@ describe('@stylexjs/babel-plugin', () => { `); }); + // A constant's value is data, not a replacement pattern. Substituting it + // with a string replacement made `String.prototype.replace`/`replaceAll` + // expand its `$` sequences: `$&` re-inserted the matched text, `$$` + // collapsed to one `$`, and `` $` ``/`$'` inserted the text around the + // match. Both the alias pre-collapse and the per-rule substitution have + // to insert values verbatim. + test('substitutes a constant value containing `$&` verbatim', () => { + // `$&` re-inserted `var(--b)` over itself, so the pre-collapse loop made + // no progress and rescanned from the start forever -- a hung build, not + // a thrown error. + const rules = [ + ['a', { constKey: 'a', constVal: 'var(--b)', ltr: '', rtl: null }, 0], + ['b', { constKey: 'b', constVal: '$&', ltr: '', rtl: null }, 0], + ['x1', { ltr: '.x1{content:var(--a)}', rtl: null }, 3000], + ]; + + expect( + stylexPlugin.processStylexRules(rules, { useLayers: false }), + ).toMatchInlineSnapshot('".x1{content:$&}"'); + }); + + test('substitutes a constant value containing `$&` with no alias chain', () => { + // Without a chain the pre-collapse loop never runs, but the per-rule + // substitution still expanded `$&` -- leaving the `var()` unsubstituted. + const rules = [ + ['b', { constKey: 'b', constVal: '$&', ltr: '', rtl: null }, 0], + ['x1', { ltr: '.x1{content:var(--b)}', rtl: null }, 3000], + ]; + + expect( + stylexPlugin.processStylexRules(rules, { useLayers: false }), + ).toMatchInlineSnapshot('".x1{content:$&}"'); + }); + + test('substitutes constant values containing other `$` sequences verbatim', () => { + const rules = [ + [ + 'a1', + { constKey: 'a1', constVal: 'var(--b1)', ltr: '', rtl: null }, + 0, + ], + ['b1', { constKey: 'b1', constVal: '$$', ltr: '', rtl: null }, 0], + [ + 'a2', + { constKey: 'a2', constVal: 'var(--b2)', ltr: '', rtl: null }, + 0, + ], + ['b2', { constKey: 'b2', constVal: '$`', ltr: '', rtl: null }, 0], + [ + 'a3', + { constKey: 'a3', constVal: 'var(--b3)', ltr: '', rtl: null }, + 0, + ], + ['b3', { constKey: 'b3', constVal: "$'", ltr: '', rtl: null }, 0], + [ + 'x1', + { + ltr: '.x1{--p1:var(--a1);--p2:var(--a2);--p3:var(--a3)}', + rtl: null, + }, + 3000, + ], + ]; + + expect( + stylexPlugin.processStylexRules(rules, { useLayers: false }), + ).toMatchInlineSnapshot('".x1{--p1:$$;--p2:$`;--p3:$\'}"'); + }); + test('legacy-expand-shorthands duplicates theme selectors for higher precedence', () => { const { _code, metadata } = transform( ` diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index b092b5bf6..93ffd72c5 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -749,7 +749,7 @@ function processStylexRules( if (refValue == null) continue; visited.add(ref); const replacement = resolveConstant(refValue, visited); - result = result.replace(match[0], replacement.toString()); + result = result.replace(match[0], () => replacement.toString()); visited.delete(ref); regex.lastIndex = 0; } @@ -788,7 +788,7 @@ function processStylexRules( for (const [varRef, constValue] of constsMap.entries()) { if (typeof original !== 'string') continue; const replacement = String(constValue); - original = original.replaceAll(varRef, replacement); + original = original.replaceAll(varRef, () => replacement); if (replacement.startsWith('var(') && replacement.endsWith(')')) { const inside = replacement.slice(4, -1).trim(); const commaIdx = inside.indexOf(',');