From c9cea472caed0439259c7c987ec2fef7e6d77c3f Mon Sep 17 00:00:00 2001 From: "Bundyo (Kamen Bundev)" Date: Thu, 26 Mar 2020 19:04:42 +0200 Subject: [PATCH] refactor(css): attribute selectors match web counterparts (#7848) * Improve CSS selector parsing/matching by 30% - 40% with some JavaScript optimization and excluding ProxyViewContainer from the process Change the specificity to be divisible to 10 * fix: selector match * fix: lint errors * refactor: restore processing of ProxyViewContainer * chore: lower the number of expected cycles * fix: some css selector fixes Co-authored-by: Manol Donev Co-authored-by: Manol Donev Co-authored-by: Vasil Trifonov --- .../ui/styling/css-selector/css-selector.d.ts | 5 +- .../ui/styling/css-selector/css-selector.ts | 191 ++++++++++-------- nativescript-core/ui/styling/style-scope.ts | 2 +- tests/webpack.config.js | 2 +- 4 files changed, 107 insertions(+), 93 deletions(-) diff --git a/nativescript-core/ui/styling/css-selector/css-selector.d.ts b/nativescript-core/ui/styling/css-selector/css-selector.d.ts index dffc447e2..3ebba8e2b 100644 --- a/nativescript-core/ui/styling/css-selector/css-selector.d.ts +++ b/nativescript-core/ui/styling/css-selector/css-selector.d.ts @@ -12,11 +12,12 @@ export interface Node { parent?: Node; id?: string; + nodeName?: string; cssType?: string; cssClasses?: Set; cssPseudoClasses?: Set; - getChildIndex?(node: Node): number - getChildAt?(index: number): Node + getChildIndex?(node: Node): number; + getChildAt?(index: number): Node; } export interface Declaration { diff --git a/nativescript-core/ui/styling/css-selector/css-selector.ts b/nativescript-core/ui/styling/css-selector/css-selector.ts index 3bfda011a..bac516bf2 100644 --- a/nativescript-core/ui/styling/css-selector/css-selector.ts +++ b/nativescript-core/ui/styling/css-selector/css-selector.ts @@ -1,19 +1,18 @@ import { Node, Declaration, Changes, ChangeMap } from "."; import { isNullOrUndefined } from "../../../utils/types"; -import { escapeRegexSymbols } from "../../../utils/utils-common"; import * as cssParser from "../../../css"; import * as parser from "../../../css/parser"; const enum Specificity { - Inline = 0x01000000, - Id = 0x00010000, - Attribute = 0x00000100, - Class = 0x00000100, - PseudoClass = 0x00000100, - Type = 0x00000001, - Universal = 0x00000000, - Invalid = 0x00000000 + Inline = 1000, + Id = 100, + Attribute = 10, + Class = 10, + PseudoClass = 10, + Type = 1, + Universal = 0, + Invalid = 0 } const enum Rarity { @@ -71,6 +70,7 @@ function SelectorProperties(specificity: Specificity, rarity: Rarity, dynamic: b declare type Combinator = "+" | ">" | "~" | " "; @SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static) export abstract class SelectorCore { + public pos: number; public specificity: number; public rarity: Rarity; public combinator: Combinator; @@ -167,43 +167,35 @@ export class AttributeSelector extends SimpleSelector { this.match = node => false; } - let escapedValue = escapeRegexSymbols(value); - let regexp: RegExp = null; - switch (test) { - case "^=": // PrefixMatch - regexp = new RegExp("^" + escapedValue); - break; - case "$=": // SuffixMatch - regexp = new RegExp(escapedValue + "$"); - break; - case "*=": // SubstringMatch - regexp = new RegExp(escapedValue); - break; - case "=": // Equals - regexp = new RegExp("^" + escapedValue + "$"); - break; - case "~=": // Includes - if (/\s/.test(value)) { - this.match = node => false; + this.match = node => { + const attr = node[attribute] + ""; - return; - } - regexp = new RegExp("(^|\\s)" + escapedValue + "(\\s|$)"); - break; - case "|=": // DashMatch - regexp = new RegExp("^" + escapedValue + "(-|$)"); - break; - } + if (test === "=") { // Equals + return attr === value; + } - if (regexp) { - this.match = node => regexp.test(node[attribute] + ""); + if (test === "^=") { // PrefixMatch + return attr.startsWith(value); + } - return; - } else { - this.match = node => false; + if (test === "$=") { // SuffixMatch + return attr.endsWith(value); + } - return; - } + if (test === "*=") { // SubstringMatch + return attr.indexOf(value) !== -1; + } + + if (test === "~=") { // Includes + const words = attr.split(" "); + + return words && words.indexOf(value) !== -1; + } + + if (test === "|=") { // DashMatch + return attr === value || attr.startsWith(value + "-"); + } + }; } public toString(): string { return `[${this.attribute}${wrap(this.test)}${(this.test && this.value) || ""}]${wrap(this.combinator)}`; } public match(node: Node): boolean { return false; } @@ -252,7 +244,13 @@ export class Selector extends SelectorCore { let siblingGroup: SimpleSelector[]; let lastGroup: SimpleSelector[][]; let groups: SimpleSelector[][][] = []; - selectors.reverse().forEach(sel => { + + this.specificity = 0; + this.dynamic = false; + + for (let i = selectors.length - 1; i > -1; i--) { + const sel = selectors[i]; + if (supportedCombinator.indexOf(sel.combinator) === -1) { throw new Error(`Unsupported combinator "${sel.combinator}".`); } @@ -262,16 +260,22 @@ export class Selector extends SelectorCore { if (sel.combinator === ">") { lastGroup.push(siblingGroup = []); } + + this.specificity += sel.specificity; + + if (sel.dynamic) { + this.dynamic = true; + } + siblingGroup.push(sel); - }); + } + this.groups = groups.map(g => new Selector.ChildGroup(g.map(sg => new Selector.SiblingGroup(sg) )) ); - this.last = selectors[0]; - this.specificity = selectors.reduce((sum, sel) => sel.specificity + sum, 0); - this.dynamic = selectors.some(sel => sel.dynamic); + this.last = selectors[selectors.length - 1]; } public toString(): string { return this.selectors.join(""); } @@ -364,15 +368,15 @@ export namespace Selector { } public match(node: Node): Node { - return this.selectors.every((sel, i) => (i === 0 ? node : node = node.parent) && !!sel.match(node)) ? node : null; + return this.selectors.every((sel, i) => (node = (i === 0 ? node : node.parent)) && sel.match(node)) ? node : null; } public mayMatch(node: Node): Node { - return this.selectors.every((sel, i) => (i === 0 ? node : node = node.parent) && !!sel.mayMatch(node)) ? node : null; + return this.selectors.every((sel, i) => (node = (i === 0 ? node : node.parent)) && sel.mayMatch(node)) ? node : null; } public trackChanges(node: Node, map: ChangeAccumulator) { - this.selectors.forEach((sel, i) => (i === 0 ? node : node = node.parent) && sel.trackChanges(node, map)); + this.selectors.forEach((sel, i) => (node = (i === 0 ? node : node.parent)) && sel.trackChanges(node, map)); } } export class SiblingGroup { @@ -383,15 +387,15 @@ export namespace Selector { } public match(node: Node): Node { - return this.selectors.every((sel, i) => (i === 0 ? node : node = getNodeDirectSibling(node)) && sel.match(node)) ? node : null; + return this.selectors.every((sel, i) => (node = (i === 0 ? node : getNodeDirectSibling(node))) && sel.match(node)) ? node : null; } public mayMatch(node: Node): Node { - return this.selectors.every((sel, i) => (i === 0 ? node : node = getNodeDirectSibling(node)) && sel.mayMatch(node)) ? node : null; + return this.selectors.every((sel, i) => (node = (i === 0 ? node : getNodeDirectSibling(node))) && sel.mayMatch(node)) ? node : null; } public trackChanges(node: Node, map: ChangeAccumulator) { - this.selectors.forEach((sel, i) => (i === 0 ? node : node = getNodeDirectSibling(node)) && sel.trackChanges(node, map)); + this.selectors.forEach((sel, i) => (node = (i === 0 ? node : getNodeDirectSibling(node))) && sel.trackChanges(node, map)); } } export interface Bound { @@ -412,9 +416,8 @@ export function fromAstNodes(astRules: cssParser.Node[]): RuleSet[] { return (astRules.filter(isRule)).map(rule => { let declarations = rule.declarations.filter(isDeclaration).map(createDeclaration); let selectors = rule.selectors.map(createSelector); - let ruleset = new RuleSet(selectors, declarations); - return ruleset; + return new RuleSet(selectors, declarations); }); } @@ -423,13 +426,28 @@ function createDeclaration(decl: cssParser.Declaration): any { } function createSimpleSelectorFromAst(ast: parser.SimpleSelector): SimpleSelector { - switch (ast.type) { - case "*": return new UniversalSelector(); - case "#": return new IdSelector(ast.identifier); - case "": return new TypeSelector(ast.identifier.replace(/-/, "").toLowerCase()); - case ".": return new ClassSelector(ast.identifier); - case ":": return new PseudoClassSelector(ast.identifier); - case "[]": return ast.test ? new AttributeSelector(ast.property, ast.test, ast.value) : new AttributeSelector(ast.property); + if (ast.type === ".") { + return new ClassSelector(ast.identifier); + } + + if (ast.type === "") { + return new TypeSelector(ast.identifier.replace("-", "").toLowerCase()); + } + + if (ast.type === "#") { + return new IdSelector(ast.identifier); + } + + if (ast.type === "[]") { + return new AttributeSelector(ast.property, ast.test, ast.test && ast.value); + } + + if (ast.type === ":") { + return new PseudoClassSelector(ast.identifier); + } + + if (ast.type === "*") { + return new UniversalSelector(); } } @@ -485,18 +503,14 @@ function isDeclaration(node: cssParser.Node): node is cssParser.Declaration { return node.type === "declaration"; } -interface SelectorInDocument { - pos: number; - sel: SelectorCore; -} interface SelectorMap { - [key: string]: SelectorInDocument[]; + [key: string]: SelectorCore[]; } export class SelectorsMap implements LookupSorter { private id: SelectorMap = {}; private class: SelectorMap = {}; private type: SelectorMap = {}; - private universal: SelectorInDocument[] = []; + private universal: SelectorCore[] = []; private position = 0; @@ -505,24 +519,23 @@ export class SelectorsMap implements LookupSorter { } query(node: T): SelectorsMatch { - let selectorClasses = [ + const selectorsMatch = new SelectorsMatch(); + const { cssClasses, id, cssType } = node; + const selectorClasses = [ this.universal, - this.id[node.id], - this.type[node.cssType] + this.id[id], + this.type[cssType] ]; - if (node.cssClasses) { - node.cssClasses.forEach(c => selectorClasses.push(this.class[c])); - } - let selectors = selectorClasses - .filter(arr => !!arr) - .reduce((cur, next) => cur.concat(next), []); - let selectorsMatch = new SelectorsMatch(); + if (cssClasses && cssClasses.size) { + cssClasses.forEach(c => selectorClasses.push(this.class[c])); + } + + const selectors = selectorClasses.reduce((cur, next) => cur.concat(next || []), []); selectorsMatch.selectors = selectors - .filter(sel => sel.sel.accumulateChanges(node, selectorsMatch)) - .sort((a, b) => a.sel.specificity - b.sel.specificity || a.pos - b.pos) - .map(docSel => docSel.sel); + .filter(sel => sel.accumulateChanges(node, selectorsMatch)) + .sort((a, b) => a.specificity - b.specificity || a.pos - b.pos); return selectorsMatch; } @@ -537,17 +550,17 @@ export class SelectorsMap implements LookupSorter { sortAsUniversal(sel: SelectorCore): void { this.universal.push(this.makeDocSelector(sel)); } private addToMap(map: SelectorMap, head: string, sel: SelectorCore): void { - this.position++; - let list = map[head]; - if (list) { - list.push(this.makeDocSelector(sel)); - } else { - map[head] = [this.makeDocSelector(sel)]; + if (!map[head]) { + map[head] = []; } + + map[head].push(this.makeDocSelector(sel)); } - private makeDocSelector(sel: SelectorCore): SelectorInDocument { - return { sel, pos: this.position++ }; + private makeDocSelector(sel: SelectorCore): SelectorCore { + sel.pos = this.position++; + + return sel; } } diff --git a/nativescript-core/ui/styling/style-scope.ts b/nativescript-core/ui/styling/style-scope.ts index 47b96e92b..0f4addb6b 100644 --- a/nativescript-core/ui/styling/style-scope.ts +++ b/nativescript-core/ui/styling/style-scope.ts @@ -801,7 +801,7 @@ export class StyleScope { } if (toMerge.length > 0) { - this._mergedCssSelectors = toMerge.filter(m => !!m).reduce((merged, next) => merged.concat(next), []); + this._mergedCssSelectors = toMerge.reduce((merged, next) => merged.concat(next || []), []); this._applyKeyframesOnSelectors(); this._selectors = new SelectorsMap(this._mergedCssSelectors); } diff --git a/tests/webpack.config.js b/tests/webpack.config.js index e3f351435..fbc899144 100644 --- a/tests/webpack.config.js +++ b/tests/webpack.config.js @@ -12,7 +12,7 @@ const { NativeScriptWorkerPlugin } = require("nativescript-worker-loader/NativeS const TerserPlugin = require("terser-webpack-plugin"); const hashSalt = Date.now().toString(); -const ANDROID_MAX_CYCLES = 66; +const ANDROID_MAX_CYCLES = 65; const IOS_MAX_CYCLES = 32; let numCyclesDetected = 0;