From 76698d38f77085ea154a44d93326f3f528534e33 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Fri, 22 Nov 2019 20:40:29 -0800 Subject: [PATCH] refactor(ivy): Add style reconciliation algorithm (#34004) This change introduces class/style reconciliation algorithm for DOM elements. NOTE: The code is not yet hooked up, it will be used by future style algorithm. Background: Styling algorithm currently has [two paths](https://hackmd.io/@5zDGNGArSxiHhgvxRGrg-g/rycZk3N5S) when computing how the style should be rendered. 1. A direct path which concatenates styling and uses `elemnent.className`/`element.style.cssText` and 2. A merge path which uses internal data structures and uses `element.classList.add/remove`/`element.style[property]`. The situation is confusing and hard to follow/maintain. So a future PR will remove the merge-path and do everything with direct-path. This however breaks when some other code adds class or style to the element without Angular's knowledge. If this happens instead of switching from direct-path to merge-path algorithm, this change provides a different mental model whereby we always do `direct-path` but the code which writes to the DOM detects the situation and reconciles the out of bound write. The reconciliation process is as follows: 1. Detect that no one has modified `className`/`cssText` and if so just write directly (fast path). 2. If out of bounds write did occur, switch from writing using `className`/`cssText` to `element.classList.add/remove`/`element.style[property]`. This does require that the write function computes the difference between the previous Angular expected state and current Angular state. (This requires a parser. The advantage of having a parser is that we can support `style="width: {{exp}}px" kind of bindings.`) Compute the diff and apply it in non destructive way using `element.classList.add/remove`/`element.style[property]` Properties of approach: - If no out of bounds style modification: - Very fast code path: Just concatenate string in right order and write them to DOM. - Class list order is preserved - If out of bounds style modification detected: - Penalty for parsing - Switch to non destructive modification: `element.classList.add/remove`/`element.style[property]` - Switch to alphabetical way of setting classes. PR Close #34004 --- .../compiler/src/render3/view/style_parser.ts | 2 +- .../core/src/render3/styling/class_differ.ts | 131 +++++++++++++ .../core/src/render3/styling/reconcile.ts | 175 ++++++++++++++++++ .../core/src/render3/styling/style_differ.ts | 172 +++++++++++++++++ .../src/render3/styling/styling_parser.ts | 153 +++++++++++++++ packages/core/src/render3/util/attrs_utils.ts | 3 +- .../core/src/render3/util/styling_utils.ts | 3 +- packages/core/src/util/char_code.ts | 32 ++++ packages/core/src/util/ng_dev_mode.ts | 2 + packages/core/test/render3/perf/BUILD.bazel | 14 ++ .../core/test/render3/perf/micro_bench.ts | 2 +- .../test/render3/perf/split_class_list.ts | 64 +++++++ .../render3/styling_next/class_differ_spec.ts | 108 +++++++++++ .../render3/styling_next/reconcile_spec.ts | 124 +++++++++++++ .../render3/styling_next/style_differ_spec.ts | 129 +++++++++++++ 15 files changed, 1110 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/render3/styling/class_differ.ts create mode 100644 packages/core/src/render3/styling/reconcile.ts create mode 100644 packages/core/src/render3/styling/style_differ.ts create mode 100644 packages/core/src/render3/styling/styling_parser.ts create mode 100644 packages/core/src/util/char_code.ts create mode 100644 packages/core/test/render3/perf/split_class_list.ts create mode 100644 packages/core/test/render3/styling_next/class_differ_spec.ts create mode 100644 packages/core/test/render3/styling_next/reconcile_spec.ts create mode 100644 packages/core/test/render3/styling_next/style_differ_spec.ts diff --git a/packages/compiler/src/render3/view/style_parser.ts b/packages/compiler/src/render3/view/style_parser.ts index 97b3f8c83f..f0de8e362b 100644 --- a/packages/compiler/src/render3/view/style_parser.ts +++ b/packages/compiler/src/render3/view/style_parser.ts @@ -30,7 +30,7 @@ export function parse(value: string): string[] { // we use a string array here instead of a string map // because a string-map is not guaranteed to retain the // order of the entries whereas a string array can be - // construted in a [key, value, key, value] format. + // constructed in a [key, value, key, value] format. const styles: string[] = []; let i = 0; diff --git a/packages/core/src/render3/styling/class_differ.ts b/packages/core/src/render3/styling/class_differ.ts new file mode 100644 index 0000000000..2c5cd26efa --- /dev/null +++ b/packages/core/src/render3/styling/class_differ.ts @@ -0,0 +1,131 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ + +import {CharCode} from '../../util/char_code'; +import {consumeClassToken, consumeWhitespace} from './styling_parser'; + +/** + * Computes the diff between two class-list strings. + * + * Example: + * `oldValue` => `"A B C"` + * `newValue` => `"A C D"` + * will result in: + * ``` + * new Map([ + * ['A', null], + * ['B', false], + * ['C', null], + * ['D', true] + * ]) + * ``` + * + * @param oldValue Previous class-list string. + * @param newValue New class-list string. + * @returns A `Map` which will be filled with changes. + * - `true`: Class needs to be added to the element. + * - `false: Class needs to be removed from the element. + * - `null`: No change (leave class as is.) + */ +export function computeClassChanges(oldValue: string, newValue: string): Map { + const changes = new Map(); + splitClassList(oldValue, changes, false); + splitClassList(newValue, changes, true); + return changes; +} + +/** + * Splits the class list into array, ignoring whitespace and add it to corresponding categories + * `changes`. + * + * @param text Class list to split + * @param changes Map which will be filled with changes. (`false` - remove; `null` - noop; + * `true` - add.) + * @param isNewValue `true` if we are processing new list. + */ +export function splitClassList( + text: string, changes: Map, isNewValue: boolean): void { + const end = text.length; + let index = 0; + while (index < end) { + index = consumeWhitespace(text, index, end); + const tokenEnd = consumeClassToken(text, index, end); + if (tokenEnd !== index) { + processClassToken(changes, text.substring(index, tokenEnd), isNewValue); + } + index = tokenEnd; + } +} + +/** + * Processes the token by adding it to the `changes` Map. + * + * @param changes Map which keeps track of what should be done with each value. + * - `false` The token should be deleted. (It was in old list, but not in new list.) + * - `null` The token should be ignored. (It was present in old list as well as new list.) + * - `true` the token should be added. (It was only present in the new value) + * @param token Token to add to set. + * @param isNewValue True if invocation represents an addition (removal otherwise.) + * - `false` means that we are processing the old value, which may need to be deleted. + * Initially all tokens are labeled `false` (remove it.) + * - `true` means that we are processing new value which may need to be added. If a token + * with same key already exists with `false` then the resulting token is `null` (no + * change.) If no token exists then the new token value is `true` (add it.) + */ +export function processClassToken( + changes: Map, token: string, isNewValue: boolean) { + if (isNewValue) { + // This code path is executed when we are iterating over new values. + const existingTokenValue = changes.get(token); + if (existingTokenValue === undefined) { + // the new list has a token which is not present in the old list. Mark it for addition. + changes.set(token, true); + } else if (existingTokenValue === false) { + // If the existing value is `false` this means it was in the old list. Because it is in the + // new list as well we marked it as `null` (noop.) + changes.set(token, null); + } + } else { + // This code path is executed when we are iterating over previous values. + // This means that we store the tokens in `changes` with `false` (removals). + changes.set(token, false); + } +} + +/** + * Removes a class from a `className` string. + * + * @param className A string containing classes (whitespace separated) + * @param classToRemove A class name to remove from the `className` + * @returns a new class-list which does not have `classToRemove` + */ +export function removeClass(className: string, classToRemove: string): string { + let start = 0; + let end = className.length; + while (start < end) { + start = className.indexOf(classToRemove, start); + if (start === -1) { + // we did not find anything, so just bail. + break; + } + const removeLength = classToRemove.length; + const hasLeadingWhiteSpace = start === 0 || className.charCodeAt(start - 1) <= CharCode.SPACE; + const hasTrailingWhiteSpace = start + removeLength === end || + className.charCodeAt(start + removeLength) <= CharCode.SPACE; + if (hasLeadingWhiteSpace && hasTrailingWhiteSpace) { + // Cut out the class which should be removed. + const endWhitespace = consumeWhitespace(className, start + removeLength, end); + className = className.substring(0, start) + className.substring(endWhitespace, end); + end = className.length; + } else { + // in this case we are only a substring of the actual class, move on. + start = start + removeLength; + } + } + return className; +} \ No newline at end of file diff --git a/packages/core/src/render3/styling/reconcile.ts b/packages/core/src/render3/styling/reconcile.ts new file mode 100644 index 0000000000..cd8ef0057b --- /dev/null +++ b/packages/core/src/render3/styling/reconcile.ts @@ -0,0 +1,175 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ + +import {ProceduralRenderer3, RElement, Renderer3, isProceduralRenderer} from '../interfaces/renderer'; +import {computeClassChanges} from './class_differ'; +import {computeStyleChanges} from './style_differ'; + +/** + * Writes new `className` value in the DOM node. + * + * In its simplest form this function just writes the `newValue` into the `element.className` + * property. + * + * However, under some circumstances this is more complex because there could be other code which + * has added `class` information to the DOM element. In such a case writing our new value would + * clobber what is already on the element and would result in incorrect behavior. + * + * To solve the above the function first reads the `element.className` to see if it matches the + * `expectedValue`. (In our case `expectedValue` is just last value written into the DOM.) In this + * way we can detect to see if anyone has modified the DOM since our last write. + * - If we detect no change we simply write: `element.className = newValue`. + * - If we do detect change then we compute the difference between the `expectedValue` and + * `newValue` and then use `element.classList.add` and `element.classList.remove` to modify the + * DOM. + * + * NOTE: Some platforms (such as NativeScript and WebWorkers) will not have `element.className` + * available and reading the value will result in `undefined`. This means that for those platforms + * we will always fail the check and will always use `element.classList.add` and + * `element.classList.remove` to modify the `element`. (A good mental model is that we can do + * `element.className === expectedValue` but we may never know the actual value of + * `element.className`) + * + * @param renderer Renderer to use + * @param element The element which needs to be updated. + * @param expectedValue The expected (previous/old) value of the class list which we will use to + * check if out of bounds modification has happened to the `element`. + * @param newValue The new class list to write. + */ +export function writeAndReconcileClass( + renderer: Renderer3, element: RElement, expectedValue: string, newValue: string): void { + if (element.className === expectedValue) { + // This is the simple/fast case where no one has written into element without our knowledge. + if (isProceduralRenderer(renderer)) { + renderer.setAttribute(element, 'class', newValue); + } else { + element.className = newValue; + } + ngDevMode && ngDevMode.rendererSetClassName++; + } else { + // The expected value is not the same as last value. Something changed the DOM element without + // our knowledge so we need to do reconciliation instead. + reconcileClassNames(renderer, element, expectedValue, newValue); + } +} + +/** +* Writes new `cssText` value in the DOM node. +* +* In its simplest form this function just writes the `newValue` into the `element.style.cssText` +* property. +* +* However, under some circumstances this is more complex because there could be other code which +* has added `style` information to the DOM element. In such a case writing our new value would +* clobber what is already on the element and would result in incorrect behavior. +* +* To solve the above the function first reads the `element.style.cssText` to see if it matches the +* `expectedValue`. (In our case `expectedValue` is just last value written into the DOM.) In this +* way we can detect to see if anyone has modified the DOM since our last write. +* - If we detect no change we simply write: `element.style.cssText = newValue` +* - If we do detect change then we compute the difference between the `expectedValue` and +* `newValue` and then use `element.style[property]` to modify the DOM. +* +* NOTE: Some platforms (such as NativeScript and WebWorkers) will not have `element.style` +* available and reading the value will result in `undefined` This means that for those platforms we +* will always fail the check and will always use `element.style[property]` to +* modify the `element`. (A good mental model is that we can do `element.style.cssText === +* expectedValue` but we may never know the actual value of `element.style.cssText`) +* +* @param renderer Renderer to use +* @param element The element which needs to be updated. +* @param expectedValue The expected (previous/old) value of the class list to write. +* @param newValue The new class list to write +*/ +export function writeAndReconcileStyle( + renderer: Renderer3, element: RElement, expectedValue: string, newValue: string): void { + const style = (element as HTMLElement).style; + if (style != null && style.cssText === expectedValue) { + // This is the simple/fast case where no one has written into element without our knowledge. + if (isProceduralRenderer(renderer)) { + renderer.setAttribute(element, 'style', newValue); + } else { + style.cssText = newValue; + } + ngDevMode && ngDevMode.rendererCssText++; + } else { + // The expected value is not the same as last value. Something changed the DOM element without + // our knowledge so we need to do reconciliation instead. + reconcileStyleNames(renderer, element, expectedValue, newValue); + } +} + +/** + * Writes to `classNames` by computing the difference between `oldValue` and `newValue` and using + * `classList.add` and `classList.remove`. + * + * NOTE: Keep this a separate function so that `writeAndReconcileClass` is small and subject to + * inlining. (We expect that this function will be called rarely.) + * + * @param renderer Renderer to use when updating DOM. + * @param element The native element to update. + * @param oldValue Old value of `classNames`. + * @param newValue New value of `classNames`. + */ +function reconcileClassNames( + renderer: Renderer3, element: RElement, oldValue: string, newValue: string) { + const isProcedural = isProceduralRenderer(renderer); + computeClassChanges(oldValue, newValue).forEach((classValue, className) => { + if (classValue === true) { + if (isProcedural) { + (renderer as ProceduralRenderer3).addClass(element, className); + } else { + (element as HTMLElement).classList.add(className); + } + ngDevMode && ngDevMode.rendererAddClass++; + } else if (classValue === false) { + if (isProcedural) { + (renderer as ProceduralRenderer3).removeClass(element, className); + } else { + (element as HTMLElement).classList.remove(className); + } + ngDevMode && ngDevMode.rendererRemoveClass++; + } + }); +} + +/** + * Writes to `styles` by computing the difference between `oldValue` and `newValue` and using + * `styles.setProperty` and `styles.removeProperty`. + * + * NOTE: Keep this a separate function so that `writeAndReconcileStyle` is small and subject to + * inlining. (We expect that this function will be called rarely.) + * + * @param renderer Renderer to use when updating DOM. + * @param element The DOM element to update. + * @param oldValue Old value of `classNames`. + * @param newValue New value of `classNames`. + */ +function reconcileStyleNames( + renderer: Renderer3, element: RElement, oldValue: string, newValue: string) { + const isProcedural = isProceduralRenderer(renderer); + const changes = computeStyleChanges(oldValue, newValue); + changes.forEach((styleValue, styleName) => { + const newValue = styleValue.new; + if (newValue === null) { + if (isProcedural) { + (renderer as ProceduralRenderer3).removeStyle(element, styleName); + } else { + (element as HTMLElement).style.removeProperty(styleName); + } + ngDevMode && ngDevMode.rendererRemoveStyle++; + } else if (styleValue.old !== newValue) { + if (isProcedural) { + (renderer as ProceduralRenderer3).setStyle(element, styleName, newValue); + } else { + (element as HTMLElement).style.setProperty(styleName, newValue); + } + ngDevMode && ngDevMode.rendererSetStyle++; + } + }); +} diff --git a/packages/core/src/render3/styling/style_differ.ts b/packages/core/src/render3/styling/style_differ.ts new file mode 100644 index 0000000000..714d5ca1a7 --- /dev/null +++ b/packages/core/src/render3/styling/style_differ.ts @@ -0,0 +1,172 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ + +import {CharCode} from '../../util/char_code'; +import {consumeSeparator, consumeStyleKey, consumeStyleValue, consumeWhitespace} from './styling_parser'; + + + +/** + * Stores changes to Style values. + * - `key`: style name. + * - `value`: + * - `old`: previous value (or `null`) + * - `new`: new value (or `null`). + * + * If `old === new` do nothing. + * If `old === null` then add `new`. + * If `new === null` then remove `old`. + */ +export type StyleChangesMap = Map; + +/** + * Computes the diff between two style strings. + * + * Example: + * `oldValue` => `"a: 1; b: 2, c: 3"` + * `newValue` => `"b: 2; c: 4; d: 5;"` + * will result in: + * ``` + * changes = Map( + * 'a', { old: '1', new: null }, + * 'b', { old: '2', new: '2' }, + * 'c', { old: '3', new: '4' }, + * 'd', { old: null, new: '5' }, + * ) + * `` + * + * @param oldValue Previous style string. + * @param newValue New style string. + * @returns `StyleChangesArrayMap`. + */ +export function computeStyleChanges(oldValue: string, newValue: string): StyleChangesMap { + const changes: StyleChangesMap = new Map(); + parseKeyValue(oldValue, changes, false); + parseKeyValue(newValue, changes, true); + return changes; +} + +/** + * Splits the style list into array, ignoring whitespace and add it to corresponding categories + * changes. + * + * @param text Style list to split + * @param changes Where changes will be stored. + * @param isNewValue `true` if parsing new value (effects how values get added to `changes`) + */ +export function parseKeyValue(text: string, changes: StyleChangesMap, isNewValue: boolean): void { + const end = text.length; + let start = 0; + while (start < end) { + const keyStart = consumeWhitespace(text, start, end); + const keyEnd = consumeStyleKey(text, keyStart, end); + if (keyEnd === keyStart) { + // we reached an end so just quit + break; + } + const valueStart = consumeSeparator(text, keyEnd, end, CharCode.COLON); + const valueEnd = consumeStyleValue(text, valueStart, end); + if (ngDevMode && valueStart === valueEnd) { + throw malformedStyleError(text, valueStart); + } + start = consumeSeparator(text, valueEnd, end, CharCode.SEMI_COLON); + const key = text.substring(keyStart, keyEnd); + const value = text.substring(valueStart, valueEnd); + processStyleKeyValue(changes, key, value, isNewValue); + } +} + +/** + * Appends style `key`/`value` information into the list of `changes`. + * + * Once all of the parsing is complete, the `changes` will contain a + * set of operations which need to be performed on the DOM to reconcile it. + * + * @param changes An `StyleChangesMap which tracks changes. + * @param key Style key to be added to the `changes`. + * @param value Style value to be added to the `changes`. + * @param isNewValue true if `key`/`value` should be processed as new value. + */ +function processStyleKeyValue( + changes: StyleChangesMap, key: string, value: string, isNewValue: boolean): void { + if (isNewValue) { + // This code path is executed when we are iterating over new values. + const existing = changes.get(key); + if (existing === undefined) { + // Key we have not seen before + changes.set(key, styleKeyValue(null, value)); + } else { + // Already seen, update value. + existing.new = value; + } + } else { + // This code path is executed when we are iteration over previous values. + changes.set(key, styleKeyValue(value, null)); + } +} + +function styleKeyValue(oldValue: string | null, newValue: string | null) { + return {old: oldValue, new: newValue}; +} + +/** + * Removes a style from a `cssText` string. + * + * @param cssText A string which contains styling. + * @param styleToRemove A style (and its value) to remove from `cssText`. + * @returns a new style text which does not have `styleToRemove` (and its value) + */ +export function removeStyle(cssText: string, styleToRemove: string): string { + let start = 0; + let end = cssText.length; + let lastValueEnd = 0; + while (start < end) { + const possibleKeyIndex = cssText.indexOf(styleToRemove, start); + if (possibleKeyIndex === -1) { + // we did not find anything, so just bail. + break; + } + while (start < possibleKeyIndex + 1) { + const keyStart = consumeWhitespace(cssText, start, end); + const keyEnd = consumeStyleKey(cssText, keyStart, end); + if (keyEnd === keyStart) { + // we reached the end + return cssText; + } + const valueStart = consumeSeparator(cssText, keyEnd, end, CharCode.COLON); + const valueEnd = consumeStyleValue(cssText, valueStart, end); + if (ngDevMode && valueStart === valueEnd) { + throw malformedStyleError(cssText, valueStart); + } + const valueEndSep = consumeSeparator(cssText, valueEnd, end, CharCode.SEMI_COLON); + if (keyStart == possibleKeyIndex && keyEnd === possibleKeyIndex + styleToRemove.length) { + if (valueEndSep == end) { + // This is a special case when we are the last key in a list, we then chop off the + // trailing separator as well. + cssText = cssText.substring(0, lastValueEnd); + } else { + cssText = cssText.substring(0, keyStart) + cssText.substring(valueEndSep, end); + } + end = cssText.length; + start = keyStart; + break; // rescan. + } else { + // This was not the item we are looking for, keep going. + start = valueEndSep; + } + lastValueEnd = valueEnd; + } + } + return cssText; +} + +function malformedStyleError(text: string, index: number) { + return new Error( + `Malformed style at location ${index} in string '` + text.substring(0, index) + '[>>' + + text.substring(index, index + 1) + '<<]' + text.substr(index + 1) + '\'.'); +} \ No newline at end of file diff --git a/packages/core/src/render3/styling/styling_parser.ts b/packages/core/src/render3/styling/styling_parser.ts new file mode 100644 index 0000000000..64f32c7843 --- /dev/null +++ b/packages/core/src/render3/styling/styling_parser.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CharCode} from '../../util/char_code'; + +/** + * Returns index of next non-whitespace character. + * + * @param text Text to scan + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index of next non-whitespace character (May be the same as `start` if no whitespace at + * that location.) + */ +export function consumeWhitespace(text: string, startIndex: number, endIndex: number): number { + while (startIndex < endIndex && text.charCodeAt(startIndex) <= CharCode.SPACE) { + startIndex++; + } + return startIndex; +} + +/** + * Returns index of last char in class token. + * + * @param text Text to scan + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index after last char in class token. + */ +export function consumeClassToken(text: string, startIndex: number, endIndex: number): number { + while (startIndex < endIndex && text.charCodeAt(startIndex) > CharCode.SPACE) { + startIndex++; + } + return startIndex; +} + +/** + * Consumes all of the characters belonging to style key and token. + * + * @param text Text to scan + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index after last style key character. + */ +export function consumeStyleKey(text: string, startIndex: number, endIndex: number): number { + let ch: number; + while (startIndex < endIndex && + ((ch = text.charCodeAt(startIndex)) === CharCode.DASH || ch === CharCode.UNDERSCORE || + ((ch & CharCode.UPPER_CASE) >= CharCode.A && (ch & CharCode.UPPER_CASE) <= CharCode.Z))) { + startIndex++; + } + return startIndex; +} + +/** + * Consumes all whitespace and the separator `:` after the style key. + * + * @param text Text to scan + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index after separator and surrounding whitespace. + */ +export function consumeSeparator( + text: string, startIndex: number, endIndex: number, separator: number): number { + startIndex = consumeWhitespace(text, startIndex, endIndex); + if (startIndex < endIndex) { + if (ngDevMode && text.charCodeAt(startIndex) !== separator) { + throw expectingError(text, String.fromCharCode(separator), startIndex); + } + startIndex++; + } + startIndex = consumeWhitespace(text, startIndex, endIndex); + return startIndex; +} + + +/** + * Consumes style value honoring `url()` and `""` text. + * + * @param text Text to scan + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index after last style value character. +*/ +export function consumeStyleValue(text: string, startIndex: number, endIndex: number): number { + let ch1 = -1; // 1st previous character + let ch2 = -1; // 2nd previous character + let ch3 = -1; // 3rd previous character + let i = startIndex; + let lastChIndex = i; + while (i < endIndex) { + const ch: number = text.charCodeAt(i++); + if (ch === CharCode.SEMI_COLON) { + return lastChIndex; + } else if (ch === CharCode.DOUBLE_QUOTE || ch === CharCode.SINGLE_QUOTE) { + lastChIndex = i = consumeQuotedText(text, ch, i, endIndex); + } else if ( + startIndex === + i - 4 && // We have seen only 4 characters so far "URL(" (Ignore "foo_URL()") + ch3 === CharCode.U && + ch2 === CharCode.R && ch1 === CharCode.L && ch === CharCode.OPEN_PAREN) { + lastChIndex = i = consumeQuotedText(text, CharCode.CLOSE_PAREN, i, endIndex); + } else if (ch > CharCode.SPACE) { + // if we have a non-whitespace character then capture its location + lastChIndex = i; + } + ch3 = ch2; + ch2 = ch1; + ch1 = ch & CharCode.UPPER_CASE; + } + return lastChIndex; +} + +/** + * Consumes all of the quoted characters. + * + * @param text Text to scan + * @param quoteCharCode CharCode of either `"` or `'` quote or `)` for `url(...)`. + * @param startIndex Starting index of character where the scan should start. + * @param endIndex Ending index of character where the scan should end. + * @returns Index after quoted characters. + */ +export function consumeQuotedText( + text: string, quoteCharCode: number, startIndex: number, endIndex: number): number { + let ch1 = -1; // 1st previous character + let index = startIndex; + while (index < endIndex) { + const ch = text.charCodeAt(index++); + if (ch == quoteCharCode && ch1 !== CharCode.BACK_SLASH) { + return index; + } + if (ch == CharCode.BACK_SLASH && ch1 === CharCode.BACK_SLASH) { + // two back slashes cancel each other out. For example `"\\"` should properly end the + // quotation. (It should not assume that the last `"` is escaped.) + ch1 = 0; + } else { + ch1 = ch; + } + } + throw ngDevMode ? expectingError(text, String.fromCharCode(quoteCharCode), endIndex) : + new Error(); +} + +function expectingError(text: string, expecting: string, index: number) { + return new Error( + `Expecting '${expecting}' at location ${index} in string '` + text.substring(0, index) + + '[>>' + text.substring(index, index + 1) + '<<]' + text.substr(index + 1) + '\'.'); +} \ No newline at end of file diff --git a/packages/core/src/render3/util/attrs_utils.ts b/packages/core/src/render3/util/attrs_utils.ts index dd0009ecba..0b67e9cd88 100644 --- a/packages/core/src/render3/util/attrs_utils.ts +++ b/packages/core/src/render3/util/attrs_utils.ts @@ -5,6 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {CharCode} from '../../util/char_code'; import {AttributeMarker, TAttributes} from '../interfaces/node'; import {CssSelector} from '../interfaces/projection'; import {ProceduralRenderer3, RElement, Renderer3, isProceduralRenderer} from '../interfaces/renderer'; @@ -103,5 +104,5 @@ export function isAnimationProp(name: string): boolean { // Perf note: accessing charCodeAt to check for the first character of a string is faster as // compared to accessing a character at index 0 (ex. name[0]). The main reason for this is that // charCodeAt doesn't allocate memory to return a substring. - return name.charCodeAt(0) === 64; // @ + return name.charCodeAt(0) === CharCode.AT_SIGN; } diff --git a/packages/core/src/render3/util/styling_utils.ts b/packages/core/src/render3/util/styling_utils.ts index c86a5c0171..7c87deea1b 100644 --- a/packages/core/src/render3/util/styling_utils.ts +++ b/packages/core/src/render3/util/styling_utils.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {unwrapSafeValue} from '../../sanitization/bypass'; +import {CharCode} from '../../util/char_code'; import {PropertyAliases, TNodeFlags} from '../interfaces/node'; import {LStylingData, StylingMapArray, StylingMapArrayIndex, TStylingContext, TStylingContextIndex, TStylingContextPropConfigFlags, TStylingNode} from '../interfaces/styling'; import {NO_CHANGE} from '../tokens'; @@ -431,7 +432,7 @@ export function splitOnWhitespace(text: string): string[]|null { let foundChar = false; for (let i = 0; i < length; i++) { const char = text.charCodeAt(i); - if (char <= 32 /*' '*/) { + if (char <= CharCode.SPACE) { if (foundChar) { if (array === null) array = []; array.push(text.substring(start, i)); diff --git a/packages/core/src/util/char_code.ts b/packages/core/src/util/char_code.ts new file mode 100644 index 0000000000..18a023dec4 --- /dev/null +++ b/packages/core/src/util/char_code.ts @@ -0,0 +1,32 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ + +/** + * List ASCII char codes to be used with `String.charCodeAt` + */ +export const enum CharCode { + UPPER_CASE = ~32, // & with this will make the char uppercase + SPACE = 32, // " " + DOUBLE_QUOTE = 34, // "\"" + SINGLE_QUOTE = 39, // "'" + OPEN_PAREN = 40, // "(" + CLOSE_PAREN = 41, // ")" + COLON = 58, // ":" + DASH = 45, // "-" + UNDERSCORE = 95, // "_" + SEMI_COLON = 59, // ";" + BACK_SLASH = 92, // "\\" + AT_SIGN = 64, // "@" + A = 65, // "A" + U = 85, // "U" + R = 82, // "R" + L = 76, // "L" + Z = 90, // "A" + a = 97, // "a" + z = 122, // "z" +} diff --git a/packages/core/src/util/ng_dev_mode.ts b/packages/core/src/util/ng_dev_mode.ts index 716ee46491..4a2819d7b6 100644 --- a/packages/core/src/util/ng_dev_mode.ts +++ b/packages/core/src/util/ng_dev_mode.ts @@ -42,6 +42,7 @@ declare global { rendererSetClassName: number; rendererAddClass: number; rendererRemoveClass: number; + rendererCssText: number; rendererSetStyle: number; rendererRemoveStyle: number; rendererDestroy: number; @@ -82,6 +83,7 @@ export function ngDevModeResetPerfCounters(): NgDevModePerfCounters { rendererSetClassName: 0, rendererAddClass: 0, rendererRemoveClass: 0, + rendererCssText: 0, rendererSetStyle: 0, rendererRemoveStyle: 0, rendererDestroy: 0, diff --git a/packages/core/test/render3/perf/BUILD.bazel b/packages/core/test/render3/perf/BUILD.bazel index 3369880398..c92cc5e155 100644 --- a/packages/core/test/render3/perf/BUILD.bazel +++ b/packages/core/test/render3/perf/BUILD.bazel @@ -9,6 +9,7 @@ ts_library( ), deps = [ "//packages/core", + "//packages/core/src/util", "@npm//@types/jasmine", "@npm//@types/node", ], @@ -215,3 +216,16 @@ ng_benchmark( name = "duplicate_map_based_style_and_class_bindings", bundle = ":duplicate_map_based_style_and_class_bindings_lib", ) + +ng_rollup_bundle( + name = "split_class_list_lib", + entry_point = ":split_class_list.ts", + deps = [ + ":perf_lib", + ], +) + +ng_benchmark( + name = "split_class_list", + bundle = ":split_class_list_lib", +) diff --git a/packages/core/test/render3/perf/micro_bench.ts b/packages/core/test/render3/perf/micro_bench.ts index 4bcb5d7ffc..00c5ff1c49 100644 --- a/packages/core/test/render3/perf/micro_bench.ts +++ b/packages/core/test/render3/perf/micro_bench.ts @@ -65,7 +65,7 @@ export function createBenchmark(benchmarkName: string): Benchmark { if (!runAgain) { // tslint:disable-next-line:no-console console.log( - ` ${formatTime(iterationTime_ms)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`); + ` ${formatTime(profile.bestTime)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`); } } iterationCounter = profile.iterationCount; diff --git a/packages/core/test/render3/perf/split_class_list.ts b/packages/core/test/render3/perf/split_class_list.ts new file mode 100644 index 0000000000..2b1ec9d341 --- /dev/null +++ b/packages/core/test/render3/perf/split_class_list.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {processClassToken, splitClassList} from '@angular/core/src/render3/styling/class_differ'; + +import {createBenchmark} from './micro_bench'; + +const benchmark = createBenchmark('split_class_list'); +const splitTime = benchmark('String.split(" ")'); +const splitRegexpTime = benchmark('String.split(/\\s+/)'); +const splitClassListTime = benchmark('splitClassList'); + +const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; +const CLASSES: string[] = [LETTERS]; +for (let i = 0; i < LETTERS.length; i++) { + CLASSES.push(LETTERS.substring(0, i) + ' ' + LETTERS.substring(i, LETTERS.length)); +} + +let index = 0; +let changes = new Map(); +let parts: string[] = []; +while (splitTime()) { + changes = clearArray(changes); + const classes = CLASSES[index++]; + parts = classes.split(' '); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part !== '') { + processClassToken(changes, part, false); + } + } + if (index === CLASSES.length) index = 0; +} + +const WHITESPACE = /\s+/m; +while (splitRegexpTime()) { + changes = clearArray(changes); + const classes = CLASSES[index++]; + parts = classes.split(WHITESPACE); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part !== '') { + processClassToken(changes, part, false); + } + } + if (index === CLASSES.length) index = 0; +} + +while (splitClassListTime()) { + changes = clearArray(changes); + splitClassList(CLASSES[index++], changes, false); + if (index === CLASSES.length) index = 0; +} + +benchmark.report(); + +function clearArray(a: Map): any { + a.clear(); +} \ No newline at end of file diff --git a/packages/core/test/render3/styling_next/class_differ_spec.ts b/packages/core/test/render3/styling_next/class_differ_spec.ts new file mode 100644 index 0000000000..3ee3f8086f --- /dev/null +++ b/packages/core/test/render3/styling_next/class_differ_spec.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {computeClassChanges, removeClass, splitClassList} from '../../../src/render3/styling/class_differ'; + +describe('class differ', () => { + describe('computeClassChanges', () => { + function expectComputeClassChanges(oldValue: string, newValue: string) { + const changes: (boolean | null | string)[] = []; + const newLocal = computeClassChanges(oldValue, newValue); + sortedForEach(newLocal, (value, key) => { changes.push(key, value); }); + return expect(changes); + } + + it('should detect no changes', () => { + expectComputeClassChanges('', '').toEqual([]); + expectComputeClassChanges('A', 'A').toEqual(['A', null]); + expectComputeClassChanges('A B', 'A B').toEqual(['A', null, 'B', null]); + }); + + it('should detect no changes when out of order', () => { + expectComputeClassChanges('A B', 'B A').toEqual(['A', null, 'B', null]); + expectComputeClassChanges('A B C', 'B C A').toEqual(['A', null, 'B', null, 'C', null]); + }); + + it('should detect additions', () => { + expectComputeClassChanges('A B', 'A B C').toEqual(['A', null, 'B', null, 'C', true]); + expectComputeClassChanges('Alpha Bravo', 'Bravo Alpha Charlie').toEqual([ + 'Alpha', null, 'Bravo', null, 'Charlie', true + ]); + expectComputeClassChanges('A B ', 'C B A').toEqual(['A', null, 'B', null, 'C', true]); + }); + + it('should detect removals', () => { + expectComputeClassChanges('A B C', 'A B').toEqual(['A', null, 'B', null, 'C', false]); + expectComputeClassChanges('B A C', 'B A').toEqual(['A', null, 'B', null, 'C', false]); + expectComputeClassChanges('C B A', 'A B').toEqual(['A', null, 'B', null, 'C', false]); + }); + + it('should detect duplicates and ignore them', () => { + expectComputeClassChanges('A A B C', 'A B C').toEqual(['A', null, 'B', null, 'C', null]); + expectComputeClassChanges('A A B', 'A A C').toEqual(['A', null, 'B', false, 'C', true]); + }); + }); + + describe('splitClassList', () => { + function expectSplitClassList(text: string) { + const changes: (boolean | null | string)[] = []; + const changesMap = new Map(); + splitClassList(text, changesMap, false); + changesMap.forEach((value, key) => changes.push(key, value)); + return expect(changes); + } + + it('should parse a list', () => { + expectSplitClassList('').toEqual([]); + expectSplitClassList('A').toEqual(['A', false]); + expectSplitClassList('A B').toEqual(['A', false, 'B', false]); + expectSplitClassList('Alpha Bravo').toEqual(['Alpha', false, 'Bravo', false]); + }); + + it('should ignore extra spaces', () => { + expectSplitClassList(' \n\r\t').toEqual([]); + expectSplitClassList(' A ').toEqual(['A', false]); + expectSplitClassList(' \n\r\t A \n\r\t B\n\r\t ').toEqual(['A', false, 'B', false]); + expectSplitClassList(' \n\r\t Alpha \n\r\t Bravo \n\r\t ').toEqual([ + 'Alpha', false, 'Bravo', false + ]); + }); + + it('should remove duplicates', () => { + expectSplitClassList('').toEqual([]); + expectSplitClassList('A A').toEqual(['A', false]); + expectSplitClassList('A B B A').toEqual(['A', false, 'B', false]); + expectSplitClassList('Alpha Bravo Bravo Alpha').toEqual(['Alpha', false, 'Bravo', false]); + }); + }); + + describe('removeClass', () => { + it('should remove class name from a class-list string', () => { + expect(removeClass('', '')).toEqual(''); + expect(removeClass('A', 'A')).toEqual(''); + expect(removeClass('AB', 'AB')).toEqual(''); + expect(removeClass('A B', 'A')).toEqual('B'); + expect(removeClass('A B', 'A')).toEqual('B'); + }); + + it('should not remove a sub-string', () => { + expect(removeClass('ABC', 'A')).toEqual('ABC'); + expect(removeClass('ABC', 'B')).toEqual('ABC'); + expect(removeClass('ABC', 'C')).toEqual('ABC'); + expect(removeClass('ABC', 'AB')).toEqual('ABC'); + expect(removeClass('ABC', 'BC')).toEqual('ABC'); + }); + }); +}); + +export function sortedForEach(map: Map, fn: (value: V, key: string) => void): void { + const keys: string[] = []; + map.forEach((value, key) => keys.push(key)); + keys.sort(); + keys.forEach((key) => fn(map.get(key) !, key)); +} diff --git a/packages/core/test/render3/styling_next/reconcile_spec.ts b/packages/core/test/render3/styling_next/reconcile_spec.ts new file mode 100644 index 0000000000..2ae9160b21 --- /dev/null +++ b/packages/core/test/render3/styling_next/reconcile_spec.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Renderer3, domRendererFactory3} from '@angular/core/src/render3/interfaces/renderer'; +import {writeAndReconcileClass, writeAndReconcileStyle} from '@angular/core/src/render3/styling/reconcile'; + +describe('styling reconcile', () => { + [document, domRendererFactory3.createRenderer(null, null)].forEach((renderer: Renderer3) => { + let element: HTMLDivElement; + beforeEach(() => { element = document.createElement('div'); }); + + describe('writeAndReconcileClass', () => { + it('should write new value to DOM', () => { + writeAndReconcileClass(renderer, element, '', 'A'); + expect(getSortedClassName(element)).toEqual('A'); + + writeAndReconcileClass(renderer, element, 'A', 'C B A'); + expect(getSortedClassName(element)).toEqual('A B C'); + + writeAndReconcileClass(renderer, element, 'C B A', ''); + expect(getSortedClassName(element)).toEqual(''); + }); + + it('should write value alphabetically when existing class present', () => { + element.className = 'X'; + writeAndReconcileClass(renderer, element, '', 'A'); + expect(getSortedClassName(element)).toEqual('A X'); + + writeAndReconcileClass(renderer, element, 'A', 'C B A'); + expect(getSortedClassName(element)).toEqual('A B C X'); + + writeAndReconcileClass(renderer, element, 'C B A', ''); + expect(getSortedClassName(element)).toEqual('X'); + }); + + }); + + describe('writeAndReconcileStyle', () => { + it('should write new value to DOM', () => { + writeAndReconcileStyle(renderer, element, '', 'width: 100px;'); + expect(getSortedStyle(element)).toEqual('width: 100px;'); + + writeAndReconcileStyle( + renderer, element, 'width: 100px;', 'color: red; height: 100px; width: 100px;'); + expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 100px;'); + + writeAndReconcileStyle(renderer, element, 'color: red; height: 100px; width: 100px;', ''); + expect(getSortedStyle(element)).toEqual(''); + }); + + it('should not clobber out of bound styles', () => { + element.style.cssText = 'color: red;'; + writeAndReconcileStyle(renderer, element, '', 'width: 100px;'); + expect(getSortedStyle(element)).toEqual('color: red; width: 100px;'); + + writeAndReconcileStyle(renderer, element, 'width: 100px;', 'width: 200px;'); + expect(getSortedStyle(element)).toEqual('color: red; width: 200px;'); + + writeAndReconcileStyle(renderer, element, 'width: 200px;', 'width: 200px; height: 100px;'); + expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 200px;'); + + writeAndReconcileStyle(renderer, element, 'width: 200px; height: 100px;', ''); + expect(getSortedStyle(element)).toEqual('color: red;'); + }); + + it('should support duplicate styles', () => { + element.style.cssText = 'color: red;'; + writeAndReconcileStyle(renderer, element, '', 'width: 100px; width: 200px;'); + expect(getSortedStyle(element)).toEqual('color: red; width: 200px;'); + + writeAndReconcileStyle( + renderer, element, 'width: 100px; width: 200px;', + 'width: 100px; width: 200px; height: 100px;'); + expect(getSortedStyle(element)).toEqual('color: red; height: 100px; width: 200px;'); + + writeAndReconcileStyle(renderer, element, 'width: 100px; height: 100px;', ''); + expect(getSortedStyle(element)).toEqual('color: red;'); + }); + }); + }); +}); + +function getSortedClassName(element: HTMLElement): string { + const names: string[] = []; + const classList = element.classList || []; + for (let i = 0; i < classList.length; i++) { + const name = classList[i]; + if (names.indexOf(name) === -1) { + names.push(name); + } + } + names.sort(); + return names.join(' '); +} + +function getSortedStyle(element: HTMLElement): string { + const names: string[] = []; + const style = element.style; + // reading `style.color` is a work around for a bug in Domino. The issue is that Domino has stale + // value for `style.length`. It seems that reading a property from the element causes the stale + // value to be updated. (As of Domino v 2.1.3) + style.color; + for (let i = 0; i < style.length; i++) { + const name = style.item(i); + if (names.indexOf(name) === -1) { + names.push(name); + } + } + names.sort(); + let sorted = ''; + names.forEach(key => { + const value = style.getPropertyValue(key); + if (value != null && value !== '') { + if (sorted !== '') sorted += ' '; + sorted += key + ': ' + value + ';'; + } + }); + return sorted; +} \ No newline at end of file diff --git a/packages/core/test/render3/styling_next/style_differ_spec.ts b/packages/core/test/render3/styling_next/style_differ_spec.ts new file mode 100644 index 0000000000..e161434f4c --- /dev/null +++ b/packages/core/test/render3/styling_next/style_differ_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {StyleChangesMap, parseKeyValue, removeStyle} from '@angular/core/src/render3/styling/style_differ'; +import {consumeSeparator, consumeStyleValue} from '@angular/core/src/render3/styling/styling_parser'; +import {CharCode} from '@angular/core/src/util/char_code'; +import {sortedForEach} from './class_differ_spec'; + +describe('style differ', () => { + describe('parseStyleValue', () => { + it('should parse empty value', () => { + expectParseValue(':').toBe(''); + expectParseValue(':;πŸ›‘ignore').toBe(''); + expectParseValue(': ;πŸ›‘ignore').toBe(''); + expectParseValue(':;πŸ›‘ignore').toBe(''); + expectParseValue(': \n\t\r ;πŸ›‘').toBe(''); + }); + + it('should parse basic value', () => { + expectParseValue(':a').toBe('a'); + expectParseValue(':text').toBe('text'); + expectParseValue(': text2 ;πŸ›‘').toBe('text2'); + expectParseValue(':text3;πŸ›‘').toBe('text3'); + expectParseValue(': text3 ;πŸ›‘').toBe('text3'); + expectParseValue(': text1 text2;πŸ›‘').toBe('text1 text2'); + expectParseValue(': text1 text2 ;πŸ›‘').toBe('text1 text2'); + }); + + it('should parse quoted values', () => { + expectParseValue(':""').toBe('""'); + expectParseValue(':"\\\\"').toBe('"\\\\"'); + expectParseValue(': ""').toBe('""'); + expectParseValue(': "" ').toBe('""'); + expectParseValue(': "text1" text2 ').toBe('"text1" text2'); + expectParseValue(':"text"').toBe('"text"'); + expectParseValue(': \'hello world\'').toBe('\'hello world\''); + expectParseValue(':"some \n\t\r text ,;";πŸ›‘').toBe('"some \n\t\r text ,;"'); + expectParseValue(':"\\"\'";πŸ›‘').toBe('"\\"\'"'); + }); + + it('should parse url()', () => { + expectParseValue(':url(:;)').toBe('url(:;)'); + expectParseValue(':URL(some :; text)').toBe('URL(some :; text)'); + expectParseValue(': url(text);πŸ›‘').toBe('url(text)'); + expectParseValue(': url(text) more text;πŸ›‘').toBe('url(text) more text'); + expectParseValue(':url(;"\':\\))').toBe('url(;"\':\\))'); + expectParseValue(': url(;"\':\\)) ;πŸ›‘').toBe('url(;"\':\\))'); + }); + }); + + describe('parseKeyValue', () => { + it('should parse empty value', () => { + expectParseKeyValue('').toEqual([]); + expectParseKeyValue(' \n\t\r ').toEqual([]); + }); + + it('should prase single style', () => { + expectParseKeyValue('width: 100px').toEqual(['width', '100px', null]); + expectParseKeyValue(' width : 100px ;').toEqual(['width', '100px', null]); + }); + + it('should prase multi style', () => { + expectParseKeyValue('width: 100px; height: 200px').toEqual([ + 'height', '200px', null, // + 'width', '100px', null, // + ]); + expectParseKeyValue(' height : 200px ; width : 100px ').toEqual([ + 'height', '200px', null, // + 'width', '100px', null // + ]); + }); + }); + + describe('removeStyle', () => { + it('should remove no style', () => { + expect(removeStyle('', 'foo')).toEqual(''); + expect(removeStyle('abc: bar', 'a')).toEqual('abc: bar'); + expect(removeStyle('abc: bar', 'b')).toEqual('abc: bar'); + expect(removeStyle('abc: bar', 'c')).toEqual('abc: bar'); + expect(removeStyle('abc: bar', 'bar')).toEqual('abc: bar'); + }); + + it('should remove all style', () => { + expect(removeStyle('foo: bar', 'foo')).toEqual(''); + expect(removeStyle('foo: bar; foo: bar;', 'foo')).toEqual(''); + }); + + it('should remove some of the style', () => { + expect(removeStyle('a: a; foo: bar; b: b', 'foo')).toEqual('a: a; b: b'); + expect(removeStyle('a: a; foo: bar; b: b; foo: bar; c: c', 'foo')) + .toEqual('a: a; b: b; c: c'); + }); + + it('should remove trailing ;', () => { + expect(removeStyle('a: a; foo: bar', 'foo')).toEqual('a: a'); + expect(removeStyle('a: a ; foo: bar ; ', 'foo')).toEqual('a: a'); + }); + }); +}); + +function expectParseValue( + /** + * The text to parse. + * + * The text can contain special πŸ›‘ character which demarcates where the parsing should stop + * and asserts that the parsing ends at that location. + */ + text: string) { + let stopIndex = text.indexOf('πŸ›‘'); + if (stopIndex < 0) stopIndex = text.length; + const valueStart = consumeSeparator(text, 0, text.length, CharCode.COLON); + const valueEnd = consumeStyleValue(text, valueStart, text.length); + const valueSep = consumeSeparator(text, valueEnd, text.length, CharCode.SEMI_COLON); + expect(valueSep).toBe(stopIndex); + return expect(text.substring(valueStart, valueEnd)); +} + +function expectParseKeyValue(text: string) { + const changes: StyleChangesMap = new Map(); + parseKeyValue(text, changes, false); + const list: any[] = []; + sortedForEach(changes, (value, key) => list.push(key, value.old, value.new)); + return expect(list); +} \ No newline at end of file