FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(core): update component styles in-place during hmr by clydin · Pull Request #69964 · angular/angular · GitHub

Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .ts  (5) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
165 changes: 159 additions & 6 deletions packages/core/src/render3/hmr.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {RendererFactory} from './interfaces/renderer';
import {NgZone} from '../zone';
import {ViewEncapsulation} from '../metadata/view';
import {NG_COMP_DEF} from './fields';
import {SHARED_STYLES_HOST, SharedStylesHost} from './interfaces/shared_styles_host';
import {APP_ID} from '../application/application_tokens';
import {
createLView,
getInitialLViewFlagsFromDef,
Expand Down Expand Up @@ -106,15 +108,166 @@ export function ɵɵreplaceMetadata(
// If a `tView` hasn't been created yet, it means that this component hasn't been instantianted
// before. In this case there's nothing left for us to do aside from patching it in.
if (oldDef.tView) {
const trackedViews = getTrackedLViews().values();
for (const root of trackedViews) {
// Note: we have the additional check, because `IsRoot` can also indicate
// a component created through something like `createComponent`.
if (isRootView(root) && root[PARENT] === null) {
recreateMatchingLViews(importMeta, id, newDef, oldDef, root);
const oldStyles = oldDef.styles ?? [];
const newStyles = newDef.styles ?? [];
const oldUrls = oldDef.getExternalStyles?.(oldDef.id) ?? oldDef.getExternalStyles?.() ?? [];
const newUrls = newDef.getExternalStyles?.(newDef.id) ?? newDef.getExternalStyles?.() ?? [];

const isStyleUpdate = isPureStyleUpdate(oldDef, newDef, oldStyles, newStyles, oldUrls, newUrls);

const trackedViews = getTrackedLViews();
let styleReplaced = false;

if (isStyleUpdate) {
const processedHosts = new Set<SharedStylesHost>();
for (const root of trackedViews.values()) {
if (isRootView(root) && root[PARENT] === null) {
try {
const sharedStylesHost = root[INJECTOR].get(SHARED_STYLES_HOST, null, {optional: true});
if (sharedStylesHost?.replaceStyles && !processedHosts.has(sharedStylesHost)) {
processedHosts.add(sharedStylesHost);
const appId = root[INJECTOR].get(APP_ID, 'ng');
const compId = `${appId}-${oldDef.id}`;
const isEmulated = oldDef.encapsulation === ViewEncapsulation.Emulated;
const shimmedOld = isEmulated ? shimStyles(compId, oldStyles) : oldStyles;
const shimmedNew = isEmulated ? shimStyles(compId, newStyles) : newStyles;

sharedStylesHost.replaceStyles(
shimmedOld,
shimmedNew,
oldUrls,
newUrls,
oldDef.encapsulation !== ViewEncapsulation.None,
);
styleReplaced = true;
}
} catch {
// Injector may be destroyed in test environments
}
}
}
}

if (!styleReplaced) {
for (const root of trackedViews.values()) {
if (isRootView(root) && root[PARENT] === null) {
recreateMatchingLViews(importMeta, id, newDef, oldDef, root);
}
}
}
}
}

/**
* Normalizes hostAttrs array by filtering out compiler-generated `_nghost-` encapsulation attributes.
*/
function normalizeHostAttrs(attrs: unknown): unknown[] {
if (!Array.isArray(attrs)) return [];
return attrs.filter((attr) => typeof attr !== 'string' || !attr.startsWith('_nghost-'));
}

/**
* Determines whether a metadata update is strictly a style modification
* (inline or external URLs) without any structural, binding, or scope changes.
*/
function isPureStyleUpdate(
oldDef: ComponentDef<unknown>,
newDef: ComponentDef<unknown>,
oldStyles: string[],
newStyles: string[],
oldUrls: string[],
newUrls: string[],
): boolean {
if (
oldDef.encapsulation !== newDef.encapsulation ||
oldDef.encapsulation === ViewEncapsulation.ShadowDom ||
oldDef.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom ||
oldDef.onPush !== newDef.onPush
) {
return false;
}

const stylesChanged =
oldStyles.length !== newStyles.length ||
oldStyles.some((s, i) => s !== newStyles[i]) ||
oldUrls.length !== newUrls.length ||
oldUrls.some((u, i) => u !== newUrls[i]);

if (!stylesChanged) {
return false;
}

return (
isSameFunctionOrNull(oldDef.template, newDef.template) &&
isSameFunctionOrNull(oldDef.hostBindings, newDef.hostBindings) &&
isSameObjectOrNull(
normalizeHostAttrs(oldDef.hostAttrs),
normalizeHostAttrs(newDef.hostAttrs),
) &&
isSameFunctionOrNull(oldDef.providersResolver, newDef.providersResolver) &&
isSameFunctionOrNull(oldDef.viewProvidersResolver, newDef.viewProvidersResolver) &&
isSameObjectOrNull(oldDef.inputs, newDef.inputs) &&
isSameObjectOrNull(oldDef.outputs, newDef.outputs) &&
isSameFunctionOrNull(oldDef.contentQueries, newDef.contentQueries) &&
isSameFunctionOrNull(oldDef.viewQuery, newDef.viewQuery) &&
isSameObjectOrNull(oldDef.selectors, newDef.selectors) &&
isSameObjectOrNull(oldDef.hostDirectives, newDef.hostDirectives) &&
isSameObjectOrNull(oldDef.exportAs, newDef.exportAs)
);
}

/**
* Replaces `%COMP%` placeholders in component styles with the component's unique encapsulation ID.
* NOTE: Keep implementation in sync with `shimStylesContent` in `packages/platform-browser/src/dom/dom_renderer.ts`.
*/
function shimStyles(compId: string, styles: string[]): string[] {
return styles.map((s) => s.replace(/%COMP%/g, compId));
}

/**
* Compares two optional function references or function implementations for equivalence.
* Returns true if both references are equal or have identical stringified code bodies.
*/
function isSameFunctionOrNull(
fn1: Function | null | undefined,
fn2: Function | null | undefined,
): boolean {
if (!fn1 && !fn2) return true;
return (
fn1 === fn2 ||
(typeof fn1 === 'function' && typeof fn2 === 'function' && fn1.toString() === fn2.toString())
);
}

/**
* Compares two optional object literals, arrays, functions, or records for structural equivalence.
*/
function isSameObjectOrNull(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (!a && !b) return true;
if (typeof a === 'function' && typeof b === 'function') {
return a.toString() === b.toString();
}
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;

if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((val, i) => isSameObjectOrNull(val, b[i]))
);
}

const keysA = Object.keys(a as object);
const keysB = Object.keys(b as object);
if (keysA.length !== keysB.length) return false;

return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
isSameObjectOrNull((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
}

/**
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ export interface SharedStylesHost {
*/
removeStyles(styles: string[], urls?: string[]): void;

/**
* Replaces existing styles in the DOM by mutating their element content or href in-place.
* @param oldStyles An array of existing style content strings.
* @param newStyles An array of new style content strings.
* @param oldUrls An array of existing external style URLs.
* @param newUrls An array of new external style URLs.
* @param isScoped Whether the styles are scoped to a specific component class. Defaults to `true`.
*/
replaceStyles?(
oldStyles: string[],
newStyles: string[],
oldUrls?: string[],
newUrls?: string[],
isScoped?: boolean,
): void;

/**
* Adds a host node to contain styles added to the DOM and adds all existing style usage to
* the newly added host node.
Expand Down
183 changes: 183 additions & 0 deletions packages/core/test/acceptance/hmr_spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,189 @@ describe('hot module replacement', () => {
);
});

it('should update styles in-place during HMR when styles are modified', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
template: '<span>Test</span>',
styles: ['span { color: red; }'],
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {}

@Component({
imports: [ChildCmp],
template: '<child-cmp/>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

markNodesAsCreatedInitially(fixture.nativeElement);

replaceMetadata(ChildCmp, {
...initialMetadata,
styles: ['span { color: blue; }'],
});
fixture.detectChanges();

verifyNodesRemainUntouched(fixture.nativeElement);
expectHTML(fixture.nativeElement, '<child-cmp><span>Test</span></child-cmp>');
});

it('should update styles in-place during HMR when multiple instances of component are rendered', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
template: '<span>Test</span>',
styles: ['span { color: red; }'],
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {}

@Component({
imports: [ChildCmp],
template: '<child-cmp/><child-cmp/>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

markNodesAsCreatedInitially(fixture.nativeElement);

replaceMetadata(ChildCmp, {
...initialMetadata,
styles: ['span { color: blue; }'],
});
fixture.detectChanges();

verifyNodesRemainUntouched(fixture.nativeElement);
expectHTML(
fixture.nativeElement,
'<child-cmp><span>Test</span></child-cmp><child-cmp><span>Test</span></child-cmp>',
);
});

it('should update styles for ShadowDom encapsulated component during HMR', () => {
// Domino doesn't support shadow DOM.
if (isNode) {
return;
}

const initialMetadata: Component = {
selector: 'child-cmp',
template: '<span>Test</span>',
styles: ['span { color: red; }'],
encapsulation: ViewEncapsulation.ShadowDom,
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {}

@Component({
imports: [ChildCmp],
template: '<child-cmp/>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

const childHost = fixture.nativeElement.querySelector('child-cmp');
expectHTML(childHost.shadowRoot, `<style>span { color: red; }</style><span>Test</span>`);

replaceMetadata(ChildCmp, {
...initialMetadata,
styles: ['span { color: blue; }'],
});
fixture.detectChanges();

const newChildHost = fixture.nativeElement.querySelector('child-cmp');
// ShadowDom encapsulation components trigger view recreation because styles are scoped within shadow roots
expect(newChildHost).not.toBe(childHost);
expectHTML(newChildHost.shadowRoot, `<style>span { color: blue; }</style><span>Test</span>`);
});

it('should recreate component when metadata other than styles is modified during HMR', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
template: '<span>Test</span>',
styles: ['span { color: red; }'],
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {}

@Component({
imports: [ChildCmp],
template: '<child-cmp/>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

const spanBefore = fixture.nativeElement.querySelector('span');

replaceMetadata(ChildCmp, {
...initialMetadata,
template: '<span>Modified Template</span>',
styles: ['span { color: blue; }'],
});
fixture.detectChanges();

const spanAfter = fixture.nativeElement.querySelector('span');
expect(spanAfter).not.toBe(spanBefore);
expectHTML(fixture.nativeElement, '<child-cmp><span>Modified Template</span></child-cmp>');
});

it('should recreate component when host attributes are modified alongside styles during HMR', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
template: '<span>Test</span>',
styles: ['span { color: red; }'],
host: {'class': 'initial-class'},
changeDetection: ChangeDetectionStrategy.Eager,
};

@Component(initialMetadata)
class ChildCmp {}

@Component({
imports: [ChildCmp],
template: '<child-cmp/>',
changeDetection: ChangeDetectionStrategy.Eager,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

const childHostBefore = fixture.nativeElement.querySelector('child-cmp');
const spanBefore = fixture.nativeElement.querySelector('span');
expect(childHostBefore.classList.contains('initial-class')).toBeTrue();

replaceMetadata(ChildCmp, {
...initialMetadata,
styles: ['span { color: blue; }'],
host: {'class': 'updated-class'},
});
fixture.detectChanges();

const spanAfter = fixture.nativeElement.querySelector('span');
expect(spanAfter).not.toBe(spanBefore);
});

it('should continue binding inputs to a component that is replaced', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL