pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/angular/angular/pull/68691/files

ef="https://github.githubassets.com/assets/primer-primitives-7f694b60439d06c0.css" /> fix(core): enforce native slots for isolated shadow dom by ryan-bendel · Pull Request #68691 · angular/angular · GitHub
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/compiler-cli/error_code.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export enum ErrorCode {
INLINE_TYPE_CTOR_REQUIRED = 8901,
INTERPOLATED_SIGNAL_NOT_INVOKED = 8109,
INVALID_BANANA_IN_BOX = 8101,
ISOLATED_SHADOW_DOM_INVALID_CONTENT_PROJECTION = 2029,
LET_USED_BEFORE_DEFINITION = 8016,
LOCAL_COMPILATION_UNRESOLVED_CONST = 11001,
LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION = 11003,
Expand Down
2 changes: 2 additions & 0 deletions goldens/public-api/core/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
INVALID_INJECTION_TOKEN = -204,
// (undocumented)
INVALID_ISOLATED_SHADOW_DOM_CONTENT_PROJECTION = 318,
// (undocumented)
INVALID_MULTI_PROVIDER = -209,
// (undocumented)
INVALID_RESOURCE_CREATION_IN_PARAMS = 992,
Expand Down
2 changes: 2 additions & 0 deletions goldens/public-api/platform-browser/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
BROWSER_MODULE_ALREADY_LOADED = 5100,
// (undocumented)
EXPERIMENTAL_ISOLATED_SHADOW_DOM_UNSUPPORTED_ON_SERVER = 5106,
// (undocumented)
HYDRATION_CONFLICTING_FEATURES = 5001,
// (undocumented)
NO_PLUGIN_FOR_EVENT = 5101,
Expand Down
2 changes: 1 addition & 1 deletion integration/defer/size.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"dist/main.js": 12709,
"dist/main.js": 14244,
"dist/polyfills.js": 35677,
"dist/defer.component-[hash].js": 345
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
SelectorlessMatcher,
SelectorMatcher,
TmplAstDeferredBlock,
TmplAstContent,
TmplAstNode,
TypeCheckId,
ViewEncapsulation,
} from '@angular/compiler';
Expand Down Expand Up @@ -915,6 +917,24 @@ export class ComponentDecoratorHandler implements DecoratorHandler<
}
}

// Check for ng-content in ExperimentalIsolatedShadowDom components
if (encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom) {
const contentNode = findContentNode(template.nodes);
if (contentNode !== null) {
if (diagnostics === undefined) {
diagnostics = [];
}
diagnostics.push(
makeDiagnostic(
ErrorCode.ISOLATED_SHADOW_DOM_INVALID_CONTENT_PROJECTION,
component.get('template') ?? node.name,
`ng-content projection is not supported with ViewEncapsulation.ExperimentalIsolatedShadowDom. ` +
`Use native <slot> elements instead. Content will remain in the light DOM and be projected via slots.`,
),
);
}
}

// If inline styles were preprocessed use those
let inlineStyles: string[] | null = null;
if (this.preanalyzeStylesCache.has(node)) {
Expand Down Expand Up @@ -2666,3 +2686,23 @@ function validateStandaloneImports(
function isDefaultImport(node: ts.ImportDeclaration): boolean {
return node.importClause !== undefined && node.importClause.namedBindings === undefined;
}

/**
* Recursively searches through template nodes to find a Content node (ng-content).
* Returns the first Content node found, or null if none exist.
*/
function findContentNode(nodes: TmplAstNode[]): TmplAstContent | null {
for (const node of nodes) {
if (node instanceof TmplAstContent) {
return node;
}
const children = 'children' in node ? (node as {children: TmplAstNode[]}).children : null;
if (children !== null && children.length > 0) {
const found = findContentNode(children);
if (found !== null) {
return found;
}
}
}
return null;
}
7 changes: 7 additions & 0 deletions packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ export enum ErrorCode {
*/
SERVICE_CONSTRUCTOR_DI = 2028,

/**
* Raised when a component with `ViewEncapsulation.ExperimentalIsolatedShadowDom` uses
* `<ng-content>`. ExperimentalIsolatedShadowDom components must use native `<slot>` elements
* instead.
*/
ISOLATED_SHADOW_DOM_INVALID_CONTENT_PROJECTION = 2029,

SYMBOL_NOT_EXPORTED = 3001,
/**
* Raised when a relationship between directives and/or pipes would cause a cyclic import to be
Expand Down
42 changes: 42 additions & 0 deletions packages/compiler-cli/test/ngtsc/ngtsc_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10685,6 +10685,48 @@ runInEachFileSystem((os: string) => {
});
});

describe('ExperimentalIsolatedShadowDom content projection diagnostics', () => {
it('should emit a diagnostic when using ng-content', () => {
env.write(
'test.ts',
`
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '<ng-content></ng-content>',
selector: 'test-cmp',
encapsulation: ViewEncapsulation.ExperimentalIsolatedShadowDom
})
export class TestCmp {}
`,
);
const diags = env.driveDiagnostics();

expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(
'ng-content projection is not supported with ViewEncapsulation.ExperimentalIsolatedShadowDom. ' +
'Use native <slot> elements instead. Content will remain in the light DOM and be projected via slots.',
);
});

it('should allow native slot elements', () => {
env.write(
'test.ts',
`
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '<slot name="content"></slot>',
selector: 'test-cmp',
encapsulation: ViewEncapsulation.ExperimentalIsolatedShadowDom
})
export class TestCmp {}
`,
);
const diags = env.driveDiagnostics();

expect(diags.length).toBe(0);
});
});

describe('i18n errors', () => {
it('reports a diagnostics on nested i18n sections', () => {
env.write(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const enum RuntimeErrorCode {
NO_BINDING_TARGET = 315,
INVALID_BINDING_TARGET = 316,
INVALID_SET_INPUT_CALL = 317,
INVALID_ISOLATED_SHADOW_DOM_CONTENT_PROJECTION = 318,

// Bootstrap Errors
MULTIPLE_PLATFORMS = 400,
Expand Down
10 changes: 4 additions & 6 deletions packages/core/src/render3/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,10 @@ function recreateLView(
const recreate = () => {
// 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
// with the clone and use it for the newly-created LView.
if (
oldDef.encapsulation === ViewEncapsulation.ShadowDom ||
oldDef.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom
) {
// to detach it. Our only option is to clone the host, replace the node and use the clone for
// the newly-created LView. ExperimentalIsolatedShadowDom reuses the existing host and shadow
// root because its native slot content remains in the host's light DOM.
if (oldDef.encapsulation === ViewEncapsulation.ShadowDom) {
const newHost = host.cloneNode(false) as HTMLElement;
host.replaceWith(newHost);
host = newHost;
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/render3/instructions/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
* 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 {RuntimeError, RuntimeErrorCode} from '../../errors';
import {findMatchingDehydratedView} from '../../hydration/views';
import {isDetachedByI18n} from '../../i18n/utils';
import {ViewEncapsulation} from '../../metadata/view';
import {newArray} from '../../util/array_utils';
import {assertLContainer, assertTNode} from '../assert';
import {getComponentDef} from '../def_getters';
import {ComponentTemplate} from '../interfaces/definition';
import {TAttributes, TElementNode, TNode, TNodeType} from '../interfaces/node';
import {ProjectionSlots} from '../interfaces/projection';
import {
CONTEXT,
DECLARATION_COMPONENT_VIEW,
HEADER_OFFSET,
HYDRATION,
Expand Down Expand Up @@ -94,7 +98,20 @@ export function matchingProjectionSlotIndex(
* @codeGenApi
*/
export function ɵɵprojectionDef(projectionSlots?: (string | (string | number)[][])[]): void {
const componentNode = getLView()[DECLARATION_COMPONENT_VIEW][T_HOST] as TElementNode;
const componentLView = getLView();
const componentNode = componentLView[DECLARATION_COMPONENT_VIEW][T_HOST] as TElementNode;

const componentDef = getComponentDef(
(componentLView[CONTEXT] as {constructor: unknown}).constructor,
);
if (componentDef?.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_ISOLATED_SHADOW_DOM_CONTENT_PROJECTION,
ngDevMode &&
`ng-content projection is not supported with ViewEncapsulation.ExperimentalIsolatedShadowDom. ` +
`Use native <slot> elements instead. Content will remain in the light DOM and be projected via slots.`,
);
}

if (!componentNode.projection) {
// If no explicit projection slots are defined, fall back to a single
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/render3/interfaces/shared_styles_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ export interface SharedStylesHost {
* Adds embedded styles to the DOM via HTML `style` elements.
* @param styles An array of style content strings.
* @param urls An array of URLs to be added as link tags.
* @param hostNode An optional node that should receive the provided styles in addition to
* the registered style hosts.
*/
addStyles(styles: string[], urls?: string[]): void;
addStyles(styles: string[], urls?: string[], hostNode?: Node): void;

/**
* Removes embedded styles from the DOM that were added as HTML `style` elements.
* @param styles An array of style content strings.
* @param urls An array of URLs to be removed as link tags.
* @param hostNode An optional node that should stop receiving the provided styles in addition to
* the registered style hosts.
*/
removeStyles(styles: string[], urls?: string[]): void;
removeStyles(styles: string[], urls?: string[], hostNode?: Node): void;

/**
* Adds a host node to contain styles added to the DOM and adds all existing style usage to
Expand Down
69 changes: 68 additions & 1 deletion packages/core/test/acceptance/hmr_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,73 @@ describe('hot module replacement', () => {
);
});

it('should preserve native slot content when replacing an isolated shadow DOM component', () => {
// Domino doesn't support shadow DOM.
if (isNode) {
return;
}

let instance!: ChildCmp;
const initialMetadata: Component = {
encapsulation: ViewEncapsulation.ExperimentalIsolatedShadowDom,
selector: 'child-cmp',
template: '<slot></slot><span>{{state}}</span>',
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {
state = 0;

constructor() {
instance = this;
}
}

@Component({
selector: 'projected-cmp',
template: '<button>Projected</button>',
})
class ProjectedCmp {}

@Component({
imports: [ChildCmp, ProjectedCmp],
template: '<child-cmp>Projected text <projected-cmp></projected-cmp></child-cmp>',

changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();
const getHost = () => fixture.nativeElement.querySelector('child-cmp') as HTMLElement;
const getSlot = () => getHost().shadowRoot!.querySelector('slot') as HTMLSlotElement;

expect(
getSlot()
.assignedNodes()
.map((node) => node.textContent?.trim()),
).toEqual(['Projected text', 'Projected']);

instance.state = 1;
fixture.detectChanges();
expectHTML(getHost().shadowRoot!, '<slot></slot><span>1</span>');

replaceMetadata(ChildCmp, {
...initialMetadata,
template: '<slot></slot><span>Changed {{state}}</span>',
changeDetection: ChangeDetectionStrategy.Eager,
});
fixture.detectChanges();

expect(
getSlot()
.assignedNodes()
.map((node) => node.textContent?.trim()),
).toEqual(['Projected text', 'Projected']);
expectHTML(getHost().shadowRoot!, '<slot></slot><span>Changed 1</span>');
});

it('should continue binding inputs to a component that is replaced', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
Expand Down Expand Up @@ -2400,7 +2467,7 @@ describe('hot module replacement', () => {
);
}

function expectHTML(element: HTMLElement, expectation: string) {
function expectHTML(element: HTMLElement | ShadowRoot, expectation: string) {
const actual = element.innerHTML
.replace(/<!--(\W|\w)*?-->/g, '')
.replace(/\s(ng-reflect|_nghost|_ngcontent)-\S*="[^"]*"/g, '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"INJECTOR_SCOPE",
"INTERNAL_APPLICATION_ERROR_HANDLER",
"INTERNAL_BROWSER_PLATFORM_PROVIDERS",
"ISOLATED_SHADOW_STYLE_HOST",
"InjectableAnimationEngine",
"InjectionToken",
"Injector",
Expand Down Expand Up @@ -257,6 +258,7 @@
"TracingService",
"TransitionAnimationEngine",
"TransitionAnimationPlayer",
"USE_SHADOW_ROOT_AS_ISOLATED_HOST",
"USE_VALUE",
"UnsubscriptionError",
"VIEW_REFS",
Expand Down Expand Up @@ -389,6 +391,7 @@
"convertToBitFlags",
"convertToInjectOptions",
"copyAnimationEvent",
"copyIsolatedShadowStyleHost",
"couldBeInjectableType",
"createAnimationFailed",
"createComponentLView",
Expand Down Expand Up @@ -515,6 +518,7 @@
"getInjectorIndex",
"getInsertInFrontOfRNode",
"getInsertInFrontOfRNodeWithNoI18n",
"getIsolatedShadowStyleHost",
"getLView",
"getLViewParent",
"getNameOnlyMarkerIndex",
Expand Down Expand Up @@ -548,6 +552,7 @@
"getSelectedTNode",
"getSimpleChangesStore",
"getStyleHost",
"getStyleInsertionReference",
"getTNode",
"getTNodeFromLView",
"getTView",
Expand Down Expand Up @@ -647,6 +652,7 @@
"isRootView",
"isSchedulerTick",
"isSkipHydrationRootTNode",
"isStyleOrLinkElement",
"isSubscribable",
"isSubscriber",
"isSubscription",
Expand Down Expand Up @@ -814,6 +820,7 @@
"setInjectImplementation",
"setInputsFromAttrs",
"setIsRefreshingViews",
"setIsolatedShadowStyleHost",
"setLocaleId",
"setPropertyAndInputs",
"setRootDomAdapter",
Expand Down
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy