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
This commit is contained in:
parent
ef95da6d3b
commit
76698d38f7
|
@ -30,7 +30,7 @@ export function parse(value: string): string[] {
|
||||||
// we use a string array here instead of a string map
|
// we use a string array here instead of a string map
|
||||||
// because a string-map is not guaranteed to retain the
|
// because a string-map is not guaranteed to retain the
|
||||||
// order of the entries whereas a string array can be
|
// 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[] = [];
|
const styles: string[] = [];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
|
@ -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<string, boolean|null> {
|
||||||
|
const changes = new Map<string, boolean|null>();
|
||||||
|
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<string, boolean|null>, 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<string, boolean|null>, 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;
|
||||||
|
}
|
|
@ -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++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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<string, {old: string | null, new: string | null}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, {old: string | null, new: string | null}>();
|
||||||
|
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) + '\'.');
|
||||||
|
}
|
|
@ -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) + '\'.');
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* 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
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
import {CharCode} from '../../util/char_code';
|
||||||
import {AttributeMarker, TAttributes} from '../interfaces/node';
|
import {AttributeMarker, TAttributes} from '../interfaces/node';
|
||||||
import {CssSelector} from '../interfaces/projection';
|
import {CssSelector} from '../interfaces/projection';
|
||||||
import {ProceduralRenderer3, RElement, Renderer3, isProceduralRenderer} from '../interfaces/renderer';
|
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
|
// 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
|
// 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.
|
// charCodeAt doesn't allocate memory to return a substring.
|
||||||
return name.charCodeAt(0) === 64; // @
|
return name.charCodeAt(0) === CharCode.AT_SIGN;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
import {unwrapSafeValue} from '../../sanitization/bypass';
|
import {unwrapSafeValue} from '../../sanitization/bypass';
|
||||||
|
import {CharCode} from '../../util/char_code';
|
||||||
import {PropertyAliases, TNodeFlags} from '../interfaces/node';
|
import {PropertyAliases, TNodeFlags} from '../interfaces/node';
|
||||||
import {LStylingData, StylingMapArray, StylingMapArrayIndex, TStylingContext, TStylingContextIndex, TStylingContextPropConfigFlags, TStylingNode} from '../interfaces/styling';
|
import {LStylingData, StylingMapArray, StylingMapArrayIndex, TStylingContext, TStylingContextIndex, TStylingContextPropConfigFlags, TStylingNode} from '../interfaces/styling';
|
||||||
import {NO_CHANGE} from '../tokens';
|
import {NO_CHANGE} from '../tokens';
|
||||||
|
@ -431,7 +432,7 @@ export function splitOnWhitespace(text: string): string[]|null {
|
||||||
let foundChar = false;
|
let foundChar = false;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const char = text.charCodeAt(i);
|
const char = text.charCodeAt(i);
|
||||||
if (char <= 32 /*' '*/) {
|
if (char <= CharCode.SPACE) {
|
||||||
if (foundChar) {
|
if (foundChar) {
|
||||||
if (array === null) array = [];
|
if (array === null) array = [];
|
||||||
array.push(text.substring(start, i));
|
array.push(text.substring(start, i));
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ declare global {
|
||||||
rendererSetClassName: number;
|
rendererSetClassName: number;
|
||||||
rendererAddClass: number;
|
rendererAddClass: number;
|
||||||
rendererRemoveClass: number;
|
rendererRemoveClass: number;
|
||||||
|
rendererCssText: number;
|
||||||
rendererSetStyle: number;
|
rendererSetStyle: number;
|
||||||
rendererRemoveStyle: number;
|
rendererRemoveStyle: number;
|
||||||
rendererDestroy: number;
|
rendererDestroy: number;
|
||||||
|
@ -82,6 +83,7 @@ export function ngDevModeResetPerfCounters(): NgDevModePerfCounters {
|
||||||
rendererSetClassName: 0,
|
rendererSetClassName: 0,
|
||||||
rendererAddClass: 0,
|
rendererAddClass: 0,
|
||||||
rendererRemoveClass: 0,
|
rendererRemoveClass: 0,
|
||||||
|
rendererCssText: 0,
|
||||||
rendererSetStyle: 0,
|
rendererSetStyle: 0,
|
||||||
rendererRemoveStyle: 0,
|
rendererRemoveStyle: 0,
|
||||||
rendererDestroy: 0,
|
rendererDestroy: 0,
|
||||||
|
|
|
@ -9,6 +9,7 @@ ts_library(
|
||||||
),
|
),
|
||||||
deps = [
|
deps = [
|
||||||
"//packages/core",
|
"//packages/core",
|
||||||
|
"//packages/core/src/util",
|
||||||
"@npm//@types/jasmine",
|
"@npm//@types/jasmine",
|
||||||
"@npm//@types/node",
|
"@npm//@types/node",
|
||||||
],
|
],
|
||||||
|
@ -215,3 +216,16 @@ ng_benchmark(
|
||||||
name = "duplicate_map_based_style_and_class_bindings",
|
name = "duplicate_map_based_style_and_class_bindings",
|
||||||
bundle = ":duplicate_map_based_style_and_class_bindings_lib",
|
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",
|
||||||
|
)
|
||||||
|
|
|
@ -65,7 +65,7 @@ export function createBenchmark(benchmarkName: string): Benchmark {
|
||||||
if (!runAgain) {
|
if (!runAgain) {
|
||||||
// tslint:disable-next-line:no-console
|
// tslint:disable-next-line:no-console
|
||||||
console.log(
|
console.log(
|
||||||
` ${formatTime(iterationTime_ms)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`);
|
` ${formatTime(profile.bestTime)} (count: ${profile.sampleCount}, iterations: ${profile.iterationCount})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
iterationCounter = profile.iterationCount;
|
iterationCounter = profile.iterationCount;
|
||||||
|
|
|
@ -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<string, boolean|null>();
|
||||||
|
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, any>): any {
|
||||||
|
a.clear();
|
||||||
|
}
|
|
@ -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<string, boolean|null>();
|
||||||
|
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<V>(map: Map<string, V>, 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));
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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<string, any>();
|
||||||
|
parseKeyValue(text, changes, false);
|
||||||
|
const list: any[] = [];
|
||||||
|
sortedForEach(changes, (value, key) => list.push(key, value.old, value.new));
|
||||||
|
return expect(list);
|
||||||
|
}
|
Loading…
Reference in New Issue