mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
BREAKING CHANGE: Fix signal input getter behavior in custom elements. Before this change, signal inputs in custom elements required function calls to access their values (`elementRef.newInput()`), while decorator inputs were accessed directly (`elementRef.oldInput`). This inconsistency caused confusion and typing difficulties. The getter behavior has been standardized so signal inputs can now be accessed directly, matching the behavior of decorator inputs: Before: - Decorator Input: `elementRef.oldInput` - Signal Input: `elementRef.newInput()` After: - Decorator Input: `elementRef.oldInput` - Signal Input: `elementRef.newInput` closes #62097 PR Close #62113
451 lines
16 KiB
TypeScript
451 lines
16 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.dev/license
|
|
*/
|
|
|
|
import {
|
|
Component,
|
|
destroyPlatform,
|
|
DoBootstrap,
|
|
EventEmitter,
|
|
Injector,
|
|
input,
|
|
Input,
|
|
isSignal,
|
|
NgModule,
|
|
Output,
|
|
signal,
|
|
WritableSignal,
|
|
} from '@angular/core';
|
|
import {BrowserModule, platformBrowser} from '@angular/platform-browser';
|
|
import {Subject} from 'rxjs';
|
|
|
|
import {createCustomElement, NgElementConstructor} from '../src/create-custom-element';
|
|
import {
|
|
NgElementStrategy,
|
|
NgElementStrategyEvent,
|
|
NgElementStrategyFactory,
|
|
} from '../src/element-strategy';
|
|
|
|
interface WithFooBar {
|
|
fooFoo: string;
|
|
barBar: string;
|
|
fooTransformed: unknown;
|
|
fooSignal: string | null;
|
|
}
|
|
|
|
describe('createCustomElement', () => {
|
|
let selectorUid = 0;
|
|
let testContainer: HTMLDivElement;
|
|
let NgElementCtor: NgElementConstructor<WithFooBar>;
|
|
let strategy: TestStrategy;
|
|
let strategyFactory: TestStrategyFactory;
|
|
let injector: Injector;
|
|
|
|
beforeAll((done) => {
|
|
testContainer = document.createElement('div');
|
|
document.body.appendChild(testContainer);
|
|
destroyPlatform();
|
|
platformBrowser()
|
|
.bootstrapModule(TestModule)
|
|
.then((ref) => {
|
|
injector = ref.injector;
|
|
strategyFactory = new TestStrategyFactory();
|
|
strategy = strategyFactory.testStrategy;
|
|
|
|
NgElementCtor = createAndRegisterTestCustomElement(strategyFactory);
|
|
})
|
|
.then(done, done.fail);
|
|
});
|
|
|
|
afterEach(() => strategy.reset());
|
|
|
|
afterAll(() => {
|
|
destroyPlatform();
|
|
testContainer.remove();
|
|
(testContainer as any) = null;
|
|
});
|
|
|
|
it('should use a default strategy for converting component inputs', () => {
|
|
expect(NgElementCtor.observedAttributes).toEqual([
|
|
'foo-foo',
|
|
'barbar',
|
|
'foo-transformed',
|
|
'foo-signal',
|
|
]);
|
|
});
|
|
|
|
it('should send input values from attributes when connected', () => {
|
|
const element = new NgElementCtor(injector);
|
|
element.setAttribute('foo-foo', 'value-foo-foo');
|
|
element.setAttribute('barbar', 'value-barbar');
|
|
element.setAttribute('foo-transformed', 'truthy');
|
|
element.setAttribute('foo-signal', 'value-signal');
|
|
element.connectedCallback();
|
|
expect(strategy.connectedElement).toBe(element);
|
|
|
|
expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
|
|
expect(strategy.getInputValue('barBar')).toBe('value-barbar');
|
|
expect(strategy.getInputValue('fooTransformed')).toBe(true);
|
|
expect(strategy.getInputValue('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should work even if the constructor is not called (due to polyfill)', () => {
|
|
// Currently, all the constructor does is initialize the `injector` property. This
|
|
// test simulates not having called the constructor by "unsetting" the property.
|
|
//
|
|
// NOTE:
|
|
// If the constructor implementation changes in the future, this test needs to be adjusted
|
|
// accordingly.
|
|
const element = new NgElementCtor(injector);
|
|
delete (element as any).injector;
|
|
|
|
element.setAttribute('foo-foo', 'value-foo-foo');
|
|
element.setAttribute('barbar', 'value-barbar');
|
|
element.setAttribute('foo-transformed', 'truthy');
|
|
element.setAttribute('foo-signal', 'value-signal');
|
|
element.connectedCallback();
|
|
|
|
expect(strategy.connectedElement).toBe(element);
|
|
expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
|
|
expect(strategy.getInputValue('barBar')).toBe('value-barbar');
|
|
expect(strategy.getInputValue('fooTransformed')).toBe(true);
|
|
expect(strategy.getInputValue('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should listen to output events after connected', () => {
|
|
const element = new NgElementCtor(injector);
|
|
element.connectedCallback();
|
|
|
|
let eventValue: any = null;
|
|
element.addEventListener('some-event', (e: Event) => (eventValue = (e as CustomEvent).detail));
|
|
strategy.events.next({name: 'some-event', value: 'event-value'});
|
|
|
|
expect(eventValue).toEqual('event-value');
|
|
});
|
|
|
|
it('should not listen to output events after disconnected', () => {
|
|
const element = new NgElementCtor(injector);
|
|
element.connectedCallback();
|
|
element.disconnectedCallback();
|
|
expect(strategy.disconnectCalled).toBe(true);
|
|
|
|
let eventValue: any = null;
|
|
element.addEventListener('some-event', (e: Event) => (eventValue = (e as CustomEvent).detail));
|
|
strategy.events.next({name: 'some-event', value: 'event-value'});
|
|
|
|
expect(eventValue).toEqual(null);
|
|
});
|
|
|
|
it('should listen to output events during initialization', () => {
|
|
const events: string[] = [];
|
|
|
|
const element = new NgElementCtor(injector);
|
|
element.addEventListener('strategy-event', (evt) => events.push((evt as CustomEvent).detail));
|
|
element.connectedCallback();
|
|
|
|
expect(events).toEqual(['connect']);
|
|
});
|
|
|
|
it('should not break if `NgElementStrategy#events` is not available before calling `NgElementStrategy#connect()`', () => {
|
|
class TestStrategyWithLateEvents extends TestStrategy {
|
|
override events: Subject<NgElementStrategyEvent> = undefined!;
|
|
|
|
override connect(element: HTMLElement): void {
|
|
this.connectedElement = element;
|
|
this.events = new Subject<NgElementStrategyEvent>();
|
|
this.events.next({name: 'strategy-event', value: 'connect'});
|
|
}
|
|
}
|
|
|
|
const strategyWithLateEvents = new TestStrategyWithLateEvents();
|
|
const capturedEvents: string[] = [];
|
|
|
|
const NgElementCtorWithLateEventsStrategy = createAndRegisterTestCustomElement({
|
|
create: () => strategyWithLateEvents,
|
|
});
|
|
|
|
const element = new NgElementCtorWithLateEventsStrategy(injector);
|
|
element.addEventListener('strategy-event', (evt) =>
|
|
capturedEvents.push((evt as CustomEvent).detail),
|
|
);
|
|
element.connectedCallback();
|
|
|
|
// The "connect" event (emitted during initialization) was missed, but things didn't break.
|
|
expect(capturedEvents).toEqual([]);
|
|
|
|
// Subsequent events are still captured.
|
|
strategyWithLateEvents.events.next({name: 'strategy-event', value: 'after-connect'});
|
|
expect(capturedEvents).toEqual(['after-connect']);
|
|
});
|
|
|
|
it('should properly set getters/setters on the element', () => {
|
|
const element = new NgElementCtor(injector);
|
|
element.fooFoo = 'foo-foo-value';
|
|
element.barBar = 'barBar-value';
|
|
element.fooTransformed = 'truthy';
|
|
element.fooSignal = 'value-signal';
|
|
|
|
expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
|
|
expect(strategy.inputs.get('barBar')).toBe('barBar-value');
|
|
expect(strategy.inputs.get('fooTransformed')).toBe(true);
|
|
expect(strategy.inputs.get('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should properly handle getting/setting properties on the element even if the constructor is not called', () => {
|
|
// Create a custom element while ensuring that the `NgElementStrategy` is not created
|
|
// inside the constructor. This is done to emulate the behavior of some polyfills that do
|
|
// not call the constructor.
|
|
strategyFactory.create = () => undefined as unknown as NgElementStrategy;
|
|
const element = new NgElementCtor(injector);
|
|
strategyFactory.create = TestStrategyFactory.prototype.create;
|
|
|
|
element.fooFoo = 'foo-foo-value';
|
|
element.barBar = 'barBar-value';
|
|
element.fooTransformed = 'truthy';
|
|
element.fooSignal = 'value-signal';
|
|
|
|
expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
|
|
expect(strategy.inputs.get('barBar')).toBe('barBar-value');
|
|
expect(strategy.inputs.get('fooTransformed')).toBe(true);
|
|
expect(strategy.inputs.get('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should capture properties set before upgrading the element', () => {
|
|
// Create a regular element and set properties on it.
|
|
const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
|
|
const element = Object.assign(document.createElement(selector), {
|
|
fooFoo: 'foo-prop-value',
|
|
barBar: 'bar-prop-value',
|
|
fooTransformed: 'truthy' as unknown,
|
|
fooSignal: 'value-signal',
|
|
});
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value');
|
|
expect(element.fooTransformed).toBe('truthy');
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
// Upgrade the element to a Custom Element and insert it into the DOM.
|
|
customElements.define(selector, ElementCtor);
|
|
testContainer.appendChild(element);
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value');
|
|
expect(element.fooTransformed).toBe(true);
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
|
|
expect(strategy.inputs.get('barBar')).toBe('bar-prop-value');
|
|
expect(strategy.inputs.get('fooTransformed')).toBe(true);
|
|
expect(strategy.inputs.get('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should capture properties set after upgrading the element but before inserting it into the DOM', () => {
|
|
// Create a regular element and set properties on it.
|
|
const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
|
|
const element = Object.assign(document.createElement(selector), {
|
|
fooFoo: 'foo-prop-value',
|
|
barBar: 'bar-prop-value',
|
|
fooTransformed: 'truthy' as unknown,
|
|
fooSignal: 'value-signal',
|
|
});
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value');
|
|
expect(element.fooTransformed).toBe('truthy');
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
// Upgrade the element to a Custom Element (without inserting it into the DOM) and update a
|
|
// property.
|
|
customElements.define(selector, ElementCtor);
|
|
customElements.upgrade(element);
|
|
element.barBar = 'bar-prop-value-2';
|
|
element.fooTransformed = '';
|
|
element.fooSignal = 'value-signal-changed';
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value-2');
|
|
expect(element.fooTransformed).toBe('');
|
|
expect(element.fooSignal).toBe('value-signal-changed');
|
|
|
|
// Insert the element into the DOM.
|
|
testContainer.appendChild(element);
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value-2');
|
|
expect(element.fooTransformed).toBe(false);
|
|
expect(element.fooSignal).toBe('value-signal-changed');
|
|
|
|
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
|
|
expect(strategy.inputs.get('barBar')).toBe('bar-prop-value-2');
|
|
expect(strategy.inputs.get('fooTransformed')).toBe(false);
|
|
expect(strategy.inputs.get('fooSignal')).toBe('value-signal-changed');
|
|
});
|
|
|
|
it('should allow overwriting properties with attributes after upgrading the element but before inserting it into the DOM', () => {
|
|
// Create a regular element and set properties on it.
|
|
const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
|
|
const element = Object.assign(document.createElement(selector), {
|
|
fooFoo: 'foo-prop-value',
|
|
barBar: 'bar-prop-value',
|
|
fooTransformed: 'truthy' as unknown,
|
|
fooSignal: 'value-signal',
|
|
});
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-prop-value');
|
|
expect(element.fooTransformed).toBe('truthy');
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
// Upgrade the element to a Custom Element (without inserting it into the DOM) and set an
|
|
// attribute.
|
|
customElements.define(selector, ElementCtor);
|
|
customElements.upgrade(element);
|
|
element.setAttribute('barbar', 'bar-attr-value');
|
|
element.setAttribute('foo-transformed', '');
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-attr-value');
|
|
expect(element.fooTransformed).toBe(false);
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
// Insert the element into the DOM.
|
|
testContainer.appendChild(element);
|
|
expect(element.fooFoo).toBe('foo-prop-value');
|
|
expect(element.barBar).toBe('bar-attr-value');
|
|
expect(element.fooTransformed).toBe(false);
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
|
|
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
|
|
expect(strategy.inputs.get('barBar')).toBe('bar-attr-value');
|
|
expect(strategy.inputs.get('fooTransformed')).toBe(false);
|
|
expect(strategy.inputs.get('fooSignal')).toBe('value-signal');
|
|
});
|
|
|
|
it('should return value from input getter for input signal', () => {
|
|
const {selector, ElementCtor} = createTestCustomElementForSignal();
|
|
const element = document.createElement(selector) as HTMLElement & {
|
|
fooSignal: string | null;
|
|
};
|
|
element.setAttribute('foo-signal', 'value-signal');
|
|
|
|
customElements.define(selector, ElementCtor);
|
|
testContainer.appendChild(element);
|
|
expect(element.fooSignal).toBe('value-signal');
|
|
});
|
|
|
|
it('should not unpack signal value with input decorator having signal as value', () => {
|
|
const {selector, ElementCtor} = createTestCustomElementForSignal();
|
|
const element = document.createElement(selector) as HTMLElement & {
|
|
fooFoo: WritableSignal<string | null>;
|
|
};
|
|
|
|
customElements.define(selector, ElementCtor);
|
|
testContainer.appendChild(element);
|
|
expect(isSignal(element.fooFoo)).toBe(true);
|
|
});
|
|
|
|
// Helpers
|
|
function createAndRegisterTestCustomElement(strategyFactory: NgElementStrategyFactory) {
|
|
const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
|
|
|
|
customElements.define(selector, ElementCtor);
|
|
|
|
return ElementCtor;
|
|
}
|
|
|
|
function createTestCustomElement(strategyFactory: NgElementStrategyFactory) {
|
|
return {
|
|
selector: `test-element-${++selectorUid}`,
|
|
ElementCtor: createCustomElement<WithFooBar>(TestComponent, {injector, strategyFactory}),
|
|
};
|
|
}
|
|
|
|
function createTestCustomElementForSignal() {
|
|
return {
|
|
selector: `test-element-${++selectorUid}`,
|
|
ElementCtor: createCustomElement(TestSignalComponent, {injector}),
|
|
};
|
|
}
|
|
|
|
@Component({
|
|
selector: 'test-component',
|
|
template: 'TestComponent|foo({{ fooFoo }})|bar({{ barBar }})',
|
|
standalone: false,
|
|
})
|
|
class TestComponent {
|
|
@Input() fooFoo: string = 'foo';
|
|
@Input('barbar') barBar!: string;
|
|
@Input({transform: (value: unknown) => !!value}) fooTransformed!: boolean;
|
|
|
|
// This needs to apply the decorator and pass `isSignal`, because
|
|
// the compiler transform doesn't run against JIT tests.
|
|
@Input({isSignal: true} as Input) fooSignal = input<string | null>(null);
|
|
|
|
@Output() bazBaz = new EventEmitter<boolean>();
|
|
@Output('quxqux') quxQux = new EventEmitter<Object>();
|
|
}
|
|
|
|
@Component({
|
|
selector: 'test-signal-component',
|
|
template: 'TestSignalComponent|foo({{ fooFoo() }})|signal({{ fooSignal() }})',
|
|
standalone: false,
|
|
})
|
|
class TestSignalComponent {
|
|
@Input() fooFoo = signal<string | null>(null);
|
|
// This needs to apply the decorator and pass `isSignal`, because
|
|
// the compiler transform doesn't run against JIT tests.
|
|
@Input({isSignal: true} as Input) fooSignal = input<string | null>(null);
|
|
}
|
|
@NgModule({
|
|
imports: [BrowserModule],
|
|
declarations: [TestComponent, TestSignalComponent],
|
|
})
|
|
class TestModule implements DoBootstrap {
|
|
ngDoBootstrap() {}
|
|
}
|
|
|
|
class TestStrategy implements NgElementStrategy {
|
|
connectedElement: HTMLElement | null = null;
|
|
disconnectCalled = false;
|
|
inputs = new Map<string, any>();
|
|
|
|
events = new Subject<NgElementStrategyEvent>();
|
|
|
|
connect(element: HTMLElement): void {
|
|
this.events.next({name: 'strategy-event', value: 'connect'});
|
|
this.connectedElement = element;
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.disconnectCalled = true;
|
|
}
|
|
|
|
getInputValue(propName: string): any {
|
|
return this.inputs.get(propName);
|
|
}
|
|
|
|
setInputValue(propName: string, value: string, transform?: (value: any) => any): void {
|
|
this.inputs.set(propName, transform ? transform(value) : value);
|
|
}
|
|
|
|
reset(): void {
|
|
this.connectedElement = null;
|
|
this.disconnectCalled = false;
|
|
this.inputs.clear();
|
|
}
|
|
}
|
|
|
|
class TestStrategyFactory implements NgElementStrategyFactory {
|
|
testStrategy = new TestStrategy();
|
|
|
|
create(injector: Injector): NgElementStrategy {
|
|
// Although not used by the `TestStrategy`, verify that the injector is provided.
|
|
if (!injector) {
|
|
throw new Error(
|
|
'Expected injector to be passed to `TestStrategyFactory#create()`, but received ' +
|
|
`value of type ${typeof injector}: ${injector}`,
|
|
);
|
|
}
|
|
|
|
return this.testStrategy;
|
|
}
|
|
}
|
|
});
|