提交 8d254902 编写于 作者: J Jordan Nielson 提交者: Phil Plückthun

Import Rule Hoisting Testing (#2775)

* WIP: WindowedTag tests

* expand WindowedTag testing

* Initial tests added for HoistedTag

* Add some comments and complex HoistedTag test

* Rework Rehydration Sheet @import, length -> groups

Reworked the Rehydration Sheet to utilize HoistedTag's new getHoistedAndNormalGroups method.
Ensured that @import rules are output at the top.
Decided to ignore them in the Rehydration of the sheet, since it looked like they are added during client side renders.
Added Tests and played in the sandbox to validate that nothing looked broken.

* Adjust tests to utilize the new HoistedTag function

We should probably add tests to lint-staged?

* numRules -> groupSize, clean up comments and tests
上级 d7e1b14a
......@@ -8,13 +8,13 @@ const BASE_SIZE = 1 << 8;
export class DefaultGroupedTag implements GroupedTag {
groupSizes: Uint32Array;
length: number;
groups: number;
tag: Tag;
constructor(tag: Tag) {
this.groupSizes = new Uint32Array(BASE_SIZE);
this.length = BASE_SIZE;
this.groups = BASE_SIZE;
this.tag = tag;
}
......@@ -35,7 +35,7 @@ export class DefaultGroupedTag implements GroupedTag {
this.groupSizes = new Uint32Array(newSize);
this.groupSizes.set(oldBuffer);
this.length = newSize;
this.groups = newSize;
for (let i = oldSize; i < newSize; i++) {
this.groupSizes[i] = 0;
......@@ -51,10 +51,10 @@ export class DefaultGroupedTag implements GroupedTag {
}
clearGroup(group: number): void {
if (group < this.length) {
const length = this.groupSizes[group];
if (group < this.groups) {
const groupSize = this.groupSizes[group];
const startIndex = this.indexOfGroup(group);
const endIndex = startIndex + length;
const endIndex = startIndex + groupSize;
this.groupSizes[group] = 0;
......@@ -66,13 +66,13 @@ export class DefaultGroupedTag implements GroupedTag {
getGroup(group: number): string {
let css = '';
if (group >= this.length || this.groupSizes[group] === 0) {
if (group >= this.groups || this.groupSizes[group] === 0) {
return css;
}
const length = this.groupSizes[group];
const groupSize = this.groupSizes[group];
const startIndex = this.indexOfGroup(group);
const endIndex = startIndex + length;
const endIndex = startIndex + groupSize;
for (let i = startIndex; i < endIndex; i++) {
css += `${this.tag.getRule(i)}\n`;
......
......@@ -4,19 +4,19 @@ import type { HoistedTag, GroupedTag, Tag } from './types';
import { DefaultGroupedTag } from './GroupedTag';
import { WindowedTag } from './WindowedTag';
const hoistedRuleRe = /\s*@(font-face|import)/;
const hoistedRuleRe = /\s*@import/;
export class DefaultHoistedTag implements HoistedTag {
hoistedTag: GroupedTag;
normalTag: GroupedTag;
length: number;
groups: number;
constructor(tag: Tag) {
this.hoistedTag = new DefaultGroupedTag(new WindowedTag(tag));
this.normalTag = new DefaultGroupedTag(new WindowedTag(tag));
this.length = 0;
this.groups = 0;
}
insertRules(group: number, rules: string[]): void {
......@@ -34,17 +34,18 @@ export class DefaultHoistedTag implements HoistedTag {
this.normalTag.insertRules(group, normalRules);
this.hoistedTag.insertRules(group, hoistedRules);
this.length = this.normalTag.length;
// We just use the normalTag groups
// since the normalTag and hoistedTag have the same groups
this.groups = this.normalTag.groups;
}
clearGroup(group: number) {
this.normalTag.clearGroup(group);
this.hoistedTag.clearGroup(group);
this.length = this.normalTag.length;
this.groups = this.normalTag.groups;
}
// TODO: Needs to be replaced to keep hoisted ordering
getGroup(group: number) {
return this.hoistedTag.getGroup(group) + this.normalTag.getGroup(group);
getHoistedAndNormalGroups(group: number) {
return { hoisted: this.hoistedTag.getGroup(group), normal: this.normalTag.getGroup(group) };
}
}
......@@ -6,18 +6,19 @@ import { getSheet } from './dom';
import type { Sheet } from './types';
const PLAIN_RULE_TYPE = 1;
const IMPORT_RULE_TYPE = 3;
const SELECTOR = `style[${SC_ATTR}][${SC_ATTR_VERSION}="${SC_VERSION}"]`;
const MARKER_RE = new RegExp(`^${SC_ATTR}\\.g(\\d+)\\[id="([\\w\\d-]+)"\\]`);
// TODO: Maybe operate on GroupedTag, then add method, then also implement on HoistingGroupedTag
export const outputSheet = (sheet: Sheet) => {
const tag = sheet.getTag();
const { hoistedTag, normalTag, length } = tag;
const hoistedTag = sheet.getTag();
const { groups } = hoistedTag;
let hoistedOutput = '';
let normalOutput = '';
for (let group = 0; group < length; group++) {
for (let group = 0; group < groups; group++) {
const id = getIdForGroup(group);
if (id === undefined) continue;
......@@ -34,20 +35,17 @@ export const outputSheet = (sheet: Sheet) => {
}
const selector = `${SC_ATTR}.g${group}[id="${id}"]`;
const hoistedRules = hoistedTag.getGroup(group);
if (hoistedRules.length > 0) {
const body = content ? `content:"${content}"` : '';
hoistedOutput += `${hoistedRules}${selector}{${body}}\n`;
content = ''; // Prevent names from being added twice
const { hoisted, normal } = hoistedTag.getHoistedAndNormalGroups(group);
if (hoisted.length > 0) {
hoistedOutput += `${hoisted}`;
}
const normalRules = normalTag.getGroup(group);
if (normalRules.length > 0) {
if (normal.length > 0) {
const body = content ? `content:"${content}"` : '';
normalOutput += `${normalRules}${selector}{${body}}\n`;
normalOutput += `${normal}${selector}{${body}}\n`;
content = ''; // Prevent names from being added twice
}
}
return hoistedOutput + normalOutput;
};
......@@ -67,10 +65,12 @@ const rehydrateSheetFromTag = (sheet: Sheet, style: HTMLStyleElement) => {
for (let i = 0, l = cssRules.length; i < l; i++) {
const cssRule = (cssRules[i]: any);
if (typeof cssRule.cssText !== 'string') {
// Avoid IE11 quirk where cssText is inaccessible on some invalid rules
continue;
} else if (cssRule.type === IMPORT_RULE_TYPE) {
// Skip trying to keep track of import rules and let them get added later
continue;
} else if (cssRule.type !== PLAIN_RULE_TYPE) {
rules.push(cssRule.cssText);
} else {
......@@ -103,7 +103,7 @@ const rehydrateSheetFromTag = (sheet: Sheet, style: HTMLStyleElement) => {
export const rehydrateSheet = (sheet: Sheet) => {
let targetDocument = document;
if(sheet.options.target) targetDocument = sheet.options.target.ownerDocument;
if (sheet.options.target) targetDocument = sheet.options.target.ownerDocument;
const nodes = targetDocument.querySelectorAll(SELECTOR);
for (let i = 0, l = nodes.length; i < l; i++) {
......
......@@ -2,78 +2,82 @@
import { VirtualTag } from '../Tag';
import { DefaultGroupedTag } from '../GroupedTag';
import { WindowedTag } from '../WindowedTag';
let tag;
let groupedTag;
const describeGroupedTag = makeTagAndGroupedTag => {
let groupedTag, tag;
beforeEach(() => {
({ groupedTag, tag } = makeTagAndGroupedTag());
});
beforeEach(() => {
tag = new VirtualTag();
groupedTag = new DefaultGroupedTag(tag);
});
it('inserts and retrieves rules by groups correctly', () => {
groupedTag.insertRules(2, ['.g2-a {}', '.g2-b {}']);
it('inserts and retrieves rules by groups correctly', () => {
groupedTag.insertRules(2, [
'.g2-a {}',
'.g2-b {}'
]);
// Insert out of order into the right group
groupedTag.insertRules(1, [
'.g1-a {}',
'.g1-b {}'
]);
groupedTag.insertRules(2, [
'.g2-c {}',
'.g2-d {}'
]);
expect(groupedTag.length).toBeGreaterThan(2);
expect(tag.length).toBe(6);
// Expect groups to contain inserted rules
expect(groupedTag.getGroup(0)).toBe('');
expect(groupedTag.getGroup(1)).toBe('.g1-a {}\n.g1-b {}\n');
expect(groupedTag.getGroup(2)).toBe(
'.g2-a {}\n.g2-b {}\n' +
'.g2-c {}\n.g2-d {}\n'
);
// Check some rules in the tag as well
expect(tag.getRule(3)).toBe('.g2-b {}');
expect(tag.getRule(0)).toBe('.g1-a {}');
// And the indices for sizes: [0, 2, 4, 0, ...]
expect(groupedTag.indexOfGroup(0)).toBe(0);
expect(groupedTag.indexOfGroup(1)).toBe(0);
expect(groupedTag.indexOfGroup(2)).toBe(2);
expect(groupedTag.indexOfGroup(3)).toBe(6);
expect(groupedTag.indexOfGroup(4)).toBe(6);
});
// Insert out of order into the right group
groupedTag.insertRules(1, ['.g1-a {}', '.g1-b {}']);
it('inserts and deletes groups correctly', () => {
groupedTag.insertRules(1, ['.g1-a {}']);
expect(tag.length).toBe(1);
expect(groupedTag.getGroup(1)).not.toBe('');
groupedTag.clearGroup(1);
expect(tag.length).toBe(0);
expect(groupedTag.getGroup(1)).toBe('');
// Noop test for non-existent group
groupedTag.clearGroup(0);
expect(tag.length).toBe(0);
});
groupedTag.insertRules(2, ['.g2-c {}', '.g2-d {}']);
expect(groupedTag.groups).toBeGreaterThan(2);
expect(tag.length).toBe(6);
// Expect groups to contain inserted rules
expect(groupedTag.getGroup(0)).toBe('');
expect(groupedTag.getGroup(1)).toBe('.g1-a {}\n.g1-b {}\n');
expect(groupedTag.getGroup(2)).toBe('.g2-a {}\n.g2-b {}\n' + '.g2-c {}\n.g2-d {}\n');
// Check some rules in the tag as well
expect(tag.getRule(3)).toBe('.g2-b {}');
expect(tag.getRule(0)).toBe('.g1-a {}');
it('does supports large group numbers', () => {
const baseSize = groupedTag.length;
const group = 1 << 10;
groupedTag.insertRules(group, ['.test {}']);
// And the indices for sizes: [0, 2, 4, 0, ...]
expect(groupedTag.indexOfGroup(0)).toBe(0);
expect(groupedTag.indexOfGroup(1)).toBe(0);
expect(groupedTag.indexOfGroup(2)).toBe(2);
expect(groupedTag.indexOfGroup(3)).toBe(6);
expect(groupedTag.indexOfGroup(4)).toBe(6);
});
// We expect the internal buffer to have grown beyond its initial size
expect(groupedTag.length).toBeGreaterThan(baseSize);
it('inserts and deletes groups correctly', () => {
groupedTag.insertRules(1, ['.g1-a {}']);
expect(tag.length).toBe(1);
expect(groupedTag.getGroup(1)).not.toBe('');
groupedTag.clearGroup(1);
expect(tag.length).toBe(0);
expect(groupedTag.getGroup(1)).toBe('');
// Noop test for non-existent group
groupedTag.clearGroup(0);
expect(tag.length).toBe(0);
});
it('does supports large group numbers', () => {
const baseSize = groupedTag.groups;
const group = 1 << 10;
groupedTag.insertRules(group, ['.test {}']);
// We expect the internal buffer to have grown beyond its initial size
expect(groupedTag.groups).toBeGreaterThan(baseSize);
expect(groupedTag.groups).toBeGreaterThan(group);
expect(tag.length).toBe(1);
expect(groupedTag.indexOfGroup(group)).toBe(0);
expect(groupedTag.getGroup(group)).toBe('.test {}\n');
});
};
describe('GroupedTag with a VirtualTag', () => {
describeGroupedTag(() => {
const tag = new VirtualTag();
const groupedTag = new DefaultGroupedTag(tag);
return { tag, groupedTag };
});
});
expect(groupedTag.length).toBeGreaterThan(group);
expect(tag.length).toBe(1);
expect(groupedTag.indexOfGroup(group)).toBe(0);
expect(groupedTag.getGroup(group)).toBe('.test {}\n');
describe('GroupedTag with a Windowed VirtualTag', () => {
describeGroupedTag(() => {
const tag = new WindowedTag(new VirtualTag());
const groupedTag = new DefaultGroupedTag(tag);
return { tag, groupedTag };
});
});
// @flow
import { VirtualTag } from '../Tag';
import { DefaultHoistedTag } from '../HoistedTag';
describe('DefaultHoistedTag', () => {
let hoistingTag, tag;
beforeEach(() => {
tag = new VirtualTag();
hoistingTag = new DefaultHoistedTag(tag);
});
// Start GroupedTag tests for when there's no hoisted rules
it('inserts and retrieves normal rules by groups correctly', () => {
hoistingTag.insertRules(2, ['.g2-a {}', '.g2-b {}']);
// Insert out of order into the right group
hoistingTag.insertRules(1, ['.g1-a {}', '.g1-b {}']);
hoistingTag.insertRules(2, ['.g2-c {}', '.g2-d {}']);
expect(hoistingTag.groups).toBeGreaterThan(2);
expect(tag.length).toBe(6);
// Expect groups to contain inserted rules
expect(hoistingTag.getHoistedAndNormalGroups(0)).toEqual({ hoisted: '', normal: '' });
expect(hoistingTag.getHoistedAndNormalGroups(1)).toEqual({
hoisted: '',
normal: '.g1-a {}\n.g1-b {}\n',
});
expect(hoistingTag.getHoistedAndNormalGroups(2)).toEqual({
hoisted: '',
normal: '.g2-a {}\n.g2-b {}\n' + '.g2-c {}\n.g2-d {}\n',
});
// Check some rules in the tag as well
expect(tag.getRule(3)).toBe('.g2-b {}');
expect(tag.getRule(0)).toBe('.g1-a {}');
// And the indices for sizes: [0, 2, 4, 0, ...]
expect(hoistingTag.normalTag.indexOfGroup(0)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(1)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(2)).toBe(2);
expect(hoistingTag.normalTag.indexOfGroup(3)).toBe(6);
expect(hoistingTag.normalTag.indexOfGroup(4)).toBe(6);
// Check to make sure the hoistedTag is empty
expect(hoistingTag.hoistedTag.getGroup(0)).toBe('');
// Dig to the WindowedTag in order to avoid the array allocated by GroupedTag
expect(hoistingTag.hoistedTag.tag.length).toBe(0);
});
it('inserts and deletes groups with normal rules correctly', () => {
hoistingTag.insertRules(1, ['.g1-a {}']);
expect(tag.length).toBe(1);
expect(hoistingTag.getHoistedAndNormalGroups(1).normal).not.toBe('');
hoistingTag.clearGroup(1);
expect(tag.length).toBe(0);
expect(hoistingTag.getHoistedAndNormalGroups(1).normal).toBe('');
// Noop test for non-existent group
hoistingTag.clearGroup(0);
expect(tag.length).toBe(0);
});
it('supports large group numbers', () => {
const baseSize = hoistingTag.groups;
const group = 1 << 10;
hoistingTag.insertRules(group, ['.test {}']);
// We expect the internal buffer to have grown beyond its initial size
expect(hoistingTag.groups).toBeGreaterThan(baseSize);
expect(hoistingTag.groups).toBeGreaterThan(group);
expect(tag.length).toBe(1);
expect(hoistingTag.normalTag.indexOfGroup(group)).toBe(0);
expect(hoistingTag.getHoistedAndNormalGroups(group).normal).toBe('.test {}\n');
});
// End GroupedTag tests, Start tests for hoisting @import rules
it('should hoist @import rules to be first in a group', () => {
// Insert some normal style rules
hoistingTag.insertRules(2, ['.g2-a {}', '.g2-b {}']);
// Insert an @import and an @font-face rule to the same group
hoistingTag.insertRules(2, [
'@import url("");',
'@font-face { font-family: "test", src: url("")}',
]);
expect(hoistingTag.groups).toBeGreaterThan(2);
expect(tag.length).toBe(4);
// Expect groups to contain inserted rules
expect(hoistingTag.getHoistedAndNormalGroups(0)).toEqual({ hoisted: '', normal: '' });
expect(hoistingTag.getHoistedAndNormalGroups(1)).toEqual({ hoisted: '', normal: '' });
expect(hoistingTag.getHoistedAndNormalGroups(2)).toEqual({
hoisted: '@import url("");\n',
normal: '.g2-a {}\n.g2-b {}\n@font-face { font-family: "test", src: url("")}\n',
});
// Check some rules in the tag as well
expect(tag.getRule(2)).toBe('.g2-b {}');
expect(tag.getRule(0)).toBe('@import url("");');
// We added 3 rules to group 2, so check the indicies
expect(hoistingTag.normalTag.indexOfGroup(0)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(1)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(2)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(3)).toBe(3);
// Check to make sure the hoistedTag has the @import rules
expect(hoistingTag.hoistedTag.getGroup(0)).toBe('');
expect(hoistingTag.hoistedTag.getGroup(2)).toBe('@import url("");\n');
expect(hoistingTag.hoistedTag.groups).toBeGreaterThan(2);
});
it('should hoist @import rules when interleaved in multiple groups', () => {
// Insert some normal style rules
hoistingTag.insertRules(2, ['.g2-a {}', '.g2-b {}']);
hoistingTag.insertRules(1, ['.g1-a {}', '.g1-b {}']);
hoistingTag.insertRules(0, ['.g0-a {}', '.g0-b {}']);
// Insert an @import and an @font-face rule to the each group, only the @import needs hoisting
hoistingTag.insertRules(2, [
'@import url("2");',
'@font-face { font-family: "test", src: url("2")}',
]);
hoistingTag.insertRules(1, [
'@import url("1");',
'@font-face { font-family: "test", src: url("1")}',
]);
hoistingTag.insertRules(0, [
'@import url("0");',
'@font-face { font-family: "test", src: url("0")}',
]);
expect(hoistingTag.normalTag.tag.length).toBe(9); // Check the normal window
expect(hoistingTag.hoistedTag.tag.length).toBe(3); // Check the hoisted window
expect(tag.length).toBe(12);
// Expect groups to contain inserted hoisted & normal rules
expect(hoistingTag.getHoistedAndNormalGroups(0)).toEqual({
hoisted: '@import url("0");\n',
normal: '.g0-a {}\n.g0-b {}\n@font-face { font-family: "test", src: url("0")}\n',
});
expect(hoistingTag.getHoistedAndNormalGroups(1)).toEqual({
hoisted: '@import url("1");\n',
normal: '.g1-a {}\n.g1-b {}\n@font-face { font-family: "test", src: url("1")}\n',
});
expect(hoistingTag.getHoistedAndNormalGroups(2)).toEqual({
hoisted: '@import url("2");\n',
normal: '.g2-a {}\n.g2-b {}\n@font-face { font-family: "test", src: url("2")}\n',
});
// Expect tag to have hoisted rules in right order then normal rules
expect(tag.getRule(0)).toBe('@import url("0");');
expect(tag.getRule(1)).toBe('@import url("1");');
expect(tag.getRule(2)).toBe('@import url("2");');
expect(tag.getRule(3)).toBe('.g0-a {}');
expect(tag.getRule(5)).toBe('@font-face { font-family: "test", src: url("0")}');
expect(tag.getRule(6)).toBe('.g1-a {}');
expect(tag.getRule(8)).toBe('@font-face { font-family: "test", src: url("1")}');
expect(tag.getRule(9)).toBe('.g2-a {}');
expect(tag.getRule(11)).toBe('@font-face { font-family: "test", src: url("2")}');
expect(hoistingTag.normalTag.indexOfGroup(0)).toBe(0);
expect(hoistingTag.normalTag.indexOfGroup(1)).toBe(3);
expect(hoistingTag.normalTag.indexOfGroup(2)).toBe(6);
expect(hoistingTag.normalTag.indexOfGroup(3)).toBe(9);
// Check to make sure the hoistedTag has the @import and @font-face rules
expect(hoistingTag.hoistedTag.getGroup(0)).toBe('@import url("0");\n');
expect(hoistingTag.hoistedTag.getGroup(1)).toBe('@import url("1");\n');
expect(hoistingTag.hoistedTag.getGroup(2)).toBe('@import url("2");\n');
});
});
......@@ -32,12 +32,41 @@ describe('outputSheet', () => {
`${SC_ATTR}.g22[id="idB"]{content:"nameB,"}`,
]);
});
it('handles hoisting @import rules', () => {
const sheet = new StyleSheet({ isServer: true });
// Make the group numbers a little more arbitrary
GroupIDAllocator.setGroupForId('idA', 11);
GroupIDAllocator.setGroupForId('idB', 22);
// Insert some rules
sheet.insertRules('idA', 'nameA', ['.a {}', '@import url("")']);
sheet.insertRules('idB', 'nameB', [
'@font-face { font-family: "test", src: url("") }',
'.b {}',
]);
const output = outputSheet(sheet)
.trim()
.split('\n');
expect(output).toEqual([
'@import url("")',
'.a {}',
`${SC_ATTR}.g11[id="idA"]{content:"nameA,"}`,
'@font-face { font-family: "test", src: url("") }',
'.b {}',
`${SC_ATTR}.g22[id="idB"]{content:"nameB,"}`,
]);
});
});
describe('rehydrateSheet', () => {
it('rehydrates sheets correctly', () => {
it('rehydrates sheets correctly and skips/ignores @imports', () => {
document.head.innerHTML = `
<style ${SC_ATTR} ${SC_ATTR_VERSION}="${SC_VERSION}">
@import url("a");
.a {}
${SC_ATTR}.g11[id="idA"]{content:"nameA,"}
${SC_ATTR}.g33[id="empty"]{content:""}
......@@ -46,6 +75,7 @@ describe('rehydrateSheet', () => {
document.body.innerHTML = `
<style ${SC_ATTR} ${SC_ATTR_VERSION}="${SC_VERSION}">
@import url("b");
.b {}
${SC_ATTR}.g22[id="idB"]{content:"nameB,"}
</style>
......@@ -69,9 +99,15 @@ describe('rehydrateSheet', () => {
expect(sheet.hasNameForId('idB', 'nameB')).toBe(true);
// Populates the underlying tag
expect(sheet.getTag().normalTag.tag.length).toBe(2);
expect(sheet.getTag().getGroup(11)).toBe('.a {}\n');
expect(sheet.getTag().getGroup(22)).toBe('.b {}\n');
expect(sheet.getTag().getGroup(33)).toBe('');
expect(sheet.getTag().getHoistedAndNormalGroups(11)).toEqual({
hoisted: '',
normal: '.a {}\n',
});
expect(sheet.getTag().getHoistedAndNormalGroups(22)).toEqual({
hoisted: '',
normal: '.b {}\n',
});
expect(sheet.getTag().getHoistedAndNormalGroups(33)).toEqual({ hoisted: '', normal: '' });
// Removes the old tags
expect(styleHead.parentElement).toBe(null);
expect(styleBody.parentElement).toBe(null);
......
// @flow
import { type Tag, CSSOMTag, TextTag, VirtualTag } from '../Tag';
import { WindowedTag } from '../WindowedTag';
const describeTag = (TagClass: Class<Tag>) => {
const describeTag = (TagClass: Class<Tag>, TagToWindow: Class<Tag>) => {
it('inserts and retrieves rules at indices', () => {
const tag = new TagClass();
const tag = TagToWindow ? new TagClass(new TagToWindow()) : new TagClass();
expect(tag.insertRule(0, '.b {}')).toBe(true);
expect(tag.insertRule(0, '.a {}')).toBe(true);
expect(tag.insertRule(2, '.c {}')).toBe(true);
......@@ -17,7 +18,7 @@ const describeTag = (TagClass: Class<Tag>) => {
});
it('deletes rules that have been inserted', () => {
const tag = new TagClass();