Single style capture (#1437)
Support a contrived/rare case where a <style> element has multiple text node children (this is usually only possible to recreate via javascript append) ... this PR fixes cases where there are subsequent text mutations to these nodes; previously these would have been lost * In this scenario, a new CSS comment may now be inserted into the captured `_cssText` for a <style> element to show where it should be broken up into text elements upon replay: `/* rr_split */` * The new 'can record and replay style mutations' test is the principal way to the problematic scenarios, and is a detailed 'catch-all' test with many checks to cover most of the ways things can fail * There are new tests for splitting/rebuilding the css using the rr_split marker * The prior 'dynamic stylesheet' route is now the main route for serializing a stylesheet; dynamic stylesheet were missed out in #1533 but that case is now covered with this PR This PR was originally extracted from #1475 so the initial motivation was to change the approach on stringifying <style> elements to do so in a single place. This is also the motivating factor for always serializing <style> elements via the `_cssText` attribute rather than in it's childNodes; in #1475 we will be delaying populating `_cssText` for performance and instead recorrding them as assets. Thanks for the detailed review to Justin Halsall <Juice10@users.noreply.github.com> & Yun Feng <https://github.com/YunFeng0817>
This commit is contained in:
@@ -1094,12 +1094,13 @@ exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = `
|
||||
{
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"style\\",
|
||||
\\"attributes\\": {},
|
||||
\\"attributes\\": {
|
||||
\\"_cssText\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\"
|
||||
},
|
||||
\\"childNodes\\": [
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"textContent\\": \\"\\",
|
||||
\\"id\\": 38
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, beforeEach, expect } from 'vitest';
|
||||
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
|
||||
import postcss, { AcceptedPlugin } from 'postcss';
|
||||
import postcss, { type AcceptedPlugin } from 'postcss';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { splitCssText, stringifyStylesheet } from './../src/utils';
|
||||
import { applyCssSplits } from './../src/rebuild';
|
||||
import {
|
||||
NodeType,
|
||||
type serializedElementNodeWithId,
|
||||
type BuildCache,
|
||||
type textNode,
|
||||
} from '../src/types';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
describe('css parser', () => {
|
||||
function parse(plugin: AcceptedPlugin, input: string): string {
|
||||
@@ -73,3 +86,157 @@ li[attr="has,comma"] a.\\:hover {background: red;}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('css splitter', () => {
|
||||
it('finds css textElement splits correctly', () => {
|
||||
const window = new Window({ url: 'https://localhost:8080' });
|
||||
const document = window.document;
|
||||
document.head.innerHTML = '<style>.a{background-color:red;}</style>';
|
||||
const style = document.querySelector('style');
|
||||
if (style) {
|
||||
// as authored, e.g. no spaces
|
||||
style.append('.a{background-color:black;}');
|
||||
|
||||
// how it is currently stringified (spaces present)
|
||||
const expected = [
|
||||
'.a { background-color: red; }',
|
||||
'.a { background-color: black; }',
|
||||
];
|
||||
const browserSheet = expected.join('');
|
||||
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
||||
|
||||
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds css textElement splits correctly when comments are present', () => {
|
||||
const window = new Window({ url: 'https://localhost:8080' });
|
||||
const document = window.document;
|
||||
// as authored, with comment, missing semicolons
|
||||
document.head.innerHTML =
|
||||
'<style>.a{color:red}.b{color:blue} /* author comment */</style>';
|
||||
const style = document.querySelector('style');
|
||||
if (style) {
|
||||
style.append('/* author comment */.a{color:red}.b{color:green}');
|
||||
|
||||
// how it is currently stringified (spaces present)
|
||||
const expected = [
|
||||
'.a { color: red; } .b { color: blue; }',
|
||||
'.a { color: red; } .b { color: green; }',
|
||||
];
|
||||
const browserSheet = expected.join('');
|
||||
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds css textElement splits correctly when vendor prefixed rules have been removed', () => {
|
||||
const style = JSDOM.fragment(`<style></style>`).querySelector('style');
|
||||
if (style) {
|
||||
// as authored, with newlines
|
||||
style.appendChild(
|
||||
JSDOM.fragment(`.x {
|
||||
-webkit-transition: all 4s ease;
|
||||
content: 'try to keep a newline';
|
||||
transition: all 4s ease;
|
||||
}`),
|
||||
);
|
||||
// TODO: splitCssText can't handle it yet if both start with .x
|
||||
style.appendChild(
|
||||
JSDOM.fragment(`.y {
|
||||
-moz-transition: all 5s ease;
|
||||
transition: all 5s ease;
|
||||
}`),
|
||||
);
|
||||
// browser .rules would usually omit the vendored versions and modifies the transition value
|
||||
const expected = [
|
||||
'.x { content: "try to keep a newline"; background: red; transition: 4s; }',
|
||||
'.y { transition: 5s; }',
|
||||
];
|
||||
const browserSheet = expected.join('');
|
||||
|
||||
// can't do this as JSDOM doesn't have style.sheet
|
||||
// also happy-dom doesn't strip out vendor-prefixed rules like a real browser does
|
||||
//expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
||||
|
||||
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCssSplits css rejoiner', function () {
|
||||
const mockLastUnusedArg = null as unknown as BuildCache;
|
||||
const halfCssText = '.a { background-color: red; }';
|
||||
const otherHalfCssText = halfCssText.replace('.a', '.x');
|
||||
const markedCssText = [halfCssText, otherHalfCssText].join('/* rr_split */');
|
||||
let sn: serializedElementNodeWithId;
|
||||
|
||||
beforeEach(() => {
|
||||
sn = {
|
||||
type: NodeType.Element,
|
||||
tagName: 'style',
|
||||
childNodes: [
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
],
|
||||
} as serializedElementNodeWithId;
|
||||
});
|
||||
|
||||
it('applies css splits correctly', () => {
|
||||
// happy path
|
||||
applyCssSplits(sn, markedCssText, false, mockLastUnusedArg);
|
||||
expect((sn.childNodes[0] as textNode).textContent).toEqual(halfCssText);
|
||||
expect((sn.childNodes[1] as textNode).textContent).toEqual(
|
||||
otherHalfCssText,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies css splits correctly even when there are too many child nodes', () => {
|
||||
let sn3 = {
|
||||
type: NodeType.Element,
|
||||
tagName: 'style',
|
||||
childNodes: [
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
],
|
||||
} as serializedElementNodeWithId;
|
||||
applyCssSplits(sn3, markedCssText, false, mockLastUnusedArg);
|
||||
expect((sn3.childNodes[0] as textNode).textContent).toEqual(halfCssText);
|
||||
expect((sn3.childNodes[1] as textNode).textContent).toEqual(
|
||||
otherHalfCssText,
|
||||
);
|
||||
expect((sn3.childNodes[2] as textNode).textContent).toEqual('');
|
||||
});
|
||||
|
||||
it('maintains entire css text when there are too few child nodes', () => {
|
||||
let sn1 = {
|
||||
type: NodeType.Element,
|
||||
tagName: 'style',
|
||||
childNodes: [
|
||||
{
|
||||
type: NodeType.Text,
|
||||
textContent: '',
|
||||
},
|
||||
],
|
||||
} as serializedElementNodeWithId;
|
||||
applyCssSplits(sn1, markedCssText, false, mockLastUnusedArg);
|
||||
expect((sn1.childNodes[0] as textNode).textContent).toEqual(
|
||||
halfCssText + otherHalfCssText,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
createCache,
|
||||
} from '../src/rebuild';
|
||||
import { NodeType } from '../src/types';
|
||||
import { createMirror, Mirror } from '../src/utils';
|
||||
import { createMirror, Mirror, normalizeCssString } from '../src/utils';
|
||||
|
||||
const expect = _expect as unknown as {
|
||||
<T = unknown>(actual: T): {
|
||||
@@ -20,7 +20,7 @@ const expect = _expect as unknown as {
|
||||
|
||||
expect.extend({
|
||||
toMatchCss: function (received: string, expected: string) {
|
||||
const pass = normCss(received) === normCss(expected);
|
||||
const pass = normalizeCssString(received) === normalizeCssString(expected);
|
||||
const message: () => string = () =>
|
||||
pass
|
||||
? ''
|
||||
@@ -32,10 +32,6 @@ expect.extend({
|
||||
},
|
||||
});
|
||||
|
||||
function normCss(cssText: string): string {
|
||||
return cssText.replace(/[\s;]/g, '');
|
||||
}
|
||||
|
||||
function getDuration(hrtime: [number, number]) {
|
||||
const [seconds, nanoseconds] = hrtime;
|
||||
return seconds * 1000 + nanoseconds / 1000000;
|
||||
|
||||
@@ -162,22 +162,27 @@ describe('style elements', () => {
|
||||
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
|
||||
const styleEl = render(`<style>body { color: red; }</style>`);
|
||||
styleEl.sheet?.insertRule('section { color: blue; }');
|
||||
expect(serializeNode(styleEl.childNodes[0])).toMatchObject({
|
||||
isStyle: true,
|
||||
expect(serializeNode(styleEl)).toMatchObject({
|
||||
rootId: undefined,
|
||||
textContent: 'section {color: blue;}body {color: red;}',
|
||||
type: 3,
|
||||
attributes: {
|
||||
_cssText: 'section {color: blue;}body {color: red;}',
|
||||
},
|
||||
type: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize individual text nodes on stylesheets with multiple child nodes', () => {
|
||||
it('should serialize all rules on stylesheets with mix of insertion type', () => {
|
||||
const styleEl = render(`<style>body { color: red; }</style>`);
|
||||
styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append
|
||||
styleEl.append(document.createTextNode('section { color: blue; }'));
|
||||
expect(serializeNode(styleEl.childNodes[1])).toMatchObject({
|
||||
isStyle: true,
|
||||
styleEl.sheet?.insertRule('section.working { color: pink; }');
|
||||
expect(serializeNode(styleEl)).toMatchObject({
|
||||
rootId: undefined,
|
||||
textContent: 'section { color: blue; }',
|
||||
type: 3,
|
||||
attributes: {
|
||||
_cssText:
|
||||
'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}',
|
||||
},
|
||||
type: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user