diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 58793060a11a..f023b1d7406b 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1563,6 +1563,7 @@ export function reflectComponentType(component: Type): ComponentMirror export abstract class Renderer2 { abstract addClass(el: any, name: string): void; abstract appendChild(parent: any, newChild: any): void; + abstract applyStyles?(styleRoot: StyleRoot): void; abstract createComment(value: string): any; abstract createElement(name: string, namespace?: string | null): any; abstract createText(value: string): any; @@ -1579,11 +1580,13 @@ export abstract class Renderer2 { abstract removeChild(parent: any, oldChild: any, isHostElement?: boolean, requireSynchronousElementRemoval?: boolean): void; abstract removeClass(el: any, name: string): void; abstract removeStyle(el: any, style: string, flags?: RendererStyleFlags2): void; + abstract removeStyles?(styleRoot: StyleRoot): void; abstract selectRootElement(selectorOrNode: string | any, preserveContent?: boolean): any; abstract setAttribute(el: any, name: string, value: string, namespace?: string | null): void; abstract setProperty(el: any, name: string, value: any): void; abstract setStyle(el: any, style: string, value: any, flags?: RendererStyleFlags2): void; abstract setValue(node: any, value: string): void; + shadowRoot?: ShadowRoot; } // @public @@ -1829,6 +1832,9 @@ export interface StreamingResourceOptions extends BaseResourceOptions; } +// @public +export type StyleRoot = Document | ShadowRoot; + // @public export class TemplateRef { createEmbeddedView(context: C, injector?: Injector): EmbeddedViewRef; diff --git a/packages/animations/browser/src/render/renderer.ts b/packages/animations/browser/src/render/renderer.ts index dac743323eb3..799b93ef475b 100644 --- a/packages/animations/browser/src/render/renderer.ts +++ b/packages/animations/browser/src/render/renderer.ts @@ -27,17 +27,30 @@ export class BaseAnimationRenderer implements Renderer2 { // See https://github.com/microsoft/rushstack/issues/4390 readonly ɵtype: AnimationRendererType.Regular = AnimationRendererType.Regular; + applyStyles: Renderer2['applyStyles']; + removeStyles: Renderer2['removeStyles']; + constructor( protected namespaceId: string, public delegate: Renderer2, public engine: AnimationEngine, private _onDestroy?: () => void, - ) {} + ) { + // Conditionally define styling functionality based on the delegate, so we don't + // always implement `applyStyles` / `removeStyles` but then no-op if the delegate + // doesn't support it. + this.applyStyles = delegate.applyStyles?.bind(delegate); + this.removeStyles = delegate.removeStyles?.bind(delegate); + } get data() { return this.delegate.data; } + get shadowRoot() { + return this.delegate.shadowRoot; + } + destroyNode(node: any): void { this.delegate.destroyNode?.(node); } diff --git a/packages/core/src/animation/queue.ts b/packages/core/src/animation/queue.ts index 86b27ec6db9a..cff1736ac5ca 100644 --- a/packages/core/src/animation/queue.ts +++ b/packages/core/src/animation/queue.ts @@ -55,6 +55,7 @@ export function addToAnimationQueue( // is re-attached before the animation queue runs. animationData?.detachedLeaveAnimationFns?.push(animationFns); } + console.log(`Scheduling: ${Boolean(animationQueue.scheduler)}`); // DEBUG animationQueue.scheduler && animationQueue.scheduler(injector); } @@ -72,8 +73,10 @@ export function scheduleAnimationQueue(injector: Injector) { const animationQueue = injector.get(ANIMATION_QUEUE); // We only want to schedule the animation queue if it hasn't already been scheduled. if (!animationQueue.isScheduled) { + console.log('afterNextRender: scheduled'); // DEBUG afterNextRender( () => { + console.log('afterNextRender: running'); // DEBUG animationQueue.isScheduled = false; for (let animateFn of animationQueue.queue) { animateFn(); diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 1e52f89feac6..7f806f7c533f 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -7,5 +7,5 @@ */ // Public API for render -export {Renderer2, RendererFactory2, ListenerOptions} from './render/api'; +export {Renderer2, RendererFactory2, ListenerOptions, StyleRoot} from './render/api'; export {RendererStyleFlags2, RendererType2} from './render/api_flags'; diff --git a/packages/core/src/render/api.ts b/packages/core/src/render/api.ts index 86d51167eab1..a8fd918db3a0 100644 --- a/packages/core/src/render/api.ts +++ b/packages/core/src/render/api.ts @@ -13,6 +13,35 @@ import {getComponentLViewByIndex} from '../render3/util/view_utils'; import {RendererStyleFlags2, RendererType2} from './api_flags'; +/** + * A location where CSS stylesheets may be added. + * + * @publicApi + */ +export type StyleRoot = Document | ShadowRoot; + +/** + * Asserts that the given node is a {@link StyleRoot}. Useful for converting the return value of + * {@link Node.prototype.getRootNode} into a {@link StyleRoot}. If the root is a detached node, + * this returns `undefined` as there is no meaningful style root to attach styles to. + * + * @param root The root node of a DOM tree to convert to a {@link StyleRoot}. + * @returns The given root as a {@link StyleRoot} if it is a valid root for styles. Otherwise + * returns `undefined`. + */ +export function asStyleRoot(root: Node): StyleRoot | undefined { + // Need to feature-detect `ShadowRoot` for Node environments where DOM emulation does not + // support it. + if ( + root instanceof Document || + (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) + ) { + return root; + } + + return undefined; +} + /** * Creates and initializes a custom renderer that implements the `Renderer2` base class. * @@ -241,6 +270,23 @@ export abstract class Renderer2 { options?: ListenerOptions, ): () => void; + /** The component's internal shadow root if one is used. */ + shadowRoot?: ShadowRoot; + + /** + * Attach any required stylesheets for the associated component to the provided + * {@link StyleRoot}. This is called when the component is initially rendered + * xor attached to a view. + */ + abstract applyStyles?(styleRoot: StyleRoot): void; + + /** + * Detach any required stylesheets for the associated component from the + * provided {@link StyleRoot}. This is called when the component is destroyed + * xor detached from a view. + */ + abstract removeStyles?(styleRoot: StyleRoot): void; + /** * @internal * @nocollapse diff --git a/packages/core/src/render3/hmr.ts b/packages/core/src/render3/hmr.ts index 2d6dd1eee3e6..a6745952fe1a 100644 --- a/packages/core/src/render3/hmr.ts +++ b/packages/core/src/render3/hmr.ts @@ -32,6 +32,7 @@ import { RENDERER, T_HOST, TVIEW, + TViewType, } from './interfaces/view'; import {assertTNodeType} from './node_assert'; import {destroyLView, removeViewFromDOM} from './node_manipulation'; @@ -44,6 +45,7 @@ import { getInitialLViewFlagsFromDef, getOrCreateComponentTView, } from './view/construction'; +import {getStyleRoot, walkDescendants} from './util/view_traversal_utils'; /** Represents `import.meta` plus some information that's not in the built-in types. */ type ImportMetaExtended = ImportMeta & { @@ -241,6 +243,16 @@ function recreateLView( ngDevMode && assertNotEqual(newDef, oldDef, 'Expected different component definition'); const zone = lView[INJECTOR].get(NgZone, null); const recreate = () => { + // Remove old styles to make sure we drop internal references and track usage + // counts correctly. We might be an `Emulated` or `None` component inside a + // shadow root. + const oldRenderer = lView[RENDERER]; + if (oldRenderer.removeStyles) { + const oldStyleRoot = getStyleRoot(lView); + ngDevMode && assertDefined(oldStyleRoot, 'oldStyleRoot'); + oldRenderer.removeStyles(oldStyleRoot!); + } + // If we're recreating a component with shadow DOM encapsulation, it will have attached a // shadow root. The browser will throw if we attempt to attach another one and there's no way // to detach it. Our only option is to make a clone only of the root node, replace the node @@ -249,6 +261,17 @@ function recreateLView( oldDef.encapsulation === ViewEncapsulation.ShadowDom || oldDef.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom ) { + // Remove all descendants' styles because they will be destroyed with this + // shadow root. + for (const view of walkDescendants(lView)) { + if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue; + + const styleRoot = getStyleRoot(view); + ngDevMode && assertDefined(styleRoot, 'styleRoot'); + const renderer = view[RENDERER]; + renderer.removeStyles?.(styleRoot!); + } + const newHost = host.cloneNode(false) as HTMLElement; host.replaceWith(newHost); host = newHost; @@ -286,7 +309,18 @@ function recreateLView( // Patch a brand-new renderer onto the new view only after the old // view is destroyed so that the runtime doesn't try to reuse it. - newLView[RENDERER] = rendererFactory.createRenderer(host, newDef); + const newRenderer = rendererFactory.createRenderer(host, newDef); + newLView[RENDERER] = newRenderer; + + // Reapply styles potentially with a newly created shadow root. + if (newRenderer.applyStyles) { + const newStyleRoot = getStyleRoot(newLView); + ngDevMode && assertDefined(newStyleRoot, 'newStyleRoot'); + newRenderer.applyStyles(newStyleRoot!); + + // Don't need to reapply styles for descendent components because + // that will be handled as part of rendering the view. + } // Remove the nodes associated with the destroyed LView. This removes the // descendants, but not the host which we want to stay in place. diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index a85677095ca4..073255bcb9c4 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -277,6 +277,7 @@ function runLeaveAnimations( const renderer = lView[RENDERER]; const ngZone = lView[INJECTOR].get(NgZone); + console.log(`runLeaveAnimations scheduling: ${lView[ID]}\n${new Error().stack}`); // DEBUG allLeavingAnimations.add(lView[ID]); (getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve); @@ -389,6 +390,7 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa const tNode = getCurrentTNode()!; cancelLeavingNodes(tNode, lView); + console.log(`ɵɵanimateLeaveListener, scheduling: ${lView[ID]}`); // DEBUG allLeavingAnimations.add(lView[ID]); addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () => diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index 77339586aa11..461b3c1584df 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -592,7 +592,11 @@ function clearDetachAnimationList(lContainer: LContainer, index: number): void { const injector = viewToDetach[INJECTOR]; removeFromAnimationQueue(injector, animations); allLeavingAnimations.delete(viewToDetach[ID]); + console.log(`clearDetachAnimationList: Removing animation: ${viewToDetach[ID]}`); // DEBUG animations.detachedLeaveAnimationFns = undefined; + } else { + allLeavingAnimations.delete(viewToDetach[ID]); + console.log(`clearDetachAnimationList: Not removing animation: ${viewToDetach?.[ID]}`); // DEBUG } } diff --git a/packages/core/src/render3/instructions/render.ts b/packages/core/src/render3/instructions/render.ts index 9e054e7e9af3..78eb03fcc8d6 100644 --- a/packages/core/src/render3/instructions/render.ts +++ b/packages/core/src/render3/instructions/render.ts @@ -7,7 +7,7 @@ */ import {retrieveHydrationInfo} from '../../hydration/utils'; -import {assertEqual, assertNotReactive} from '../../util/assert'; +import {assertDefined, assertEqual, assertNotReactive} from '../../util/assert'; import {RenderFlags} from '../interfaces/definition'; import { CONTEXT, @@ -18,6 +18,7 @@ import { LView, LViewFlags, QUERIES, + RENDERER, TVIEW, TView, } from '../interfaces/view'; @@ -28,6 +29,7 @@ import {enterView, leaveView} from '../state'; import {getComponentLViewByIndex, isCreationMode} from '../util/view_utils'; import {executeTemplate} from './shared'; +import {getStyleRoot} from '../util/view_traversal_utils'; export function renderComponent(hostLView: LView, componentHostIdx: number) { ngDevMode && assertEqual(isCreationMode(hostLView), true, 'Should be run in creation mode'); @@ -45,6 +47,20 @@ export function renderComponent(hostLView: LView, componentHostIdx: number) { try { renderView(componentTView, componentView, componentView[CONTEXT]); + + // TODO: test + if (hostRNode?.isConnected) { + // Element is already attached to the DOM, apply its styles immediately. + ngDevMode && assertDefined(componentView[CONTEXT], 'component instance'); + const styleRoot = getStyleRoot(componentView); + ngDevMode && assertDefined(styleRoot, 'styleRoot'); + + const componentRenderer = componentView[RENDERER]; + componentRenderer.applyStyles?.(styleRoot!); + } else { + // Element is *not* attached to the DOM, can't know where its styles should go. + // Styles will be applied when attaching the view to a container. + } } finally { profiler(ProfilerEvent.ComponentEnd, componentView[CONTEXT] as any as {}); } diff --git a/packages/core/src/render3/interfaces/renderer.ts b/packages/core/src/render3/interfaces/renderer.ts index 74af7dd35d09..d99bc67f0ce3 100644 --- a/packages/core/src/render3/interfaces/renderer.ts +++ b/packages/core/src/render3/interfaces/renderer.ts @@ -7,7 +7,7 @@ */ import {RendererStyleFlags2, RendererType2} from '../../render/api_flags'; -import type {ListenerOptions} from '../../render/api'; +import type {ListenerOptions, StyleRoot} from '../../render/api'; import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../../util/security/trusted_type_defs'; import {RComment, RElement, RNode, RText} from './renderer_dom'; @@ -77,6 +77,15 @@ export interface Renderer { callback: (event: any) => boolean | void, options?: ListenerOptions, ): () => void; + + /** The component's internal shadow root if one is used. */ + shadowRoot?: ShadowRoot; + + /** Attach any required stylesheets to the DOM. */ + applyStyles?(styleRoot: StyleRoot): void; + + /** Detach any stylesheets from the DOM. */ + removeStyles?(styleRoot: StyleRoot): void; } export interface RendererFactory { diff --git a/packages/core/src/render3/interfaces/renderer_dom.ts b/packages/core/src/render3/interfaces/renderer_dom.ts index ac2498586a80..aea41b29460f 100644 --- a/packages/core/src/render3/interfaces/renderer_dom.ts +++ b/packages/core/src/render3/interfaces/renderer_dom.ts @@ -52,6 +52,23 @@ export interface RNode { * Used exclusively for building up DOM which are static (ie not View roots) */ appendChild(newChild: RNode): RNode; + + /** + * Get the root node of the DOM tree containing the node. + * + * This is typically either the `document` object (when the Node exists in light + * DOM) or a `ShadowRoot` (when the Node exists in shadow DOM). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode + */ + getRootNode(options?: GetRootNodeOptions): Node; + + /** + * Whether or not the Node is attached to a `Document` (directly or indirectly). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected + */ + readonly isConnected: boolean; } /** @@ -78,6 +95,7 @@ export interface RElement extends RNode { removeEventListener(type: string, listener?: EventListener, options?: boolean): void; remove(): void; setProperty?(name: string, value: any): void; + shadowRoot: ShadowRoot | null; } export interface RCssStyleDeclaration { diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 1e6f59afae1a..a8efff5314fa 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -250,6 +250,7 @@ export function detachViewFromDOM(tView: TView, lView: LView) { * - Destroy only called on movement to sibling or movement to parent (laterally or up) * * @param rootView The view to destroy + * TODO: Relevant? */ export function destroyViewTree(rootView: LView): void { // If the view has no children, we can clean it up and return early. @@ -388,7 +389,10 @@ function runLeaveAnimationsWithCallback( if (animations == null || animations.leave == undefined || !animations.leave.has(tNode.index)) return callback(false); - if (lView) allLeavingAnimations.add(lView[ID]); + if (lView) { + console.log(`runLeaveAnimationsWithCallback scheduling: ${lView[ID]}\n${new Error().stack}`); // DEBUG + allLeavingAnimations.add(lView[ID]); + } addToAnimationQueue( injector, @@ -411,7 +415,11 @@ function runLeaveAnimationsWithCallback( animations.running = Promise.allSettled(runningAnimations); runAfterLeaveAnimations(lView!, callback); } else { - if (lView) allLeavingAnimations.delete(lView[ID]); + if (lView) { + allLeavingAnimations.delete(lView[ID]); + } else { + console.log('runAfterLeaveAnimations: Not deleting.'); // DEBUG + } callback(false); } }, @@ -425,9 +433,12 @@ function runAfterLeaveAnimations(lView: LView, callback: Function) { runningAnimations.then(() => { lView[ANIMATIONS]!.running = undefined; allLeavingAnimations.delete(lView[ID]); + console.log(`runAfterLeaveAnimations: Deleting: ${lView[ID]}`); // DEBUG callback(true); }); return; + } else { + console.log(`runAfterLeaveAnimations: Not deleting: ${lView[ID]}`); // DEBUG } callback(false); } diff --git a/packages/core/src/render3/util/view_traversal_utils.ts b/packages/core/src/render3/util/view_traversal_utils.ts index a2eb9670c80f..d20949fd5161 100644 --- a/packages/core/src/render3/util/view_traversal_utils.ts +++ b/packages/core/src/render3/util/view_traversal_utils.ts @@ -6,12 +6,27 @@ * found in the LICENSE file at https://angular.dev/license */ +import {DOCUMENT} from '../../document'; +import {StyleRoot} from '../../render'; +import {asStyleRoot} from '../../render/api'; import {assertDefined} from '../../util/assert'; import {assertLView} from '../assert'; import {readPatchedLView} from '../context_discovery'; -import {LContainer} from '../interfaces/container'; -import {isLContainer, isLView, isRootView} from '../interfaces/type_checks'; -import {CHILD_HEAD, CONTEXT, LView, NEXT} from '../interfaces/view'; +import {CONTAINER_HEADER_OFFSET, LContainer} from '../interfaces/container'; +import {TElementNode, TNode} from '../interfaces/node'; +import {isComponentHost, isLContainer, isLView, isRootView} from '../interfaces/type_checks'; +import { + CHILD_HEAD, + DECLARATION_COMPONENT_VIEW, + CONTEXT, + HOST, + INJECTOR, + LView, + NEXT, + RENDERER, + T_HOST, + PARENT, +} from '../interfaces/view'; import {getLViewParent} from './view_utils'; @@ -65,3 +80,97 @@ function getNearestLContainer(viewOrContainer: LContainer | LView | null) { } return viewOrContainer as LContainer | null; } + +/** + * Generates all the {@link LView} and {@link LContainer} descendants of the given input. Also generates {@link LView} + * and {@link LContainer} instances which are projected into a descendant. + * + * There are no strict guarantees on the order of traversal. + * TODO: Duplicating results. + */ +export function* walkDescendants( + parent: LView | LContainer, +): Generator { + for (const child of walkChildren(parent)) { + yield child; + yield* walkDescendants(child); + } +} + +function* walkChildren(parent: LView | LContainer): Generator { + let child = isLContainer(parent) ? parent[CONTAINER_HEADER_OFFSET] : parent[CHILD_HEAD]; + while (child) { + yield child; + child = child[NEXT]; + } + + if (isLView(parent)) { + const host = parent[T_HOST]; + if (host && isComponentHost(host)) { + // `parent[T_HOST]` is the `TElementNode` in the parents's parent view, which + // owns the host element of `parent`. So we need to look up the grandparent + // to access it. + const grandparent = isLContainer(parent[PARENT]) ? parent[PARENT][PARENT]! : parent[PARENT]!; + yield* walkProjectedChildren(grandparent, host as TElementNode); + } + } +} + +function* walkProjectedChildren( + lView: LView, + componentHost: TElementNode, +): Generator { + if (!componentHost.projection) return; + + for (const projectedNodes of componentHost.projection) { + if (Array.isArray(projectedNodes)) { + // Projected `RNode` objects are just raw elements and don't contain any `LView` objects. + continue; + } + + for (const projectedNode of walkProjectedSiblings(projectedNodes)) { + const projected = lView[projectedNode.index]; + if (isLView(projected) || isLContainer(projected)) yield projected; + } + } +} + +function* walkProjectedSiblings(node: TNode | null): Generator { + while (node) { + yield node; + node = node.projectionNext; + } +} + +/** Combine multiple iterables into a single stream with the same ordering. */ +export function* concat(...iterables: Array>): Iterable { + for (const iterable of iterables) { + yield* iterable; + } +} + +/** Returns the {@link StyleRoot} where styles for the component should be applied. */ +export function getStyleRoot(lView: LView): StyleRoot | undefined { + // DOM emulation does not support shadow DOM and `Node.prototype.getRootNode`, so we + // need to feature detect and fallback even though it is already Baseline Widely + // Available. In theory, we could do this only on SSR, but Jest, Vitest, and other + // Node testing solutions lack DOM emulation as well. + if (!Node.prototype.getRootNode) { + // TODO: Can't use injector during destroy because it is destroyed before the + // component. Is it ok to depend on the `document` global? If not, might need to + // change the contract of `getStyleRoot` and inject `DOCUMENT` prior to + // destruction. + // const injector = lView[INJECTOR]; + // const doc = injector.get(DOCUMENT); + + return document; + } + + const renderer = lView[RENDERER]; + if (renderer?.shadowRoot) return renderer.shadowRoot; + + const hostRNode = lView[HOST]; + ngDevMode && assertDefined(hostRNode, 'hostRNode'); + + return asStyleRoot(hostRNode!.getRootNode()); +} diff --git a/packages/core/src/render3/view/container.ts b/packages/core/src/render3/view/container.ts index e8a9e70898f6..a3a4fcf9feb4 100644 --- a/packages/core/src/render3/view/container.ts +++ b/packages/core/src/render3/view/container.ts @@ -9,6 +9,7 @@ import {addToArray, removeFromArray} from '../../util/array_utils'; import {assertDefined, assertEqual} from '../../util/assert'; import {assertLContainer, assertLView} from '../assert'; +import {isComponentInstance} from '../context_discovery'; import { CONTAINER_HEADER_OFFSET, LContainer, @@ -18,11 +19,13 @@ import { } from '../interfaces/container'; import {TNode} from '../interfaces/node'; import {RComment, RElement} from '../interfaces/renderer_dom'; -import {isLView} from '../interfaces/type_checks'; +import {isLContainer, isLView} from '../interfaces/type_checks'; import { + CONTEXT, DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, FLAGS, + HOST, HYDRATION, LView, LViewFlags, @@ -33,6 +36,7 @@ import { T_HOST, TView, TVIEW, + TViewType, } from '../interfaces/view'; import { addViewToDOM, @@ -41,6 +45,7 @@ import { getBeforeNodeForView, removeViewFromDOM, } from '../node_manipulation'; +import {concat, getStyleRoot, walkDescendants} from '../util/view_traversal_utils'; import {updateAncestorTraversalFlagsOnAttach} from '../util/view_utils'; /** @@ -113,6 +118,20 @@ export function addLViewToLContainer( const parentRNode = renderer.parentNode(lContainer[NATIVE] as RElement | RComment); if (parentRNode !== null) { addViewToDOM(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode); + + if (parentRNode.isConnected) { + for (const view of concat([lView], walkDescendants(lView))) { + if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue; + + // Element is already attached to the DOM, apply its styles immediately. + const componentRenderer = view[RENDERER]; + if (componentRenderer.applyStyles && isComponentInstance(view[CONTEXT])) { + const styleRoot = getStyleRoot(view); + ngDevMode && assertDefined(styleRoot, 'styleRoot'); + componentRenderer.applyStyles(styleRoot!); + } + } + } } } @@ -161,9 +180,23 @@ export function detachView(lContainer: LContainer, removeIndex: number): LView | if (removeIndex > 0) { lContainer[indexInContainer - 1][NEXT] = viewToDetach[NEXT] as LView; } + const removedLView = removeFromArray(lContainer, CONTAINER_HEADER_OFFSET + removeIndex); removeViewFromDOM(viewToDetach[TVIEW], viewToDetach); + for (const view of concat([viewToDetach], walkDescendants(viewToDetach))) { + if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue; + + const hostRNode = view[HOST]; + const renderer = view[RENDERER]; + if (hostRNode && renderer?.removeStyles && isComponentInstance(view[CONTEXT])) { + // Component might already have been detached and removed from the DOM if it was manually destroyed + // while present in a `ViewContainerRef`. + const styleRoot = getStyleRoot(view); + if (styleRoot) renderer.removeStyles(styleRoot); + } + } + // notify query that a view has been removed const lQueries = removedLView[QUERIES]; if (lQueries !== null) { diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index 60fa9ce4b5ae..e6e3646fa17e 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -31,7 +31,9 @@ import { LView, LViewFlags, PARENT, + RENDERER, TVIEW, + TViewType, } from './interfaces/view'; import {destroyLView, detachMovedView, detachViewFromDOM} from './node_manipulation'; import { @@ -41,6 +43,7 @@ import { requiresRefreshOrTraversal, } from './util/view_utils'; import {detachView, trackMovedView} from './view/container'; +import {concat, getStyleRoot, walkDescendants} from './util/view_traversal_utils'; // Needed due to tsickle downleveling where multiple `implements` with classes creates // multiple @extends in Closure annotations, which is illegal. This workaround fixes @@ -107,9 +110,8 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRefInterfac } destroy(): void { - if (this._appRef) { - this._appRef.detachView(this); - } else if (this._attachedToViewContainer) { + let attached = true; + if (!this._appRef && this._attachedToViewContainer) { const parent = this._lView[PARENT]; if (isLContainer(parent)) { const viewRefs = parent[VIEW_REFS] as ViewRef[] | null; @@ -121,12 +123,33 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRefInterfac parent.indexOf(this._lView) - CONTAINER_HEADER_OFFSET, 'An attached view should be in the same position within its container as its ViewRef in the VIEW_REFS array.', ); + // Implicitly removes styles as part of detaching the view. detachView(parent, index); removeFromArray(viewRefs!, index); + attached = false; } } this._attachedToViewContainer = false; } + + if (attached) { + for (const view of concat([this._lView], walkDescendants(this._lView))) { + if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue; + + // Component might already be destroyed in cases like `NgModuleRef.prototype.destroy` + // being called before `ComponentRef.prototype.destroy`. + if (isDestroyed(view)) continue; + + const renderer = view[RENDERER]; + const styleRoot = getStyleRoot(view); + + // Component might already be detached prior to destroy and have had its styles removed previously. + if (styleRoot) renderer.removeStyles?.(styleRoot!); + } + } + + if (this._appRef) this._appRef.detachView(this); + destroyLView(this._lView[TVIEW], this._lView); } diff --git a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts index bfac1c27fbff..1e328ad04ce3 100644 --- a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts +++ b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts @@ -33,6 +33,8 @@ import {By} from '@angular/platform-browser'; import {tickAnimationFrames} from '../../animation_utils/tick_animation_frames'; import {isNode} from '@angular/private/testing'; import {Subscription} from 'rxjs'; +import {walkDescendants} from '../../../src/render3/util/view_traversal_utils'; +import {readPatchedLView} from '../../../src/render3/context_discovery'; describe('signal inputs', () => { beforeEach(() => @@ -373,7 +375,8 @@ describe('signal inputs', () => { expect(childCmp.nativeElement.className).not.toContain('fade-in'); })); - it('should support content projection', fakeAsync(() => { + // TODO: Walk projected content. + fit('should support content projection', fakeAsync(() => { const animateStyles = ` .fade-in { animation: fade 1ms forwards; @@ -453,6 +456,8 @@ describe('signal inputs', () => { const fixture = TestBed.createComponent(TestComponent); const button = fixture.nativeElement.querySelector('button'); + Array.from(walkDescendants(readPatchedLView(fixture.componentInstance)!)); + fixture.detectChanges(); expect(fixture.nativeElement.querySelector('app-content')).toBeNull(); expect(button).not.toBeNull(); @@ -601,6 +606,7 @@ describe('signal inputs', () => { public ngOnDestroy(): void { this.closedSubscription?.unsubscribe(); this.closedSubscription = null; + // TODO: Valid? this.componentRef?.destroy(); // Explicitly destroy the dynamically created component } diff --git a/packages/core/test/acceptance/hmr_spec.ts b/packages/core/test/acceptance/hmr_spec.ts index 8c7d8cac7c88..a95c4f20aff8 100644 --- a/packages/core/test/acceptance/hmr_spec.ts +++ b/packages/core/test/acceptance/hmr_spec.ts @@ -23,6 +23,7 @@ import { provideZoneChangeDetection, QueryList, SimpleChanges, + StyleRoot, Type, ViewChild, ViewChildren, @@ -39,14 +40,20 @@ import {clearTranslations, loadTranslations} from '@angular/localize'; import {computeMsgId} from '@angular/compiler'; import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser'; import {ComponentType} from '../../src/render3'; +import {ɵSharedStylesHost as SharedStylesHost} from '@angular/platform-browser'; import {isNode} from '@angular/private/testing'; +import {allLeavingAnimations} from '../../src/animation/longest_animation'; +import {ANIMATION_QUEUE} from '../../src/animation/queue'; describe('hot module replacement', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [provideZoneChangeDetection()], }); + + for (const child of document.body.childNodes) child.remove(); }); + it('should recreate a single usage of a basic component', () => { let instance!: ChildCmp; const initialMetadata: Component = { @@ -285,11 +292,11 @@ describe('hot module replacement', () => { const getShadowRoot = () => fixture.nativeElement.querySelector('child-cmp').shadowRoot; markNodesAsCreatedInitially(getShadowRoot()); - expectHTML(getShadowRoot(), `Hello 0`); + expectHTML(getShadowRoot(), `Hello 0`); instance.state = 1; fixture.detectChanges(); - expectHTML(getShadowRoot(), `Hello 1`); + expectHTML(getShadowRoot(), `Hello 1`); replaceMetadata(ChildCmp, { ...initialMetadata, @@ -307,6 +314,108 @@ describe('hot module replacement', () => { getShadowRoot(), `Changed 1!`, ); + + fixture.destroy(); + assertNoLeakedStyles(TestBed.inject(SharedStylesHost)); + }); + + it("should replace a component child's styles within shadow DOM encapsulation", async () => { + // Domino doesn't support shadow DOM. + if (isNode) { + return; + } + + const animationsQueue = TestBed.inject(ANIMATION_QUEUE); + console.log(`animationQueue: ${animationsQueue.queue.size}`); // DEBUG + + await waitForAnimations(); + console.log(`animations: ${Array.from(allLeavingAnimations.values()).join(', ')}`); // DEBUG + console.log(`animationQueue: ${animationsQueue.queue.size}`); // DEBUG + + const initialMetadata: Component = { + selector: 'child-cmp', + template: 'Hello World!', + styles: `strong {color: red;}`, + encapsulation: ViewEncapsulation.None, + }; + + @Component(initialMetadata) + class ChildCmp {} + + @Component({ + template: '', + encapsulation: ViewEncapsulation.ShadowDom, + imports: [ChildCmp], + }) + class RootCmp {} + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + const getShadowRoot = () => fixture.nativeElement.shadowRoot; + + expect(getShadowRoot().innerHTML).toContain(``); + + replaceMetadata(ChildCmp, { + ...initialMetadata, + styles: `strong {background: pink;}`, + }); + fixture.detectChanges(); + + expect(getShadowRoot().innerHTML).toContain(''); + expect(getShadowRoot().innerHTML).not.toContain(``); + + fixture.destroy(); + assertNoLeakedStyles(TestBed.inject(SharedStylesHost)); + }); + + it('should support components within nested shadow DOM', () => { + // Domino doesn't support shadow DOM. + if (isNode) { + return; + } + + @Component({ + selector: 'child-cmp', + template: 'Hello {{state}}', + styles: ` + strong { + color: red; + } + `, + encapsulation: ViewEncapsulation.ShadowDom, + }) + class ChildCmp {} + + const initialMetadata: Component = { + template: '', + styles: `:host {color: red;}`, + encapsulation: ViewEncapsulation.ShadowDom, + imports: [ChildCmp], + }; + + @Component(initialMetadata) + class RootCmp {} + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + // Can't use `fixture.nativeElement` because the host element is recreated + // during HMR and `fixture` is not updated. + const getShadowRoot = () => document.querySelector('[ng-version]')!.shadowRoot!; + + expect(getShadowRoot().innerHTML).toContain(``); + + replaceMetadata(RootCmp, { + ...initialMetadata, + styles: `:host {background: pink;}`, + }); + fixture.detectChanges(); + + expect(getShadowRoot().innerHTML).toContain(''); + expect(getShadowRoot().innerHTML).not.toContain(``); + + fixture.destroy(); + assertNoLeakedStyles(TestBed.inject(SharedStylesHost)); }); it('should continue binding inputs to a component that is replaced', () => { @@ -2173,4 +2282,33 @@ describe('hot module replacement', () => { } } } + + function assertNoLeakedStyles(sharedStylesHost: SharedStylesHost): void { + const ssh = sharedStylesHost as unknown as Omit & { + inline: Map; + external: Map; + }; + + const totalStyles = ssh.inline.size + ssh.external.size; + if (totalStyles > 0) { + throw new Error( + `Expected \`SharedStylesHost\` to have no leaked styles, found: ${totalStyles}.`, + ); + } + } + async function waitForAnimations() { + const timer = timeout(() => allLeavingAnimations.size > 0, 5_000); + while (timer()) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + + function timeout(predicate: () => boolean, timeout: number): () => boolean { + const start = Date.now(); + return () => { + if (!predicate()) return false; + if (Date.now() - start > timeout) return false; + return true; + }; + } }); diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index ce27b86aa842..17b526e041eb 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -1412,6 +1412,7 @@ describe('ViewContainerRef', () => { {provide: ErrorHandler, useValue: TestBed.inject(ErrorHandler)}, {provide: RendererFactory2, useValue: TestBed.inject(RendererFactory2)}, {provide: ANIMATION_QUEUE, useValue: TestBed.inject(ANIMATION_QUEUE)}, + {provide: DOCUMENT, useValue: TestBed.inject(DOCUMENT)}, ], }) class MyAppModule {} diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index d2a5a9fa5ee0..2e50cd149a24 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -324,6 +324,7 @@ "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "allowPreviousPlayerStylesMerge", @@ -341,6 +342,7 @@ "applyView", "areAnimationSupported", "arrRemove", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -373,6 +375,7 @@ "collectNativeNodesInLContainer", "computeStaticStyling", "computeStyle", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -543,6 +546,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getTNode", "getTNodeFromLView", "getTView", @@ -611,6 +615,7 @@ "isBoundToModule", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCssClassMatching", "isCurrentTNodeParent", @@ -641,6 +646,7 @@ "isRefreshingViews", "isRootView", "isSchedulerTick", + "isShadowRoot", "isSkipHydrationRootTNode", "isSubscribable", "isSubscriber", @@ -757,7 +763,6 @@ "rememberChangeHistoryAndInvokeOnChangesHook", "remove", "removeClass", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeNodesAfterAnimationDone", @@ -854,6 +859,8 @@ "viewAttachedToChangeDetector", "viewShouldHaveReactiveConsumer", "visitDslNode", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "writeDirectClass", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index e60602de7b05..fa95074436f0 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -254,6 +254,7 @@ "addToEndOfViewTree", "addViewToDOM", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "angularZoneInstanceIdProperty", @@ -267,6 +268,7 @@ "applyView", "areAnimationSupported", "arrRemove", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -289,6 +291,7 @@ "collectNativeNodes", "collectNativeNodesInLContainer", "computeStaticStyling", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -442,6 +445,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getTNode", "getTNodeFromLView", "getTView", @@ -497,6 +501,7 @@ "isBoundToModule", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCurrentTNodeParent", "isDestroyed", @@ -520,6 +525,7 @@ "isRefreshingViews", "isRootView", "isSchedulerTick", + "isShadowRoot", "isSubscribable", "isSubscriber", "isSubscription", @@ -604,7 +610,6 @@ "relativePath", "rememberChangeHistoryAndInvokeOnChangesHook", "remove", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeViewFromDOM", @@ -685,6 +690,8 @@ "viewAttachedToChangeDetector", "viewAttachedToContainer", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "writeDirectClass", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 9faceefd656d..80795380f5ad 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -39,6 +39,7 @@ "_document", "_keyMap", "addServerStyles", + "allLegacyShadowRoots", "baseElement", "bootstrapApplication", "createLinkElement", @@ -48,10 +49,10 @@ "getBaseElementHref", "getDOM", "initDomAdapter", + "isShadowRoot", "isTemplateNode", "parseCookieValue", "relativePath", - "removeElements", "setRootDomAdapter", "shimContentAttribute", "shimHostAttribute", @@ -312,6 +313,7 @@ "applyView", "areAnimationSupported", "arrRemove", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -333,6 +335,7 @@ "collectNativeNodes", "collectNativeNodesInLContainer", "computeStaticStyling", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -493,6 +496,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getTDeferBlockDetails", "getTNode", "getTNodeFromLView", @@ -547,6 +551,7 @@ "isBoundToModule", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCssClassMatching", "isCurrentTNodeParent", @@ -741,6 +746,8 @@ "updateMicroTaskStatus", "viewAttachedToChangeDetector", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "writeDirectClass", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index cd8df286076c..aa2772ced2ca 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -353,6 +353,7 @@ "addValidators", "addViewToDOM", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "angularZoneInstanceIdProperty", @@ -370,6 +371,7 @@ "argsArgArrayOrObject", "arrRemove", "arrayInsert2", + "asStyleRoot", "assertAllValuesPresent", "assertControlPresent", "assertNotDestroyed", @@ -414,6 +416,7 @@ "composeValidators", "computeStaticStyling", "computed", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -614,6 +617,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getSuperType", "getSymbolIterator", "getTNode", @@ -692,6 +696,7 @@ "isClassProvider", "isComponentDef", "isComponentHost", + "isComponentInstance", "isComponentResourceResolutionQueueEmpty", "isContentQueryHost", "isCssClassMatching", @@ -735,6 +740,7 @@ "isRefreshingViews", "isRootView", "isSchedulerTick", + "isShadowRoot", "isSkipHydrationRootTNode", "isStylingMatch", "isStylingValuePresent", @@ -875,7 +881,6 @@ "relativePath", "rememberChangeHistoryAndInvokeOnChangesHook", "remove", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeListItem", @@ -1009,6 +1014,8 @@ "viewAttachedToChangeDetector", "viewAttachedToContainer", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "wrapInStaticStylingKey", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 4f17ff2f041d..4952dc092bb4 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -357,6 +357,7 @@ "addValidators", "addViewToDOM", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "angularZoneInstanceIdProperty", @@ -374,6 +375,7 @@ "argsArgArrayOrObject", "arrRemove", "arrayInsert2", + "asStyleRoot", "assertAllValuesPresent", "assertControlPresent", "assertNotDestroyed", @@ -416,6 +418,7 @@ "composeValidators", "computeStaticStyling", "computed", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -615,6 +618,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getSuperType", "getSymbolIterator", "getTNode", @@ -692,6 +696,7 @@ "isClassProvider", "isComponentDef", "isComponentHost", + "isComponentInstance", "isComponentResourceResolutionQueueEmpty", "isContentQueryHost", "isCssClassMatching", @@ -734,6 +739,7 @@ "isRefreshingViews", "isRootView", "isSchedulerTick", + "isShadowRoot", "isSignal", "isSkipHydrationRootTNode", "isStylingMatch", @@ -873,7 +879,6 @@ "relativePath", "rememberChangeHistoryAndInvokeOnChangesHook", "remove", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeListItem", @@ -1008,6 +1013,8 @@ "viewAttachedToChangeDetector", "viewAttachedToContainer", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "walkUpViews", "wasLastNodeCreated", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index ba6f62617343..bafd25489ee5 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -293,6 +293,7 @@ "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "angularZoneInstanceIdProperty", @@ -307,6 +308,7 @@ "applyView", "areAnimationSupported", "arrRemove", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -337,6 +339,7 @@ "collectNativeNodes", "collectNativeNodesInLContainer", "computeStaticStyling", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -512,6 +515,7 @@ "getSelectedIndex", "getSerializedContainerViews", "getSimpleChangesStore", + "getStyleRoot", "getSymbolIterator", "getTNode", "getTNodeFromLView", @@ -567,6 +571,7 @@ "isBoundToModule", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCurrentTNodeParent", "isDestroyed", @@ -599,6 +604,7 @@ "isRootView", "isScheduler", "isSchedulerTick", + "isShadowRoot", "isSsrContentsIntegrity", "isSubscribable", "isSubscriber", @@ -699,7 +705,6 @@ "remove", "removeDehydratedView", "removeDehydratedViews", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeStaleDehydratedBranch", @@ -790,6 +795,8 @@ "verifySsrContentsIntegrity", "viewAttachedToChangeDetector", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "whenStableWithTimeout", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index c24887e2ce25..7e6df99410bf 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -409,6 +409,7 @@ "advanceActivatedRoute", "afterNextNavigation", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "allowSanitizationBypassAndThrow", @@ -425,6 +426,7 @@ "argsArgArrayOrObject", "arrRemove", "arrayEquals", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -460,6 +462,7 @@ "computeStaticStyling", "computed", "concat", + "concat2", "concatAll", "concatMap", "concatStringsWithSpace", @@ -725,6 +728,7 @@ "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", + "getStyleRoot", "getSymbolIterator", "getTNode", "getTNodeFromLView", @@ -808,6 +812,7 @@ "isCommandWithOutlets", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCreationMode", "isCssClassMatching", @@ -853,6 +858,7 @@ "isRootView", "isScheduler", "isSchedulerTick", + "isShadowRoot", "isSkipHydrationRootTNode", "isSubscribable", "isSubscriber", @@ -1002,7 +1008,6 @@ "relativePath", "rememberChangeHistoryAndInvokeOnChangesHook", "remove", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeViewFromDOM", @@ -1155,6 +1160,8 @@ "viewAttachedToChangeDetector", "viewAttachedToContainer", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "wrapIntoObservable", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 47f4af11c79a..8851ffe1cee3 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -234,6 +234,7 @@ "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", + "allLegacyShadowRoots", "allocExpando", "allocLFrame", "angularZoneInstanceIdProperty", @@ -247,6 +248,7 @@ "applyView", "areAnimationSupported", "arrRemove", + "asStyleRoot", "assertNotDestroyed", "assertTypeDefined", "attachPatchData", @@ -268,6 +270,7 @@ "collectNativeNodes", "collectNativeNodesInLContainer", "computeStaticStyling", + "concat", "concatStringsWithSpace", "config", "configureViewWithDirective", @@ -408,6 +411,7 @@ "getRuntimeErrorCode", "getSelectedIndex", "getSimpleChangesStore", + "getStyleRoot", "getTNode", "getTNodeFromLView", "getTView", @@ -452,6 +456,7 @@ "isBoundToModule", "isComponentDef", "isComponentHost", + "isComponentInstance", "isContentQueryHost", "isCurrentTNodeParent", "isDestroyed", @@ -475,6 +480,7 @@ "isRefreshingViews", "isRootView", "isSchedulerTick", + "isShadowRoot", "isSubscribable", "isSubscriber", "isSubscription", @@ -552,7 +558,6 @@ "relativePath", "rememberChangeHistoryAndInvokeOnChangesHook", "remove", - "removeElements", "removeFromArray", "removeLViewOnDestroy", "removeViewFromDOM", @@ -625,6 +630,8 @@ "updateMicroTaskStatus", "viewAttachedToChangeDetector", "viewShouldHaveReactiveConsumer", + "walkChildren", + "walkDescendants", "walkProviderTree", "wasLastNodeCreated", "writeDirectClass", diff --git a/packages/core/test/linker/projection_integration_spec.ts b/packages/core/test/linker/projection_integration_spec.ts index 60c0104fe90e..931bf03d03ea 100644 --- a/packages/core/test/linker/projection_integration_spec.ts +++ b/packages/core/test/linker/projection_integration_spec.ts @@ -496,8 +496,8 @@ describe('projection', () => { const main = TestBed.createComponent(MainComp); const childNodes = main.nativeElement.childNodes; - expect(childNodes[0]).toHaveText('div {color: red}SIMPLE1(A)'); - expect(childNodes[1]).toHaveText('div {color: blue}SIMPLE2(B)'); + expect(childNodes[0]).toHaveText('SIMPLE1(A)div {color: red}'); + expect(childNodes[1]).toHaveText('SIMPLE2(B)div {color: blue}'); main.destroy(); }); } diff --git a/packages/core/test/render/api_spec.ts b/packages/core/test/render/api_spec.ts new file mode 100644 index 000000000000..b39f4943d37e --- /dev/null +++ b/packages/core/test/render/api_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import {isNode} from '@angular/private/testing'; +import {asStyleRoot} from '../../src/render/api'; + +describe('api', () => { + describe('asStyleRoot', () => { + it('returns an input `Document`', () => { + expect(asStyleRoot(document)).toBe(document); + }); + + it('returns an input `ShadowRoot`', () => { + // Shadow DOM isn't implemented in DOM emulation. + if (isNode) { + expect().nothing(); + return; + } + + const shadowRoot = document.createElement('div').attachShadow({mode: 'open'}); + + expect(asStyleRoot(shadowRoot)).toBe(shadowRoot); + }); + + it('returns `undefined` for a detached node', () => { + expect(asStyleRoot(document.createElement('div'))).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/render3/util/view_traversal_util_spec.ts b/packages/core/test/render3/util/view_traversal_util_spec.ts new file mode 100644 index 000000000000..943fd0b5f041 --- /dev/null +++ b/packages/core/test/render3/util/view_traversal_util_spec.ts @@ -0,0 +1,204 @@ +import {NgComponentOutlet} from '@angular/common'; +import {By} from '@angular/platform-browser'; +import {Component} from '../../../src/core'; +import {readPatchedLView} from '../../../src/render3/context_discovery'; +import {walkDescendants} from '../../../src/render3/util/view_traversal_utils'; +import {HEADER_OFFSET} from '../../../src/render3/interfaces/view'; +import {TestBed} from '../../../testing'; + +describe('view_traversal_util', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + describe('walkDescendants', () => { + it('yields descendants of the given `LView`', () => { + @Component({ + selector: 'app-grandchild', + template: ``, + }) + class Grandchild {} + + @Component({ + selector: 'app-child1', + template: ` `, + imports: [Grandchild], + }) + class Child1 {} + + @Component({ + selector: 'app-child2', + template: ``, + }) + class Child2 {} + + @Component({ + selector: 'app-root', + template: ` + + + `, + imports: [Child1, Child2], + }) + class Root {} + + const fixture = TestBed.createComponent(Root); + fixture.detectChanges(); + const root = fixture.debugElement; + + const rootLView = readPatchedLView(fixture.componentInstance)!; + const descendants = Array.from(walkDescendants(rootLView)); + + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-child1')).componentInstance)!, + ); + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-child2')).componentInstance)!, + ); + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-grandchild')).componentInstance)!, + ); + expect(duplicates(descendants)).toHaveSize(0); + }); + + it('yields nothing for components with no descendants', () => { + @Component({ + selector: 'app-root', + template: '', + }) + class Root {} + + const fixture = TestBed.createComponent(Root); + const rootLView = readPatchedLView(fixture.componentInstance)!; + const rootComponentLView = rootLView[HEADER_OFFSET]; + const descendants = Array.from(walkDescendants(rootComponentLView)); + + expect(descendants).toEqual([]); + expect(duplicates(descendants)).toHaveSize(0); + }); + + it('yields dynamic descendants', () => { + @Component({ + selector: 'app-child1', + template: '', + }) + class Child1 {} + + @Component({ + selector: 'app-child2', + template: '', + }) + class Child2 {} + + @Component({ + selector: 'app-root', + template: ` + @if (true) { + + } + + + `, + imports: [Child1, NgComponentOutlet], + }) + class Root { + protected readonly Child2 = Child2; + } + + const fixture = TestBed.createComponent(Root); + fixture.detectChanges(); + + const root = fixture.debugElement; + const rootLView = readPatchedLView(fixture.componentInstance)!; + const descendants = Array.from(walkDescendants(rootLView)); + + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-child1')).componentInstance)!, + ); + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-child2')).componentInstance)!, + ); + expect(duplicates(descendants)).toHaveSize(0); + }); + + it('yields projected descendants', () => { + @Component({ + selector: 'app-projected-child', + template: ``, + }) + class ProjectedChild {} + + @Component({ + selector: 'app-projected', + template: ``, + imports: [ProjectedChild], + }) + class Projected {} + + @Component({ + selector: 'app-child', + template: ``, + }) + class Child {} + + @Component({ + selector: 'app-root', + template: ` + + + + `, + imports: [Child, Projected], + }) + class Root {} + + const fixture = TestBed.createComponent(Root); + fixture.detectChanges(); + + const root = fixture.debugElement; + + { + const rootLView = readPatchedLView(fixture.componentInstance)!; + const descendants = Array.from(walkDescendants(rootLView)); + + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-projected')).componentInstance)!, + ); + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-projected-child')).componentInstance)!, + ); + expect(duplicates(descendants)).toHaveSize(0); + } + + // Check for views projected from ancestors outside the root. + { + const childLView = readPatchedLView( + fixture.debugElement.query(By.css('app-child'))!.componentInstance, + )!; + const descendants = Array.from(walkDescendants(childLView)); + + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-projected')).componentInstance)!, + ); + expect(descendants).toContain( + readPatchedLView(root.query(By.css('app-projected-child')).componentInstance)!, + ); + expect(duplicates(descendants)).toHaveSize(0); + } + }); + }); +}); + +function duplicates(items: Iterable): Set { + const seen = new Set(); + const duplicates = new Set(); + for (const item of items) { + if (seen.has(item)) { + duplicates.add(item); + } else { + seen.add(item); + } + } + + return duplicates; +} diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 87b51bc0e260..5a21769496de 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -9,7 +9,6 @@ import {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common'; import { APP_ID, - CSP_NONCE, Inject, Injectable, InjectionToken, @@ -26,12 +25,13 @@ import { ɵTracingSnapshot as TracingSnapshot, Optional, ɵallLeavingAnimations as allLeavingAnimations, + StyleRoot, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; import {EventManager} from './events/event_manager'; -import {createLinkElement, SharedStylesHost} from './shared_styles_host'; +import {SharedStylesHost} from './shared_styles_host'; export const NAMESPACE_URIS: {[ns: string]: string} = { 'svg': 'http://www.w3.org/2000/svg', @@ -69,6 +69,9 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken( }, ); +/** Set of all legacy (`ViewEncapsulation.ShadowDom` and not `ViewEncapsulation.IsolatedShadowDom`) shadow roots. */ +const allLegacyShadowRoots = new Set(); + export function shimContentAttribute(componentShortId: string): string { return CONTENT_ATTR.replace(COMPONENT_REGEX, componentShortId); } @@ -142,7 +145,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { @Inject(REMOVE_STYLES_ON_COMPONENT_DESTROY) private removeStylesOnCompDestroy: boolean, @Inject(DOCUMENT) private readonly doc: Document, readonly ngZone: NgZone, - @Inject(CSP_NONCE) private readonly nonce: string | null = null, @Inject(TracingService) @Optional() private readonly tracingService: TracingService | null = null, @@ -165,16 +167,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { type = {...type, encapsulation: ViewEncapsulation.Emulated}; } - const renderer = this.getOrCreateRenderer(element, type); - // Renderers have different logic due to different encapsulation behaviours. - // Ex: for emulated, an attribute is added to the element. - if (renderer instanceof EmulatedEncapsulationDomRenderer2) { - renderer.applyToHost(element); - } else if (renderer instanceof NoneEncapsulationDomRenderer) { - renderer.applyStyles(); - } - - return renderer; + return this.getOrCreateRenderer(element, type); } private getOrCreateRenderer(element: any, type: RendererType2): Renderer2 { @@ -193,6 +186,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { case ViewEncapsulation.Emulated: renderer = new EmulatedEncapsulationDomRenderer2( eventManager, + element, sharedStylesHost, type, this.appId, @@ -203,16 +197,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ); break; case ViewEncapsulation.ShadowDom: - return new ShadowDomRenderer( - eventManager, - element, - type, - doc, - ngZone, - this.nonce, - tracingService, - sharedStylesHost, - ); case ViewEncapsulation.ExperimentalIsolatedShadowDom: return new ShadowDomRenderer( eventManager, @@ -220,8 +204,8 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { type, doc, ngZone, - this.nonce, tracingService, + sharedStylesHost, ); default: @@ -267,7 +251,7 @@ class DefaultDomRenderer2 implements Renderer2 { constructor( private readonly eventManager: EventManager, - private readonly doc: Document, + protected readonly doc: Document, protected readonly ngZone: NgZone, private readonly tracingService: TracingService | null, ) {} @@ -491,62 +475,59 @@ function isTemplateNode(node: any): node is HTMLTemplateElement { } class ShadowDomRenderer extends DefaultDomRenderer2 { - private shadowRoot: any; + shadowRoot: ShadowRoot; constructor( eventManager: EventManager, private hostEl: any, - component: RendererType2, + private readonly component: RendererType2, doc: Document, ngZone: NgZone, - nonce: string | null, tracingService: TracingService | null, - private sharedStylesHost?: SharedStylesHost, + private sharedStylesHost: SharedStylesHost, ) { super(eventManager, doc, ngZone, tracingService); this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'}); - - // SharedStylesHost is used to add styles to the shadow root by ShadowDom. - // This is optional as it is not used by ExperimentalIsolatedShadowDom. - if (this.sharedStylesHost) { - this.sharedStylesHost.addHost(this.shadowRoot); + if (this.component.encapsulation === ViewEncapsulation.ShadowDom) { + allLegacyShadowRoots.add(this.shadowRoot); } - let styles = component.styles; + } + + // HACK: HMR modifies the component definition mid-execution, meaning an + // `applyStyles` followed by a `removeStyles` can have different style content + // and confuse `SharedStylesHost`. Therefore we keep a references of the + // previously applied styles to ensure that the same thing is removed later even + // if the component definition has changed. + // + // Ideally, HMR should be able to `removeStyles` with the *old* component + // definition and then `applyStyles` with the *new* component definition, but + // this seems to be a more complicated refactor to make right now. + // + // TODO: Do this for other encapsulation modes too? + private appliedStyles: [inline: string[], external?: string[]] | undefined = undefined; + + applyStyles(styleRoot: StyleRoot): void { + let styles = this.component.styles; if (ngDevMode) { // We only do this in development, as for production users should not add CSS sourcemaps to components. - const baseHref = getDOM().getBaseHref(doc) ?? ''; + const baseHref = getDOM().getBaseHref(this.doc) ?? ''; styles = addBaseHrefToCssSourceMap(baseHref, styles); } - styles = shimStylesContent(component.id, styles); - - for (const style of styles) { - const styleEl = document.createElement('style'); - - if (nonce) { - styleEl.setAttribute('nonce', nonce); - } + styles = shimStylesContent(this.component.id, styles); + const externalStyles = this.component.getExternalStyles?.(); + this.sharedStylesHost.addStyles(styleRoot, styles, externalStyles); + this.appliedStyles = [styles, externalStyles]; + } - styleEl.textContent = style; - this.shadowRoot.appendChild(styleEl); + removeStyles(styleRoot: StyleRoot): void { + if (typeof ngDevMode !== 'undefined' && ngDevMode && !this.appliedStyles) { + throw new Error(`Expected styles to already be applied.`); } - // Apply any external component styles to the shadow root for the component's element. - // The ShadowDOM renderer uses an alternative execution path for component styles that - // does not use the SharedStylesHost that other encapsulation modes leverage. Much like - // the manual addition of embedded styles directly above, any external stylesheets - // must be manually added here to ensure ShadowDOM components are correctly styled. - // TODO: Consider reworking the DOM Renderers to consolidate style handling. - const styleUrls = component.getExternalStyles?.(); - if (styleUrls) { - for (const styleUrl of styleUrls) { - const linkEl = createLinkElement(styleUrl, doc); - if (nonce) { - linkEl.setAttribute('nonce', nonce); - } - this.shadowRoot.appendChild(linkEl); - } - } + const [styles, externalStyes] = this.appliedStyles!; + this.sharedStylesHost.removeStyles(styleRoot, styles, externalStyes); + this.appliedStyles = undefined; } private nodeOrShadowRoot(node: any): any { @@ -570,9 +551,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { } override destroy() { - if (this.sharedStylesHost) { - this.sharedStylesHost.removeHost(this.shadowRoot); - } + allLegacyShadowRoots.delete(this.shadowRoot); } } @@ -602,16 +581,52 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 { this.styleUrls = component.getExternalStyles?.(compId); } - applyStyles(): void { - this.sharedStylesHost.addStyles(this.styles, this.styleUrls); + applyStyles(styleRoot: StyleRoot): void { + // TODO: Apply styles on legacy shadow root creation afterwards? + + const isShadowRootObj = isShadowRoot(styleRoot); + const isIsolatedShadowRoot = + isShadowRootObj && !allLegacyShadowRoots.has(styleRoot as ShadowRoot); + if (isIsolatedShadowRoot) { + // For isolated shadow roots, we limit to the one usage. + this.sharedStylesHost.addStyles(styleRoot, this.styles, this.styleUrls); + } else { + // Legacy behavior: Always add to the document and all legacy shadow roots. + // `ViewEncapsulation.ShadowDom` incorrectly puts styles on more shadow + // roots than necessary and we must retain that broken behavior for + // backwards compatibility. + this.sharedStylesHost.addStyles(this.doc, this.styles, this.styleUrls); + for (const styleRoot of allLegacyShadowRoots) { + this.sharedStylesHost.addStyles(styleRoot, this.styles, this.styleUrls); + } + } } - override destroy(): void { + removeStyles(styleRoot: StyleRoot): void { if (!this.removeStylesOnCompDestroy) { return; } + + // TODO: Problematic? if (allLeavingAnimations.size === 0) { - this.sharedStylesHost.removeStyles(this.styles, this.styleUrls); + const isShadowRootObj = isShadowRoot(styleRoot); + const isIsolatedShadowRoot = + isShadowRootObj && !allLegacyShadowRoots.has(styleRoot as ShadowRoot); + if (isIsolatedShadowRoot) { + // For isolated shadow roots, we limit to the one usage. + this.sharedStylesHost.removeStyles(styleRoot, this.styles, this.styleUrls); + } else { + // Legacy behavior: Always remove a usage from the document and all legacy + // shadow roots. + // `ViewEncapsulation.ShadowDom` incorrectly puts styles on more shadow + // roots than necessary and does not remove them from any until it is + // unused in *all*. We must retain that broken behavior for backwards + // compatibility. + this.sharedStylesHost.removeStyles(this.doc, this.styles, this.styleUrls); + for (const styleRoot of allLegacyShadowRoots) { + this.sharedStylesHost.removeStyles(styleRoot, this.styles, this.styleUrls); + } + } } } } @@ -622,6 +637,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { constructor( eventManager: EventManager, + private readonly hostEl: Element, sharedStylesHost: SharedStylesHost, component: RendererType2, appId: string, @@ -645,9 +661,9 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { this.hostAttr = shimHostAttribute(compId); } - applyToHost(element: any): void { - this.applyStyles(); - this.setAttribute(element, this.hostAttr, ''); + override applyStyles(styleRoot: StyleRoot): void { + super.applyStyles(styleRoot); + this.setAttribute(this.hostEl, this.hostAttr, ''); } override createElement(parent: any, name: string): Element { @@ -656,3 +672,10 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { return el; } } + +/** + * In some runtimes (e.g. server-side with Domino or node unit tests), ShadowRoot may not be defined. + */ +function isShadowRoot(obj: unknown): obj is ShadowRoot { + return typeof ShadowRoot !== 'undefined' && obj instanceof ShadowRoot; +} diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index 19101a833f20..589ae93ea455 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -15,6 +15,7 @@ import { OnDestroy, Optional, PLATFORM_ID, + StyleRoot, } from '@angular/core'; /** The style elements attribute name used to set value of `APP_ID` token. */ @@ -25,20 +26,10 @@ const APP_ID_ATTRIBUTE_NAME = 'ng-app-id'; * that contain a given style. */ interface UsageRecord { - elements: T[]; + element: T; usage: number; } -/** - * Removes all provided elements from the document. - * @param elements An array of HTML Elements. - */ -function removeElements(elements: Iterable): void { - for (const element of elements) { - element.remove(); - } -} - /** * Creates a `style` element with the provided inline style content. * @param style A string of the inline style content. @@ -56,15 +47,19 @@ function createStyleElement(style: string, doc: Document): HTMLStyleElement { * Searches a DOM document's head element for style elements with a matching application * identifier attribute (`ng-app-id`) to the provide identifier and adds usage records for each. * @param doc An HTML DOM document instance. - * @param appId A string containing an Angular application identifer. - * @param inline A Map object for tracking inline (defined via `styles` in component decorator) style usage. - * @param external A Map object for tracking external (defined via `styleUrls` in component decorator) style usage. + * @param appId A string containing an Angular application identifier. + * @param inline Maps individual {@link StyleRoot} objects to a map of CSS style + * source to its `'); - }); + afterEach(() => { + ssh.ngOnDestroy(); + }); - it('should add new styles to hosts', () => { - ssh.addHost(someHost); - ssh.addStyles(['a {};']); - expect(someHost.innerHTML).toEqual(''); - }); + const shadowRootHosts: Element[] = []; + function createShadowRoot(hostTag: string = 'div'): ShadowRoot { + const host = doc.createElement(hostTag); + const shadowRoot = host.attachShadow({mode: 'open'}); + doc.body.append(host); + shadowRootHosts.push(host); + return shadowRoot; + } + + afterEach(() => { + for (const host of shadowRootHosts) host.remove(); + shadowRootHosts.splice(0, shadowRootHosts.length); + }); - it('should add styles only once to hosts', () => { - ssh.addStyles(['a {};']); - ssh.addHost(someHost); - ssh.addStyles(['a {};']); - expect(someHost.innerHTML).toEqual(''); + describe('inline', () => { + it('should add styles', () => { + ssh.addStyles(doc, ['a {};']); + expect(doc.head.innerHTML).toContain(''); }); - it('should use the document head as default host', () => { - ssh.addStyles(['a {};', 'b {};']); - expect(doc.head).toHaveText('a {};b {};'); + it('should add styles only once', () => { + ssh.addStyles(doc, ['a {};']); + ssh.addStyles(doc, ['a {};']); + expect(doc.head.innerHTML.indexOf('')).toBe( + doc.head.innerHTML.lastIndexOf(''), + ); }); it('should remove style nodes on destroy', () => { - ssh.addStyles(['a {};']); - ssh.addHost(someHost); - expect(someHost.innerHTML).toEqual(''); + ssh.addStyles(doc, ['a {};']); + expect(doc.head.innerHTML).toContain(''); ssh.ngOnDestroy(); - expect(someHost.innerHTML).toEqual(''); + expect(doc.head.innerHTML).not.toContain(''); }); it(`should add 'nonce' attribute when a nonce value is provided`, () => { ssh = new SharedStylesHost(doc, 'app-id', '{% nonce %}'); - ssh.addStyles(['a {};']); - ssh.addHost(someHost); - expect(someHost.innerHTML).toEqual(''); + ssh.addStyles(doc, ['a {};']); + expect(doc.head.innerHTML).toContain(''); }); - it(`should reuse SSR generated element`, () => { + it(`should reuse SSR generated elements`, () => { const style = doc.createElement('style'); style.setAttribute('ng-app-id', 'app-id'); style.textContent = 'a {};'; doc.head.appendChild(style); ssh = new SharedStylesHost(doc, 'app-id'); - ssh.addStyles(['a {};']); + ssh.addStyles(doc, ['a {};']); expect(doc.head.innerHTML).toContain(''); expect(doc.head.innerHTML).not.toContain('ng-app-id'); }); }); describe('external', () => { - it('should add existing styles to new hosts', () => { - ssh.addStyles([], ['component-1.css']); - ssh.addHost(someHost); - expect(someHost.innerHTML).toEqual(''); - }); - - it('should add new styles to hosts', () => { - ssh.addHost(someHost); - ssh.addStyles([], ['component-1.css']); - expect(someHost.innerHTML).toEqual(''); + it('should add styles', () => { + ssh.addStyles(doc, [], ['component-1.css']); + expect(doc.head.innerHTML).toContain(''); }); - it('should add styles only once to hosts', () => { - ssh.addStyles([], ['component-1.css']); - ssh.addHost(someHost); - ssh.addStyles([], ['component-1.css']); - expect(someHost.innerHTML).toEqual(''); + it('should add styles only once', () => { + ssh.addStyles(doc, [], ['component-1.css']); + ssh.addStyles(doc, [], ['component-1.css']); + expect(doc.head.innerHTML.indexOf('')).toBe( + doc.head.innerHTML.lastIndexOf(''), + ); }); - it('should use the document head as default host', () => { - ssh.addStyles([], ['component-1.css', 'component-2.css']); + it('should remove styles on destroy', () => { + ssh.addStyles(doc, [], ['component-1.css']); expect(doc.head.innerHTML).toContain(''); - expect(doc.head.innerHTML).toContain(''); - }); - - it('should remove style nodes on destroy', () => { - ssh.addStyles([], ['component-1.css']); - ssh.addHost(someHost); - expect(someHost.innerHTML).toEqual(''); ssh.ngOnDestroy(); - expect(someHost.innerHTML).toEqual(''); + expect(doc.head.innerHTML).not.toContain(''); }); it(`should add 'nonce' attribute when a nonce value is provided`, () => { ssh = new SharedStylesHost(doc, 'app-id', '{% nonce %}'); - ssh.addStyles([], ['component-1.css']); - ssh.addHost(someHost); - expect(someHost.innerHTML).toEqual( + ssh.addStyles(doc, [], ['component-1.css']); + expect(doc.head.innerHTML).toContain( '', ); }); it('should keep search parameters of urls', () => { - ssh.addHost(someHost); - ssh.addStyles([], ['component-1.css?ngcomp=ng-app-c123456789']); - expect(someHost.innerHTML).toEqual( + ssh.addStyles(doc, [], ['component-1.css?ngcomp=ng-app-c123456789']); + expect(doc.head.innerHTML).toContain( '', ); }); - it(`should reuse SSR generated element`, () => { + it(`should reuse SSR generated elements`, () => { const link = doc.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('href', 'component-1.css'); @@ -135,11 +131,73 @@ describe('SharedStylesHost', () => { doc.head.appendChild(link); ssh = new SharedStylesHost(doc, 'app-id'); - ssh.addStyles([], ['component-1.css']); + ssh.addStyles(doc, [], ['component-1.css']); expect(doc.head.innerHTML).toContain( '', ); expect(doc.head.innerHTML).not.toContain('ng-app-id'); }); }); + + it('should track styles in shadow roots', () => { + const shadowRoot = createShadowRoot(); + ssh.addStyles(shadowRoot, ['a {};']); + expect(shadowRoot.innerHTML).toContain(''); + expect(doc.head.innerHTML).not.toContain(''); + + ssh.removeStyles(shadowRoot, ['a {};']); + expect(shadowRoot.innerHTML).not.toContain(''); + }); + + it('does not duplicate styles', () => { + const shadowRoot = createShadowRoot(); + ssh.addStyles(shadowRoot, ['a {};']); + ssh.addStyles(shadowRoot, ['a {};']); + + expect(shadowRoot.innerHTML).toContain(''); + expect(shadowRoot.innerHTML.indexOf('')).toBe( + shadowRoot.innerHTML.lastIndexOf(''), + ); + }); + + it('should track usage per-shadow root', () => { + const shadowRoot1 = createShadowRoot(); + ssh.addStyles(shadowRoot1, ['a {};']); + expect(shadowRoot1.innerHTML).toContain(''); + + // Multiple usages in a different shadow root. + const shadowRoot2 = createShadowRoot(); + ssh.addStyles(shadowRoot2, ['b {};']); + ssh.addStyles(shadowRoot2, ['b {};']); + ssh.addStyles(shadowRoot2, ['b {};']); + expect(shadowRoot2.innerHTML).toContain(''); + + // Should not have mixed the styles. + expect(shadowRoot1.innerHTML).not.toContain(''); + expect(shadowRoot2.innerHTML).not.toContain(''); + + // `shadowRoot2` has three usages, all need to be removed to have an effect. + ssh.removeStyles(shadowRoot2, ['b {};']); + expect(shadowRoot2.innerHTML).toContain(''); + ssh.removeStyles(shadowRoot2, ['b {};']); + ssh.removeStyles(shadowRoot2, ['b {};']); + expect(shadowRoot2.innerHTML).not.toContain(''); + + // `shadowRoot1` should not be affected at all. + expect(shadowRoot1.innerHTML).toContain(''); + }); + + it('throws when removing a style which was never added', () => { + expect(() => ssh.removeStyles(doc, ['a {};'])).toThrowError( + /remove styles which are not in the provided `StyleRoot`/, + ); + + // Add a different style to track something for this root. + ssh.addStyles(doc, ['a {};']); + + // Removing the wrong style from a known root. + expect(() => ssh.removeStyles(doc, ['b {};'])).toThrowError( + /remove styles which are not in the provided `StyleRoot`/, + ); + }); });