-
Notifications
You must be signed in to change notification settings - Fork 24.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
signal inputs wierd behavior when used with @angular/elements. #53981
Comments
It could be better if we have set/update methodes exported for input signals; so we can update them programatically. |
@aihilali Small update on this. I did look into this. Angular Elements require more design and consideration to work well with the signals integration of the framework. Right now it's not working and we aware of the issue. Thanks for reporting this. There seem to be fundamental design questions with regards to how signal inpus are exposed on the native element instance, how they can be updated etc. Exposing a |
@devversion Any specific timeline when we get this fixed? |
Hi, I've noticed this behavior as well. Not only when setting it via JavaScript, but also in HTML, like: <to-be-a-web-component anInput="hello world"></to-be-a-web-component> This will also change the Accessing the signal in the component templates then will lead to an error, because it cannot execute a function on a string. edit: this also happens in Angular 17.2. |
I tried playing with Input Transformers, but no luck :( |
I created a simple minimal example of this bug, @aihilali could you add this to your main comment? https://stackblitz.com/edit/aeh4ae?file=src%2Fapp%2Fangular-view-child.ts |
@devversion I’ve got this working using the following NgElementStrategy: /**
* Factory that creates new SignalComponentNgElementStrategy instance. Gets the component factory with the
* constructor's injector's factory resolver and passes that factory to each strategy.
*/
export class SignalComponentNgElementStrategyFactory implements NgElementStrategyFactory {
constructor(readonly component: Type<object>) {}
create(injector: Injector) {
return new SignalComponentNgElementStrategy(this.component, injector);
}
}
/**
* Creates and destroys a component ref using a component factory and handles change detection
* in response to input changes.
*/
export class SignalComponentNgElementStrategy implements NgElementStrategy {
// Subject of `NgElementStrategyEvent` observables corresponding to the component's outputs.
private eventEmitters = new ReplaySubject<Observable<NgElementStrategyEvent>[]>(1);
/** Merged stream of the component's output events. */
readonly events = this.eventEmitters.pipe(switchMap((emitters) => merge(...emitters)));
/** Reference to the component that was created on connect. */
private componentRef: ComponentRef<object> | null = null;
/** Whether a change detection has been scheduled to run on the component. */
private scheduledChangeDetectionFn: (() => void) | null = null;
/** Callback function that when called will cancel a scheduled destruction on the component. */
private scheduledDestroyFn: (() => void) | null = null;
/** Initial input values that were set before the component was created. */
private readonly initialInputValues = new Map<string, any>();
/**
* Set of component inputs that have not yet changed, i.e. for which `recordInputChange()` has not
* fired.
* (This helps detect the first change of an input, even if it is explicitly set to `undefined`.)
*/
private readonly unchangedInputs: Set<string>;
/** Service for setting zone context. */
private readonly ngZone: NgZone;
/** The zone the element was created in or `null` if Zone.js is not loaded. */
private readonly elementZone: Zone | null;
/** A mirror of the component, for accessing the component’s inputs */
private readonly componentMirror;
constructor(
private component: Type<object>,
private injector: Injector,
) {
this.componentMirror = reflectComponentType(component);
this.unchangedInputs = new Set<string>(this.componentMirror?.inputs.map(({ propName }) => propName));
this.ngZone = this.injector.get<NgZone>(NgZone);
this.elementZone = typeof Zone === 'undefined' ? null : this.ngZone.run(() => Zone.current);
}
/**
* Initializes a new component if one has not yet been created and cancels any scheduled
* destruction.
*/
connect(element: HTMLElement) {
this.runInZone(() => {
// If the element is marked to be destroyed, cancel the task since the component was
// reconnected
if (this.scheduledDestroyFn !== null) {
this.scheduledDestroyFn();
this.scheduledDestroyFn = null;
return;
}
if (this.componentRef === null) {
this.initializeComponent(element);
}
});
}
/**
* Schedules the component to be destroyed after some small delay in case the element is just
* being moved across the DOM.
*/
disconnect() {
this.runInZone(() => {
// Return if there is no componentRef or the component is already scheduled for destruction
if (this.componentRef === null || this.scheduledDestroyFn !== null) {
return;
}
// Schedule the component to be destroyed after a small timeout in case it is being
// moved elsewhere in the DOM
this.scheduledDestroyFn = schedule(() => {
if (this.componentRef !== null) {
this.componentRef.destroy();
this.componentRef = null;
}
}, DESTROY_DELAY);
});
}
/**
* Returns the component property value. If the component has not yet been created, the value is
* retrieved from the cached initialization values.
*/
getInputValue(property: string): unknown {
return this.runInZone(() => {
if (this.componentRef === null) {
return this.initialInputValues.get(property);
}
const value: unknown = (this.componentRef.instance as Record<string, unknown>)[property];
if (isSignal(value)) return value();
else return value;
});
}
/**
* Sets the input value for the property. If the component has not yet been created, the value is
* cached and set when the component is created.
*/
setInputValue(property: string, value: any, transform?: (value: any) => any): void {
this.runInZone(() => {
if (transform) {
value = transform.call(this.componentRef?.instance, value);
}
if (this.componentRef === null) {
this.initialInputValues.set(property, value);
return;
}
// Ignore the value if it is strictly equal to the current value, except if it is `undefined`
// and this is the first change to the value (because an explicit `undefined` _is_ strictly
// equal to not having a value set at all, but we still need to record this as a change).
if (
strictEquals(value, this.getInputValue(property)) &&
!(value === undefined && this.unchangedInputs.has(property))
) {
return;
}
this.unchangedInputs.delete(property);
// Update the component instance and schedule change detection.
this.componentRef.setInput(property, value);
this.scheduleDetectChanges();
});
}
/**
* Creates a new component through the component factory with the provided element host and
* sets up its initial inputs, listens for outputs changes, and runs an initial change detection.
*/
protected initializeComponent(element: HTMLElement) {
const environmentInjector = this.injector.get(EnvironmentInjector);
const elementInjector = Injector.create({ providers: [], parent: this.injector });
const projectableNodes = this.componentMirror
? extractProjectableNodes(element, this.componentMirror.ngContentSelectors)
: [];
this.componentRef = createComponent(this.component, {
environmentInjector,
elementInjector,
projectableNodes,
hostElement: element,
});
this.initializeInputs();
this.initializeOutputs(this.componentRef);
this.detectChanges();
const applicationRef = this.injector.get<ApplicationRef>(ApplicationRef);
applicationRef.attachView(this.componentRef.hostView);
}
/** Set any stored initial inputs on the component's properties. */
protected initializeInputs(): void {
this.componentMirror?.inputs.forEach(({ propName, transform }) => {
if (this.initialInputValues.has(propName)) {
// Call `setInputValue()` now that the component has been instantiated to update its
// properties and fire `ngOnChanges()`.
this.setInputValue(propName, this.initialInputValues.get(propName), transform);
}
});
this.initialInputValues.clear();
}
/** Sets up listeners for the component's outputs so that the events stream emits the events. */
protected initializeOutputs(componentRef: ComponentRef<any>): void {
const eventEmitters: Observable<NgElementStrategyEvent>[] =
this.componentMirror?.outputs.map(({ propName, templateName }) => {
const emitter: EventEmitter<unknown> | OutputEmitterRef<unknown> = componentRef.instance[propName];
const emitterObservable: Observable<unknown> = isObservable(emitter) ? emitter : outputToObservable(emitter);
return emitterObservable.pipe(map((value) => ({ name: templateName, value })));
}) ?? [];
this.eventEmitters.next(eventEmitters);
}
/**
* Schedules change detection to run on the component.
* Ignores subsequent calls if already scheduled.
*/
protected scheduleDetectChanges(): void {
if (this.scheduledChangeDetectionFn) {
return;
}
this.scheduledChangeDetectionFn = scheduleBeforeRender(() => {
this.scheduledChangeDetectionFn = null;
this.detectChanges();
});
}
/** Runs change detection on the component. */
protected detectChanges(): void {
if (this.componentRef === null) {
return;
}
this.componentRef.changeDetectorRef.detectChanges();
}
/** Runs in the angular zone, if present. */
private runInZone(fn: () => unknown) {
return this.elementZone && Zone.current !== this.elementZone ? this.ngZone.run(fn) : fn();
}
} |
Support signal-based components in createCustomElement. Previously, the signal was overwritten by the input value, losing reactivity. This change calls setInput on the componentRef. Also remove onChanges logic in ComponentFactoryStrategy, because setInput handles this. DEPRECATED: the injector argument of ComponentNgElementStrategyFactory The injector argument is no longer needed in the constructor. Fixes angular#53981
https://stackblitz.com/edit/stackblitz-starters-jc7rqz?file=src%2Fmain.ts Another example that reproduces the issue of using signals in Angular Elements |
Hey there, I'm also experiencing the same issue as detailed in @tomasdev's stackblitz. Interestingly, one of mine is working, but others aren't. inputOne = input.required<string>();
inputTwo = input.required<string>();
inputThree = input(mockObject, {
transform: (value: string | MyObjectInterface) =>
typeof value === 'string'
? (JSON.parse(value) as MyObjectInterface)
: value,
}); inputThree seems to work fine when I call |
After playing with this a bit I think I can kind of see what's happening. Check out this stackblitz: https://stackblitz.com/edit/stackblitz-starters-chkc8v If you open the console, you'll notice that the |
The issue seems to be here: angular/packages/elements/src/component-factory-strategy.ts Lines 206 to 207 in 1223122
Changing this to use |
I looked more into the code of the default |
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
Yes
Description
lets suppose we have this component :
by using the @angular/elements package I can export that component as a web-component named : to-be-a-web-component.
Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
No response
Please provide the environment you discovered this bug in (run
ng version
)Anything else?
No response
The text was updated successfully, but these errors were encountered: