mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
The custom element implementation previously used a custom code path for setting inputs, which contained bespoke code for writing input properties, detecting whether inputs actually change, marking the component dirty, scheduling and running CD, invoking `ngOnChanges`, etc. This custom logic had several downsides: * Its behavior different from how Angular components behave in a normal template. For example, inputs setters were invoked in `NgZone.run`, which (when called from outside the zone) would trigger synchronous CD in the component, _without_ calling `ngOnChanges`. Only when the custom rAF- scheduled `detectChanges()` call triggered would `ngOnChanges` be called. * CD always ran multiple times, because of the above. `NgZone.run` would trigger CD, and then separately the scheduler would trigger CD. * Signal inputs were not supported, since inputs were set via direct property writes. This change refactors the custom element implementation with two changes: 1. `ComponentRef.setInput` is used instead of a custom code path for writing inputs. This allows us to drop all the custom logic related to managing `ngOnChanges`, since `setInput` does that under the hood. `ngOnChanges` behavior now matches how the component would behave when _not_ rendered as a custom element. 2. The custom rAF-based CD scheduler is removed in favor of the main Angular scheduler, which now handles custom elements as necessary. Running `NgZone.run` is sufficient to trigger CD when zones are used, and the hybrid zoneless scheduler now ensures CD is scheduled when `setInput` is called even with no ZoneJS enabled. As a result, the dedicated elements scheduler is now only used when Angular's built-in scheduler is disabled. As a concession to backwards compatibility, the element's view is also marked for refresh when an input changes. Doing this allows CD to revisit the element even if it becomes dirty during CD, mimicking how it would be detected by the former elements scheduler unconditionally refreshing the view a second time. As a part of this change, the elements tests have been significantly refactored. Previously all of Angular was faked/spied, which had a number of downsides. For example, there were tests which asserted that change detection only happens once when setting multiple inputs. This wasn't actually the case (because of `NgZone.run` - see logic above) but the test didn't catch the issue because it was only spying on `detectChanges()` which isn't called from `ApplicationRef.tick()`. Even the components were fake. Now, the tests use real Angular components and factories. They've also been updated to not use `fakeAsync`. A number of tests have been disabled, which were previously asserting behavior that wasn't matching what was actually happening (as above). Other tests were disabled due to real differences with `ngOnChanges` behavior, where the current behavior could be seen as a bug. Fixes #53981 BREAKING CHANGE: as part of switching away from custom CD behavior to the hybrid scheduler, timing of change detection around custom elements has changed subtly. These changes make elements more efficient, but can cause tests which encoded assumptions about how or when elements would be checked to require updating. PR Close #56728
111 lines
3.2 KiB
TypeScript
111 lines
3.2 KiB
TypeScript
/**
|
|
* @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.io/license
|
|
*/
|
|
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
|
|
|
|
/**
|
|
* Provide methods for scheduling the execution of a callback.
|
|
*/
|
|
export const scheduler = {
|
|
/**
|
|
* Schedule a callback to be called after some delay.
|
|
*
|
|
* Returns a function that when executed will cancel the scheduled function.
|
|
*/
|
|
schedule(taskFn: () => void, delay: number): () => void {
|
|
const id = setTimeout(taskFn, delay);
|
|
return () => clearTimeout(id);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Convert a camelCased string to kebab-cased.
|
|
*/
|
|
export function camelToDashCase(input: string): string {
|
|
return input.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
}
|
|
|
|
/**
|
|
* Check whether the input is an `Element`.
|
|
*/
|
|
export function isElement(node: Node | null): node is Element {
|
|
return !!node && node.nodeType === Node.ELEMENT_NODE;
|
|
}
|
|
|
|
/**
|
|
* Check whether the input is a function.
|
|
*/
|
|
export function isFunction(value: any): value is Function {
|
|
return typeof value === 'function';
|
|
}
|
|
|
|
/**
|
|
* Convert a kebab-cased string to camelCased.
|
|
*/
|
|
export function kebabToCamelCase(input: string): string {
|
|
return input.replace(/-([a-z\d])/g, (_, char) => char.toUpperCase());
|
|
}
|
|
|
|
let _matches: (this: any, selector: string) => boolean;
|
|
|
|
/**
|
|
* Check whether an `Element` matches a CSS selector.
|
|
* NOTE: this is duplicated from @angular/upgrade, and can
|
|
* be consolidated in the future
|
|
*/
|
|
export function matchesSelector(el: any, selector: string): boolean {
|
|
if (!_matches) {
|
|
const elProto = <any>Element.prototype;
|
|
_matches =
|
|
elProto.matches ||
|
|
elProto.matchesSelector ||
|
|
elProto.mozMatchesSelector ||
|
|
elProto.msMatchesSelector ||
|
|
elProto.oMatchesSelector ||
|
|
elProto.webkitMatchesSelector;
|
|
}
|
|
return el.nodeType === Node.ELEMENT_NODE ? _matches.call(el, selector) : false;
|
|
}
|
|
|
|
/**
|
|
* Test two values for strict equality, accounting for the fact that `NaN !== NaN`.
|
|
*/
|
|
export function strictEquals(value1: any, value2: any): boolean {
|
|
return value1 === value2 || (value1 !== value1 && value2 !== value2);
|
|
}
|
|
|
|
/** Gets a map of default set of attributes to observe and the properties they affect. */
|
|
export function getDefaultAttributeToPropertyInputs(
|
|
inputs: {propName: string; templateName: string; transform?: (value: any) => any}[],
|
|
) {
|
|
const attributeToPropertyInputs: {
|
|
[key: string]: [propName: string, transform: ((value: any) => any) | undefined];
|
|
} = {};
|
|
inputs.forEach(({propName, templateName, transform}) => {
|
|
attributeToPropertyInputs[camelToDashCase(templateName)] = [propName, transform];
|
|
});
|
|
|
|
return attributeToPropertyInputs;
|
|
}
|
|
|
|
/**
|
|
* Gets a component's set of inputs. Uses the injector to get the component factory where the inputs
|
|
* are defined.
|
|
*/
|
|
export function getComponentInputs(
|
|
component: Type<any>,
|
|
injector: Injector,
|
|
): {
|
|
propName: string;
|
|
templateName: string;
|
|
transform?: (value: any) => any;
|
|
isSignal: boolean;
|
|
}[] {
|
|
const componentFactoryResolver = injector.get(ComponentFactoryResolver);
|
|
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
|
|
return componentFactory.inputs;
|
|
}
|