Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ describe('InitColorSchemeScript', () => {
});
});

it('should resolve the %s placeholder at build time, not with a runtime replace', () => {
// The generated script uses string concatenation (`"mode-" + colorScheme`); no `%s` template
// or `.replace('%s', …)` call should survive into the browser.
['class', 'data', '.mode-%s', '[data-mode-%s]', "[data-mode='%s']"].forEach((attribute) => {
const { container } = renderToString(<InitColorSchemeScript attribute={attribute} />);
const script = container.firstChild.textContent;
expect(script).not.to.include("replace('%s'");
expect(script).not.to.include('%s');
});
});

// Client renders must stay script-free (#48595).
it('should not render the script on the client', () => {
const { container } = render(<InitColorSchemeScript />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ const maybeReactUseSyncExternalStore: undefined | any = safeReact.useSyncExterna

const subscribe = () => () => {};

// Insert a runtime scheme variable (`light`, `dark`, or `colorScheme`) into every `%s` placeholder
// of an attribute/selector template.
function interpolateScheme(template: string, variable: string) {
const segments = template.split('%s');
const tokens: string[] = [];
for (let index = 0; index < segments.length; index += 1) {
if (index > 0) {
tokens.push(variable);
}
if (segments[index]) {
tokens.push(JSON.stringify(segments[index]));
}
}
return tokens.join(' + ') || '""';
}

/**
* `true` during the server render and the matching hydration render, `false`
* on every client render afterwards. React warns when a `<script>` is
Expand Down Expand Up @@ -96,20 +112,23 @@ export function buildInitColorSchemeScript(options?: InitColorSchemeScriptProps)
}
if (attribute.startsWith('.')) {
const selector = attribute.substring(1);
setter += `${colorSchemeNode}.classList.remove('${selector}'.replace('%s', light), '${selector}'.replace('%s', dark));
${colorSchemeNode}.classList.add('${selector}'.replace('%s', colorScheme));`;
setter += `${colorSchemeNode}.classList.remove(${interpolateScheme(selector, 'light')}, ${interpolateScheme(selector, 'dark')});
${colorSchemeNode}.classList.add(${interpolateScheme(selector, 'colorScheme')});`;
}
const matches = attribute.match(/\[([^[\]]+)\]/); // case [data-color-scheme='%s'] or [data-color-scheme]
if (matches) {
const [attr, value] = matches[1].split('=');
if (!value) {
setter += `${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', light));
${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', dark));`;
setter += `${colorSchemeNode}.removeAttribute(${interpolateScheme(attr, 'light')});
${colorSchemeNode}.removeAttribute(${interpolateScheme(attr, 'dark')});`;
}
const attributeValue = value
? interpolateScheme(value.replace(/^(['"])(.*)\1$/, '$2'), 'colorScheme')
: '""';
setter += `
${colorSchemeNode}.setAttribute('${attr}'.replace('%s', colorScheme), ${value ? `${value}.replace('%s', colorScheme)` : '""'});`;
} else if (attribute !== '.%s') {
setter += `${colorSchemeNode}.setAttribute('${attribute}', colorScheme);`;
${colorSchemeNode}.setAttribute(${interpolateScheme(attr, 'colorScheme')}, ${attributeValue});`;
} else if (!attribute.startsWith('.')) {
setter += `${colorSchemeNode}.setAttribute(${JSON.stringify(attribute)}, colorScheme);`;
}

return (
Expand Down
Loading