Skip to content

Commit d3b131d

Browse files
committed
fix(postcss): preserve source escapes in changed fields
Refs #1508
1 parent c7b0360 commit d3b131d

4 files changed

Lines changed: 207 additions & 4 deletions

File tree

.changeset/bright-ravens-escape.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@linaria/postcss-linaria': patch
3+
---
4+
5+
Preserve source escapes within fields changed by fixers
6+
7+
When a fixer changes part of a selector or declaration value, the stringifier now compares the original and changed CSS escape tokens. Retained source backslashes remain unchanged, while backslashes added by the fixer are still escaped for the surrounding JavaScript template (#1508).

packages/postcss-linaria/__tests__/stringify.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,65 @@ export const style = {
585585
}
586586
});
587587

588+
// https://github.com/callstack/linaria/issues/1508. A fixer can change one
589+
// part of a field while retaining a source-derived escape elsewhere in it.
590+
it('should preserve source backslashes in a declaration changed by a fixer', () => {
591+
const { ast } = createTestAst(`
592+
css\`
593+
.foo {
594+
content: '\\u2022';
595+
}
596+
\`;
597+
`);
598+
const root = ast.nodes[0] as Root;
599+
const rule = root.nodes[0] as Rule;
600+
const content = rule.nodes[0] as Declaration;
601+
602+
content.value = content.value.replace(/'/g, '"');
603+
604+
expect(ast.toString(syntax)).toEqual(
605+
`
606+
css\`
607+
.foo {
608+
content: "\\u2022";
609+
}
610+
\`;
611+
`
612+
);
613+
});
614+
615+
it('should distinguish retained and introduced backslashes in a changed selector', () => {
616+
const { ast } = createTestAst(`
617+
css\`.foo\\:bar { color: hotpink; }\`;
618+
`);
619+
const root = ast.nodes[0] as Root;
620+
const rule = root.nodes[0] as Rule;
621+
622+
rule.selector = `.new\\#qux${rule.selector.replace('.foo', '.baz')}`;
623+
624+
expect(ast.toString(syntax)).toEqual(
625+
`
626+
css\`.new\\\\#qux.baz\\:bar { color: hotpink; }\`;
627+
`
628+
);
629+
});
630+
631+
it('should escape duplicate backslashes conservatively when their origin is ambiguous', () => {
632+
const { ast } = createTestAst(`
633+
css\`.foo\\:bar { color: hotpink; }\`;
634+
`);
635+
const root = ast.nodes[0] as Root;
636+
const rule = root.nodes[0] as Rule;
637+
638+
rule.selector = `.new\\:qux${rule.selector.replace('.foo', '.baz')}`;
639+
640+
expect(ast.toString(syntax)).toEqual(
641+
`
642+
css\`.new\\\\:qux.baz\\\\:bar { color: hotpink; }\`;
643+
`
644+
);
645+
});
646+
588647
it('should escape a backtick a rule introduces exactly once', () => {
589648
const { ast } = createTestAst(`
590649
css\`.foo { color: hotpink; }\`;

packages/postcss-linaria/src/originalState.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export const isOriginalField = (
9191
return originalState !== undefined && originalState.fields[name] === value;
9292
};
9393

94+
export const getOriginalField = (node: AnyNode, name: string): unknown =>
95+
originalStates.get(node)?.fields[name];
96+
9497
export const isOriginalRaw = (
9598
node: AnyNode,
9699
name: string,

packages/postcss-linaria/src/stringify.ts

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import type {
1111
} from 'postcss';
1212
import Stringifier from 'postcss/lib/stringifier';
1313

14-
import { isOriginalField, isOriginalRaw } from './originalState';
14+
import {
15+
getOriginalField,
16+
isOriginalField,
17+
isOriginalRaw,
18+
} from './originalState';
1519
import { placeholderText } from './util';
1620

1721
const commentPlaceholderPattern = new RegExp(
@@ -40,8 +44,130 @@ const escapeBacktick = (value: string): string =>
4044
backslashes.length % 2 === 0 ? `${backslashes}\\\`` : `${backslashes}\``
4145
);
4246

43-
const escapeChangedField = (value: string): string =>
44-
value.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
47+
interface FieldToken {
48+
isEscape: boolean;
49+
value: string;
50+
}
51+
52+
const fieldTokenPattern =
53+
/\\(?:[0-9a-fA-F]{1,6}[ \t\r\n\f]?|[\s\S])|[^\\]+|\\$/g;
54+
const maxTrackedTokenComparisons = 65_536;
55+
56+
const tokenizeField = (value: string): FieldToken[] =>
57+
Array.from(value.matchAll(fieldTokenPattern), ([token]) => ({
58+
isEscape: token.startsWith('\\'),
59+
value: token,
60+
}));
61+
62+
const tokensEqual = (
63+
first: FieldToken | undefined,
64+
second: FieldToken | undefined
65+
): boolean =>
66+
first !== undefined &&
67+
second !== undefined &&
68+
first.isEscape === second.isEscape &&
69+
first.value === second.value;
70+
71+
const countEscapeTokens = (tokens: FieldToken[]): Map<string, number> =>
72+
tokens.reduce((counts, token) => {
73+
if (token.isEscape) {
74+
counts.set(token.value, (counts.get(token.value) ?? 0) + 1);
75+
}
76+
return counts;
77+
}, new Map<string, number>());
78+
79+
// A fixer exposes only its complete before/after strings. Comparing CSS escape
80+
// tokens plus their unchanged neighbouring chunks identifies retained escapes
81+
// without a character-sized diff. If an escape's count changes, its occurrences
82+
// are ambiguous and remain fixer-owned. The bound prevents pathological fields
83+
// with thousands of escapes from allocating a large LCS table; those safely
84+
// fall back to treating every escape as fixer-owned.
85+
const findRetainedEscapeTokens = (
86+
original: FieldToken[],
87+
changed: FieldToken[]
88+
): boolean[] => {
89+
const retained = new Array<boolean>(changed.length).fill(false);
90+
if (original.length * changed.length > maxTrackedTokenComparisons) {
91+
return retained;
92+
}
93+
94+
const originalEscapeCounts = countEscapeTokens(original);
95+
const changedEscapeCounts = countEscapeTokens(changed);
96+
const unambiguousEscapes = new Set<string>();
97+
originalEscapeCounts.forEach((count, escape) => {
98+
if (changedEscapeCounts.get(escape) === count) {
99+
unambiguousEscapes.add(escape);
100+
}
101+
});
102+
103+
const columns = changed.length + 1;
104+
const lengths = new Uint16Array((original.length + 1) * columns);
105+
for (
106+
let originalIndex = original.length - 1;
107+
originalIndex >= 0;
108+
originalIndex -= 1
109+
) {
110+
for (
111+
let changedIndex = changed.length - 1;
112+
changedIndex >= 0;
113+
changedIndex -= 1
114+
) {
115+
const index = originalIndex * columns + changedIndex;
116+
lengths[index] = tokensEqual(
117+
original[originalIndex],
118+
changed[changedIndex]
119+
)
120+
? (lengths[index + columns + 1] ?? 0) + 1
121+
: Math.max(lengths[index + columns] ?? 0, lengths[index + 1] ?? 0);
122+
}
123+
}
124+
125+
let originalIndex = 0;
126+
let changedIndex = 0;
127+
while (originalIndex < original.length && changedIndex < changed.length) {
128+
const originalToken = original[originalIndex];
129+
const changedToken = changed[changedIndex];
130+
if (tokensEqual(originalToken, changedToken)) {
131+
retained[changedIndex] =
132+
changedToken?.isEscape === true &&
133+
unambiguousEscapes.has(changedToken.value);
134+
originalIndex += 1;
135+
changedIndex += 1;
136+
} else if (
137+
(lengths[(originalIndex + 1) * columns + changedIndex] ?? 0) >=
138+
(lengths[originalIndex * columns + changedIndex + 1] ?? 0)
139+
) {
140+
originalIndex += 1;
141+
} else {
142+
changedIndex += 1;
143+
}
144+
}
145+
146+
return retained;
147+
};
148+
149+
const escapeChangedField = (value: string, originalValue?: string): string => {
150+
if (
151+
originalValue === undefined ||
152+
!originalValue.includes('\\') ||
153+
!value.includes('\\')
154+
) {
155+
return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
156+
}
157+
158+
const originalTokens = tokenizeField(originalValue);
159+
const changedTokens = tokenizeField(value);
160+
const retained = findRetainedEscapeTokens(originalTokens, changedTokens);
161+
const escaped = changedTokens
162+
.map((token, index) =>
163+
token.isEscape && !retained[index]
164+
? token.value.replace(/\\/g, '\\\\')
165+
: token.value
166+
)
167+
.join('');
168+
169+
return escapeBacktick(escaped);
170+
};
45171

46172
const rawValueFields = new Set(['params', 'selector', 'value']);
47173

@@ -93,11 +219,19 @@ const escapeNodeField = (
93219
value: string
94220
): string => {
95221
const currentValue = (node as unknown as Record<string, unknown>)[name];
222+
const originalValue = getOriginalField(node, name);
96223
const currentRaw = (node.raws as Record<string, unknown>)[name];
97224
const isSourceDerived =
98225
isOriginalField(node, name, currentValue) &&
99226
(!rawValueFields.has(name) || isOriginalRaw(node, name, currentRaw));
100-
return isSourceDerived ? escapeBacktick(value) : escapeChangedField(value);
227+
if (isSourceDerived) return escapeBacktick(value);
228+
229+
return escapeChangedField(
230+
value,
231+
typeof originalValue === 'string' && currentValue === value
232+
? originalValue
233+
: undefined
234+
);
101235
};
102236

103237
const escapeRawField = (node: AnyNode, name: string, value: string): string => {

0 commit comments

Comments
 (0)