Skip to content

Commit

Permalink
fix(compiler): support multiple selectors in :host-context() (#40494)
Browse files Browse the repository at this point in the history
The previous commits refactored the `ShadowCss` emulator to support
desirable use-cases of `:host-context()`, but it dropped support
for passing a comma separated list of selectors to the `:host-context()` .

This commit rectifies that omission, despite the use-case not being
valid according to the ShadowDOM spec, to ensure backward compatibility
with the previous implementation.

PR Close #40494
  • Loading branch information
petebacondarwin authored and josephperrott committed Feb 16, 2021
1 parent ab8ea87 commit fb0ce21
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 17 deletions.
74 changes: 65 additions & 9 deletions packages/compiler/src/shadow_css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,26 +295,62 @@ export class ShadowCss {
private _convertColonHostContext(cssText: string): string {
return cssText.replace(_cssColonHostContextReGlobal, selectorText => {
// We have captured a selector that contains a `:host-context` rule.
// There may be more than one so `selectorText` could look like:
// `:host-context(.one):host-context(.two)`.

const contextSelectors: string[] = [];
let match: RegExpMatchArray|null;
// For backward compatibility `:host-context` may contain a comma separated list of selectors.
// Each context selector group will contain a list of host-context selectors that must match
// an ancestor of the host.
// (Normally `contextSelectorGroups` will only contain a single array of context selectors.)
const contextSelectorGroups: string[][] = [[]];

// There may be more than `:host-context` in this selector so `selectorText` could look like:
// `:host-context(.one):host-context(.two)`.
// Execute `_cssColonHostContextRe` over and over until we have extracted all the
// `:host-context` selectors from this selector.
let match: RegExpMatchArray|null;
while (match = _cssColonHostContextRe.exec(selectorText)) {
// `match` = [':host-context(<selectors>)<rest>', <selectors>, <rest>]
const contextSelector = (match[1] ?? '').trim();
if (contextSelector !== '') {
contextSelectors.push(contextSelector);

// The `<selectors>` could actually be a comma separated list: `:host-context(.one, .two)`.
const newContextSelectors =
(match[1] ?? '').trim().split(',').map(m => m.trim()).filter(m => m !== '');

// We must duplicate the current selector group for each of these new selectors.
// For example if the current groups are:
// ```
// [
// ['a', 'b', 'c'],
// ['x', 'y', 'z'],
// ]
// ```
// And we have a new set of comma separated selectors: `:host-context(m,n)` then the new
// groups are:
// ```
// [
// ['a', 'b', 'c', 'm'],
// ['x', 'y', 'z', 'm'],
// ['a', 'b', 'c', 'n'],
// ['x', 'y', 'z', 'n'],
// ]
// ```
const contextSelectorGroupsLength = contextSelectorGroups.length;
repeatGroups(contextSelectorGroups, newContextSelectors.length);
for (let i = 0; i < newContextSelectors.length; i++) {
for (let j = 0; j < contextSelectorGroupsLength; j++) {
contextSelectorGroups[j + (i * contextSelectorGroupsLength)].push(
newContextSelectors[i]);
}
}

// Update the `selectorText` and see repeat to see if there are more `:host-context`s.
selectorText = match[2];
}

// The context selectors now must be combined with each other to capture all the possible
// selectors that `:host-context` can match.
return combineHostContextSelectors(contextSelectors, selectorText);
// selectors that `:host-context` can match. See `combineHostContextSelectors()` for more
// info about how this is done.
return contextSelectorGroups
.map(contextSelectors => combineHostContextSelectors(contextSelectors, selectorText))
.join(', ');
});
}

Expand Down Expand Up @@ -714,3 +750,23 @@ function combineHostContextSelectors(contextSelectors: string[], otherSelectors:
`${s}${hostMarker}${otherSelectors}, ${s} ${hostMarker}${otherSelectors}`)
.join(',');
}

/**
* Mutate the given `groups` array so that there are `multiples` clones of the original array
* stored.
*
* For example `repeatGroups([a, b], 3)` will result in `[a, b, a, b, a, b]` - but importantly the
* newly added groups will be clones of the original.
*
* @param groups An array of groups of strings that will be repeated. This array is mutated
* in-place.
* @param multiples The number of times the current groups should appear.
*/
export function repeatGroups<T>(groups: string[][], multiples: number): void {
const length = groups.length;
for (let i = 1; i < multiples; i++) {
for (let j = 0; j < length; j++) {
groups[j + (i * length)] = groups[j].slice(0);
}
}
}
57 changes: 49 additions & 8 deletions packages/compiler/test/shadow_css_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {CssRule, processRules, ShadowCss} from '@angular/compiler/src/shadow_css';
import {CssRule, processRules, repeatGroups, ShadowCss} from '@angular/compiler/src/shadow_css';
import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util';

{
Expand Down Expand Up @@ -249,14 +249,27 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util';
]);
});

// This test is checking backward compatibility.
// It is not clear what the behaviour should be for a `:host-context` with no selectors.
// Arguably it is actually an error that should be reported.
// It is not clear what the behavior should be for a `:host-context` with no selectors.
// This test is checking that the result is backward compatible with previous behavior.
// Arguably it should actually be an error that should be reported.
it('should handle :host-context with no ancestor selectors', () => {
expect(s('.outer :host-context .inner {}', 'contenta', 'a-host'))
.toEqual('.outer [a-host] .inner[contenta] {}');
expect(s('.outer :host-context() .inner {}', 'contenta', 'a-host'))
.toEqual('.outer [a-host] .inner[contenta] {}');
expect(s(':host-context .inner {}', 'contenta', 'a-host'))
.toEqual('[a-host] .inner[contenta] {}');
expect(s(':host-context() .inner {}', 'contenta', 'a-host'))
.toEqual('[a-host] .inner[contenta] {}');
});

// More than one selector such as this is not valid as part of the :host-context spec.
// This test is checking that the result is backward compatible with previous behavior.
// Arguably it should actually be an error that should be reported.
it('should handle selectors', () => {
expect(s(':host-context(.one,.two) .inner {}', 'contenta', 'a-host'))
.toEqual(
'.one[a-host] .inner[contenta], ' +
'.one [a-host] .inner[contenta], ' +
'.two[a-host] .inner[contenta], ' +
'.two [a-host] .inner[contenta] ' +
'{}');
});
});

Expand Down Expand Up @@ -482,4 +495,32 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util';
});
});
});

describe('repeatGroups()', () => {
it('should do nothing if `multiples` is 0', () => {
const groups = [['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']];
repeatGroups(groups, 0);
expect(groups).toEqual([['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']]);
});

it('should do nothing if `multiples` is 1', () => {
const groups = [['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']];
repeatGroups(groups, 1);
expect(groups).toEqual([['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']]);
});

it('should add clones of the original groups if `multiples` is greater than 1', () => {
const group1 = ['a1', 'b1', 'c1'];
const group2 = ['a2', 'b2', 'c2'];
const groups = [group1, group2];
repeatGroups(groups, 3);
expect(groups).toEqual([group1, group2, group1, group2, group1, group2]);
expect(groups[0]).toBe(group1);
expect(groups[1]).toBe(group2);
expect(groups[2]).not.toBe(group1);
expect(groups[3]).not.toBe(group2);
expect(groups[4]).not.toBe(group1);
expect(groups[5]).not.toBe(group2);
});
});
}

0 comments on commit fb0ce21

Please sign in to comment.