From 9df7463352a83ef265367657a100f2cd02178b40 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Tue, 3 Sep 2024 21:09:24 -0700 Subject: [PATCH] Removes `live`, `liveAttr`, `bind`, and `bindAttr` from `ComponentRef`. These functions have been moved to `hydroactive/signal-accessors.js` as loose functions. --- src/component-ref.test.ts | 1281 ------------------------------------- src/component-ref.ts | 319 +-------- 2 files changed, 1 insertion(+), 1599 deletions(-) diff --git a/src/component-ref.test.ts b/src/component-ref.test.ts index f13ab75..694867c 100644 --- a/src/component-ref.test.ts +++ b/src/component-ref.test.ts @@ -276,1287 +276,6 @@ describe('component-ref', () => { }); }); - describe('live', () => { - it('returns an initialized signal', () => { - const el = parseHtml(NoopComponent, ` - Hello! - `); - const ref = el.getComponentRef(); - - const text = ref.live(ref.host, String); - expect(text()).toBe('Hello!'); - }); - - it('binds to the provided DOM element', async () => { - const el = parseHtml(NoopComponent, ` - - test - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const text = ref.live(ref.host.query('span'), String); - expect(text()).toBe('test'); - - text.set('test2'); - await el.stable(); - expect(el.querySelector('span')!.textContent!).toBe('test2'); - }); - - it('binds to the element returned by the provided selector query', async () => { - const el = parseHtml(NoopComponent, ` - - test - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const text = ref.live('span', String); - expect(text()).toBe('test'); - - text.set('test2'); - await el.stable(); - expect(el.querySelector('span')!.textContent!).toBe('test2'); - }); - - it('processes the DOM element based on the provided primitive serializer token', async () => { - { - const el = parseHtml(NoopComponent, ` - test1 - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.live(ref.host, String); - expect(value()).toBe('test1'); - - value.set('test2'); - await el.stable(); - expect(el.textContent!).toBe('test2'); - } - - { - const el = parseHtml(NoopComponent, ` - 1234 - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.live(ref.host, Number); - expect(value()).toBe(1234); - - value.set(4321); - await el.stable(); - expect(el.textContent!).toBe('4321'); - } - - { - const el = parseHtml(NoopComponent, ` - true - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.live(ref.host, Boolean); - expect(value()).toBe(true); - - value.set(false); - await el.stable(); - expect(el.textContent!).toBe('false'); - } - - { - const el = parseHtml(NoopComponent, ` - 1234 - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.live(ref.host, BigInt); - expect(value()).toBe(1234n); - - value.set(4321n); - await el.stable(); - expect(el.textContent!).toBe('4321'); - } - }); - - it('processes the DOM element based on the provided custom serializer', async () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const serializer: ElementSerializer = { - serializeTo(value: string, element: Element): void { - element.textContent = `serialized: ${value}`; - }, - - deserializeFrom(): string { - return 'deserialized'; - }, - }; - - const value = ref.live(ref.host, serializer); - expect(value()).toBe('deserialized'); - - value.set('test'); - await el.stable(); - expect(el.textContent!).toBe('serialized: test'); - }); - - it('processes the DOM element based on the provided serializable', async () => { - class User { - public constructor(private name: string) {} - public static [toSerializer](): ElementSerializer { - return { - serializeTo(user: User, element: Element): void { - element.textContent = user.name; - }, - - deserializeFrom(element: Element): User { - return new User(element.textContent!); - } - }; - } - } - - const el = parseHtml(NoopComponent, ` - Devel - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.live(ref.host, User); - expect(value()).toEqual(new User('Devel')); - - value.set(new User('Devel without a Cause')); - await el.stable(); - expect(el.textContent!).toBe('Devel without a Cause'); - }); - - it('throws an error when binding to the same element multiple times', () => { - const el = parseHtml(NoopComponent, ` - - - - `); - const ref = el.getComponentRef(); - - ref.live('span', String); - expect(() => ref.live('#my-span', String)) - .toThrowError(/cannot bind it again/); - }); - - it('throws an error when binding to the same element multiple times from different components', () => { - const outerEl = parseHtml(NoopComponent, ` - - - - `); - const outerRef = outerEl.getComponentRef(); - - const innerEl = outerRef.host.query('noop-component').native; - const innerRef = innerEl.getComponentRef(); - - outerRef.live('noop-component', String); - expect(() => innerRef.live(innerRef.host, String)) - .toThrowError(/cannot bind it again/); - }); - - it('infers the return type based on the serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - // Primitive serializer tokens. - const _signal1: WriteableSignal = ref.live(ref.host, String); - const _signal2: WriteableSignal = ref.live(ref.host, Number); - const _signal3: WriteableSignal = - ref.live(ref.host, Boolean); - const _signal4: WriteableSignal = ref.live(ref.host, BigInt); - - // Custom `ElementSerializer` type. - const serializer = {} as ElementSerializer; - const _signal5: WriteableSignal = - ref.live(ref.host, serializer); - - // Custom `ElementSerializable` type. - const serializable = {} as ElementSerializable; - const _signal6: WriteableSignal = - ref.live(ref.host, serializable); - }; - }); - - it('resolves serializer type based on element type', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - const div = {} as ElementRef; - - const divSerializer = {} as ElementSerializer; - ref.live(div, divSerializer); - ref.live('div', divSerializer); - - const inputSerializer = - {} as ElementSerializable; - // @ts-expect-error - ref.live(div, inputSerializer); - // @ts-expect-error - ref.live('div', inputSerializer); - - const elSerializer = {} as ElementSerializer; - ref.live('.foo', elSerializer); - // @ts-expect-error - ref.live('.foo', divSerializer); - }; - }); - - it('throws a compile time error when given an attribute serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - const serializer = {} as AttrSerializer; - // @ts-expect-error - ref.live(ref.host, serializer); - - const serializable = {} as AttrSerializable; - // @ts-expect-error - ref.live(ref.host, serializable); - }; - }); - }); - - describe('liveAttr', () => { - it('returns an initialized signal', () => { - const el = parseHtml(NoopComponent, ` - - `); - const ref = el.getComponentRef(); - - const text = ref.liveAttr(ref.host, 'value', String); - expect(text()).toBe('Hello!'); - }); - - it('binds to the provided DOM element', async () => { - const el = parseHtml(NoopComponent, ` - - - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const text = ref.liveAttr(ref.host.query('span'), 'value', String); - expect(text()).toBe('test'); - - text.set('test2'); - await el.stable(); - expect(el.querySelector('span')!.getAttribute('value')).toBe('test2'); - }); - - it('binds to the element returned by the provided selector query', async () => { - const el = parseHtml(NoopComponent, ` - - - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const text = ref.liveAttr('span', 'value', String); - expect(text()).toBe('test'); - - text.set('test2'); - await el.stable(); - expect(el.querySelector('span')!.getAttribute('value')).toBe('test2'); - }); - - it('processes the DOM element based on the provided primitive serializer token', async () => { - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.liveAttr(ref.host, 'value', String); - expect(value()).toBe('test1'); - - value.set('test2'); - await el.stable(); - expect(el.getAttribute('value')).toBe('test2'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.liveAttr(ref.host, 'value', Number); - expect(value()).toBe(1234); - - value.set(4321); - await el.stable(); - expect(el.getAttribute('value')).toBe('4321'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.liveAttr(ref.host, 'value', Boolean); - expect(value()).toBe(true); - - value.set(false); - await el.stable(); - expect(el.getAttribute('value')).toBe('false'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.liveAttr(ref.host, 'value', BigInt); - expect(value()).toBe(1234n); - - value.set(4321n); - await el.stable(); - expect(el.getAttribute('value')).toBe('4321'); - } - }); - - it('processes the DOM element based on the provided custom serializer', async () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const serializer: AttrSerializer = { - serialize(value: string): string { - return `serialized: ${value}`; - }, - - deserialize(): string { - return 'deserialized'; - }, - }; - - const value = ref.liveAttr(ref.host, 'value', serializer); - expect(value()).toBe('deserialized'); - - value.set('test'); - await el.stable(); - expect(el.getAttribute('value')).toBe('serialized: test'); - }); - - it('processes the DOM element based on the provided serializable', async () => { - class User { - public constructor(private name: string) {} - public static [toSerializer](): AttrSerializer { - return { - serialize(user: User): string { - return user.name; - }, - - deserialize(name: string): User { - return new User(name); - } - }; - } - } - - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = ref.liveAttr(ref.host, 'user', User); - expect(value()).toEqual(new User('Devel')); - - value.set(new User('Devel without a Cause')); - await el.stable(); - expect(el.getAttribute('user')).toBe('Devel without a Cause'); - }); - - it('throws an error when binding to the same element attribute multiple times', () => { - const el = parseHtml(NoopComponent, ` - - - - `); - const ref = el.getComponentRef(); - - ref.liveAttr('span', 'value', String); - expect(() => ref.liveAttr('#my-span', 'value', String)) - .toThrowError(/cannot bind it again/); - }); - - it('throws an error when binding to the same element attribute multiple times from different components', () => { - const outerEl = parseHtml(NoopComponent, ` - - - - `); - const outerRef = outerEl.getComponentRef(); - - const innerEl = outerRef.host.query('noop-component').native; - const innerRef = innerEl.getComponentRef(); - - outerRef.liveAttr('noop-component', 'value', String); - expect(() => innerRef.liveAttr(innerRef.host, 'value', String)) - .toThrowError(/cannot bind it again/); - }); - - it('infers the return type based on the serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - // Primitive serializer tokens. - const _signal1: WriteableSignal = - ref.liveAttr(ref.host, 'value', String); - const _signal2: WriteableSignal = - ref.liveAttr(ref.host, 'value', Number); - const _signal3: WriteableSignal = - ref.liveAttr(ref.host, 'value', Boolean); - const _signal4: WriteableSignal = - ref.liveAttr(ref.host, 'value', BigInt); - - // Custom `AttrSerializer` type. - const serializer = {} as AttrSerializer; - const _signal5: WriteableSignal = - ref.liveAttr(ref.host, 'value', serializer); - - // Custom `AttrSerializable` type. - const serializable = {} as AttrSerializable; - const _signal6: WriteableSignal = - ref.liveAttr(ref.host, 'value', serializable); - }; - }); - - it('throws a compile time error when given an element serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - const serializer = {} as ElementSerializer; - // @ts-expect-error - ref.liveAttr(ref.host, 'test', serializer); - - const serializable = {} as ElementSerializable; - // @ts-expect-error - ref.liveAttr(ref.host, 'test', serializable); - }; - }); - }); - - describe('bind', () => { - it('updates the provided element\'s text content reactively', async () => { - const el = document.createElement('noop-component'); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - expect(el.textContent).toBe(''); - - const value = signal('1'); - ref.bind(ref.host, () => value(), String); - await el.stable(); - expect(el.textContent).toBe('1'); - - value.set('2'); - await el.stable(); - expect(el.textContent).toBe('2'); - }); - - it('does not invoke the signal until connected', async () => { - const el = document.createElement('noop-component'); - const ref = el.getComponentRef(); - const sig = jasmine.createSpy<() => string>('sig') - .and.returnValue('test'); - - ref.bind(ref.host, sig, String); - await el.stable(); - expect(sig).not.toHaveBeenCalled(); - - document.body.appendChild(el); - - await el.stable(); - expect(sig).toHaveBeenCalledOnceWith(); - }); - - it('pauses updates while disconnected', async () => { - const el = document.createElement('noop-component'); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = signal('1'); - const sig = jasmine.createSpy<() => string>('sig') - .and.callFake(() => value()); - - ref.bind(ref.host, sig, String); - await el.stable(); - expect(sig).toHaveBeenCalledOnceWith(); - expect(el.textContent).toBe('1'); - sig.calls.reset(); - - el.remove(); - - value.set('2'); - await el.stable(); - expect(sig).not.toHaveBeenCalled(); - expect(el.textContent).toBe('1'); // Does not update. - }); - - it('updates the explicitly provided element', async () => { - const el = parseHtml(NoopComponent, ` - - - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host.query('span'), () => 'test'); - await el.stable(); - expect(el.querySelector('span')!.textContent!).toBe('test'); - }); - - it('queries for the given selector and updates that element', async () => { - const el = parseHtml(NoopComponent, ` - - - ` - ); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind('span', () => 'test', String); - await el.stable(); - expect(el.querySelector('span')!.textContent!).toBe('test'); - }); - - it('throws when the given selector is not found', () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - expect(() => ref.bind('span', () => 'test', String)).toThrow(); - }); - - it('serializes with an implicit primitive serializer', async () => { - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 'test'); - await el.stable(); - expect(ref.host.native.textContent!).toBe('test'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 1234); - await el.stable(); - expect(ref.host.native.textContent!).toBe('1234'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => true); - await el.stable(); - expect(ref.host.native.textContent!).toBe('true'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 1234n); - await el.stable(); - expect(ref.host.native.textContent!).toBe('1234'); - } - }); - - it('serializes with an explicit primitive serializer', async () => { - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 'test', String); - await el.stable(); - expect(ref.host.native.textContent!).toBe('test'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 1234, Number); - await el.stable(); - expect(ref.host.native.textContent!).toBe('1234'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => true, Boolean); - await el.stable(); - expect(ref.host.native.textContent!).toBe('true'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => 1234n, BigInt); - await el.stable(); - expect(ref.host.native.textContent!).toBe('1234'); - } - }); - - it('serializes with a custom `Serializer`', async () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const serializer: ElementSerializer = { - serializeTo(_value: undefined, element: Element): void { - element.textContent = 'undefined'; - }, - - deserializeFrom(): undefined { - return undefined; - }, - }; - - ref.bind(ref.host, () => undefined, serializer); - await el.stable(); - expect(ref.host.native.textContent!).toBe('undefined'); - }); - - it('serializes with a custom `Serializable`', async () => { - class User { - public constructor(private name: string) {} - - public static [toSerializer](): ElementSerializer { - return { - serializeTo(user: User, element: Element): void { - element.textContent = user.name; - }, - - deserializeFrom(element: Element): User { - return new User(element.textContent!); - } - }; - } - } - - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bind(ref.host, () => new User('Devel'), User); - await el.stable(); - expect(ref.host.native.textContent!).toBe('Devel'); - }); - - it('throws an error when binding to the same element multiple times', () => { - const el = parseHtml(NoopComponent, ` - - - - `); - const ref = el.getComponentRef(); - - ref.bind('span', () => 'test1'); - expect(() => ref.bind('#my-span', () => 'test2')) - .toThrowError(/cannot bind it again/); - }); - - it('throws an error when binding to the same element multiple times from different components', () => { - const outerEl = parseHtml(NoopComponent, ` - - - - `); - const outerRef = outerEl.getComponentRef(); - - const innerEl = outerRef.host.query('noop-component').native; - const innerRef = innerEl.getComponentRef(); - - outerRef.bind('noop-component', () => 'test1'); - expect(() => innerRef.bind(innerRef.host, () => 'test2')) - .toThrowError(/cannot bind it again/); - }); - - it('restricts the signal result and serializer to be the same type', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - // Correct implicit primitive types. - ref.bind(ref.host, () => 'test'); - ref.bind(ref.host, () => 1234); - ref.bind(ref.host, () => true); - ref.bind(ref.host, () => 1234n); - - // Incorrect implicit types. - // @ts-expect-error - ref.bind(ref.host, () => ({})); - // @ts-expect-error - ref.bind(ref.host, () => []); - // @ts-expect-error - ref.bind(ref.host, () => undefined); - // @ts-expect-error - ref.bind(ref.host, () => null); - - // Incorrect types with explicitly `undefined` serializer. - // @ts-expect-error - ref.bind(ref.host, () => ({}), undefined); - // @ts-expect-error - ref.bind(ref.host, () => [], undefined); - // @ts-expect-error - ref.bind(ref.host, () => undefined, undefined); - // @ts-expect-error - ref.bind(ref.host, () => null, undefined); - - // Incorrect types with possibly `undefined` serializer. - const maybeSerializer = {} as AttrSerializer<{}> | undefined; - // @ts-expect-error - ref.bind(ref.host, () => ({}), maybeSerializer); - // @ts-expect-error - ref.bind(ref.host, () => [], maybeSerializer); - // @ts-expect-error - ref.bind(ref.host, () => undefined, maybeSerializer); - // @ts-expect-error - ref.bind(ref.host, () => null, maybeSerializer); - - // Correct explicit primitive types. - ref.bind(ref.host, () => 'test', String); - ref.bind(ref.host, () => 1234, Number); - ref.bind(ref.host, () => true, Boolean); - ref.bind(ref.host, () => 1234n, BigInt); - - // Incorrect explicit primitive types. - // @ts-expect-error - ref.bind(ref.host, () => 'test', Number); - // @ts-expect-error - ref.bind(ref.host, () => 1234, String); - // @ts-expect-error - ref.bind(ref.host, () => true, String); - // @ts-expect-error - ref.bind(ref.host, () => 1234n, String); - - // Correct explicit serializer types. - const serializer = {} as ElementSerializer; - ref.bind(ref.host, () => 'test', serializer); - - // Incorrect explicit serializer types. - // @ts-expect-error - ref.bind(ref.host, () => 1234, serializer); - - // Correct explicit serializable types. - const serializable = {} as ElementSerializable; - ref.bind(ref.host, () => 'test', serializable); - - // Incorrect explicit serializable types. - // @ts-expect-error - ref.bind(ref.host, () => 1234, serializable); - }; - }); - - it('resolves serializer type based on element type', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - const div = {} as ElementRef; - - const divSerializer = {} as ElementSerializer; - ref.bind(div, () => 1234, divSerializer); - ref.bind('div', () => 1234, divSerializer); - - const inputSerializer = - {} as ElementSerializable; - // @ts-expect-error - ref.bind(div, () => 1234, inputSerializer); - // @ts-expect-error - ref.bind('div', () => 1234, inputSerializer); - - const elSerializer = {} as ElementSerializer; - ref.bind('.foo', () => 1234, elSerializer); - // @ts-expect-error - ref.bind('.foo', () => 1234, divSerializer); - }; - }); - - it('throws a compile time error when given an attribute serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - const serializer = {} as AttrSerializer; - // @ts-expect-error - ref.bind(ref.host, () => 'test', serializer); - - const serializable = {} as AttrSerializable; - // @ts-expect-error - ref.bind(ref.host, () => 'test', serializable); - }; - }); - }); - - describe('bindAttr', () => { - it('updates the provided element\'s named attribute reactively', async () => { - const el = document.createElement('noop-component'); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - expect(el.textContent).toBe(''); - - const value = signal('1'); - ref.bindAttr(ref.host, 'count', () => value(), String); - await el.stable(); - expect(el.getAttribute('count')).toBe('1'); - - value.set('2'); - await el.stable(); - expect(el.getAttribute('count')).toBe('2'); - }); - - it('does not invoke the signal until connected', async () => { - const el = document.createElement('noop-component'); - const ref = el.getComponentRef(); - const sig = jasmine.createSpy<() => string>('sig') - .and.returnValue('test'); - - ref.bindAttr(ref.host, 'hello', sig, String); - await el.stable(); - expect(sig).not.toHaveBeenCalled(); - - document.body.appendChild(el); - - await el.stable(); - expect(sig).toHaveBeenCalledOnceWith(); - }); - - it('pauses updates while disconnected', async () => { - const el = document.createElement('noop-component'); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const value = signal('1'); - const sig = jasmine.createSpy<() => string>('sig') - .and.callFake(() => value()); - - ref.bindAttr(ref.host, 'count', sig, String); - await el.stable(); - expect(sig).toHaveBeenCalledOnceWith(); - expect(el.getAttribute('count')).toBe('1'); - sig.calls.reset(); - - el.remove(); - - value.set('2'); - await el.stable(); - expect(sig).not.toHaveBeenCalled(); - expect(el.getAttribute('count')).toBe('1'); // Does not update. - }); - - it('updates the explicitly provided element', async () => { - const el = parseHtml(NoopComponent, ` - - - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host.query('span'), 'name', () => 'test'); - await el.stable(); - expect(el.querySelector('span')!.getAttribute('name')).toBe('test'); - }); - - it('queries for the given selector and updates that element', async () => { - const el = parseHtml(NoopComponent, ` - - - ` - ); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr('span', 'name', () => 'test', String); - await el.stable(); - expect(el.querySelector('span')!.getAttribute('name')).toBe('test'); - }); - - it('throws when the given selector is not found', () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - expect(() => ref.bindAttr('span', 'name', () => 'test', String)) - .toThrow(); - }); - - it('serializes with an implicit primitive serializer', async () => { - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 'test'); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('test'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 1234); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('1234'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => true); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('true'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 1234n); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('1234'); - } - }); - - it('serializes with an explicit primitive serializer', async () => { - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 'test', String); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('test'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 1234, Number); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('1234'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => true, Boolean); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('true'); - } - - { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name', () => 1234n, BigInt); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('1234'); - } - }); - - it('serializes with a custom `Serializer`', async () => { - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - const serializer: AttrSerializer = { - serialize(): string { - return 'undefined'; - }, - - deserialize(): undefined { - return undefined; - }, - }; - - ref.bindAttr(ref.host, 'name', () => undefined, serializer); - await el.stable(); - expect(ref.host.native.getAttribute('name')).toBe('undefined'); - }); - - it('serializes with a custom `Serializable`', async () => { - class User { - public constructor(private name: string) {} - - public static [toSerializer](): AttrSerializer { - return { - serialize(user: User): string { - return user.name; - }, - - deserialize(name: string): User { - return new User(name); - } - }; - } - } - - const el = parseHtml(NoopComponent, ` - - `); - document.body.appendChild(el); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'user', () => new User('Devel'), User); - await el.stable(); - expect(ref.host.native.getAttribute('user')).toBe('Devel'); - }); - - it('throws an error when binding to the same element attribute multiple times', () => { - const el = parseHtml(NoopComponent, ` - - - - `); - const ref = el.getComponentRef(); - - ref.bindAttr('span', 'name', () => 'test1'); - expect(() => ref.bindAttr('#my-span', 'name', () => 'test2')) - .toThrowError(/cannot bind it again/); - }); - - it('throws an error when binding to the same element attribute multiple times from different components', () => { - const outerEl = parseHtml(NoopComponent, ` - - - - `); - const outerRef = outerEl.getComponentRef(); - - const innerEl = outerRef.host.query('noop-component').native; - const innerRef = innerEl.getComponentRef(); - - outerRef.bindAttr('noop-component', 'name', () => 'test1'); - expect(() => innerRef.bindAttr(innerRef.host, 'name', () => 'test2')) - .toThrowError(/cannot bind it again/); - }); - - it('allows binding to different attributes of the same element', () => { - const el = parseHtml(NoopComponent, ` - - `); - const ref = el.getComponentRef(); - - ref.bindAttr(ref.host, 'name1', () => 'test1'); - expect(() => ref.bindAttr(ref.host, 'name2', () => 'test2')) - .not.toThrow(); - }); - - it('allows binding to the same attribute of different elements', () => { - const el = parseHtml(NoopComponent, ` - - - - - `); - const ref = el.getComponentRef(); - - ref.bindAttr('#first', 'name', () => 'test1'); - expect(() => ref.bindAttr('#second', 'name', () => 'test2')) - .not.toThrow(); - }); - - it('restricts the signal result and serializer to be the same type', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - // Correct implicit primitive types. - ref.bindAttr(ref.host, 'data', () => 'test'); - ref.bindAttr(ref.host, 'data', () => 1234); - ref.bindAttr(ref.host, 'data', () => true); - ref.bindAttr(ref.host, 'data', () => 1234n); - - // Incorrect implicit types. - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => ({})); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => []); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => undefined); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => null); - - // Incorrect types with explicitly `undefined` serializer. - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => ({}), undefined); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => [], undefined); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => undefined, undefined); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => null, undefined); - - // Incorrect types with possibly `undefined` serializer. - const maybeSerializer = {} as AttrSerializer<{}> | undefined; - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => ({}), maybeSerializer); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => [], maybeSerializer); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => undefined, maybeSerializer); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => null, maybeSerializer); - - // Correct explicit primitive types. - ref.bindAttr(ref.host, 'data', () => 'test', String); - ref.bindAttr(ref.host, 'data', () => 1234, Number); - ref.bindAttr(ref.host, 'data', () => true, Boolean); - ref.bindAttr(ref.host, 'data', () => 1234n, BigInt); - - // Incorrect explicit primitive types. - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 'test', Number); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 1234, String); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => true, String); - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 1234n, String); - - // Correct explicit serializer types. - const serializer = {} as AttrSerializer; - ref.bindAttr(ref.host, 'data', () => 'test', serializer); - - // Incorrect explicit serializer types. - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 1234, serializer); - - // Correct explicit serializable types. - const serializable = {} as AttrSerializer; - ref.bindAttr(ref.host, 'data', () => 'test', serializable); - - // Incorrect explicit serializable types. - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 1234, serializable); - }; - }); - - it('throws a compile time error when given an element serializer', () => { - // Type-only test, only needs to compile, not execute. - expect().nothing(); - () => { - const ref = {} as ComponentRef; - - const serializer = {} as ElementSerializer; - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 'test', serializer); - - const serializable = {} as ElementSerializable; - // @ts-expect-error - ref.bindAttr(ref.host, 'data', () => 'test', serializable); - }; - }); - }); - describe('listen', () => { it('listens invokes the given callback when the specified event is triggered', () => { const el = document.createElement('noop-component'); diff --git a/src/component-ref.ts b/src/component-ref.ts index f288ea7..2ef361c 100644 --- a/src/component-ref.ts +++ b/src/component-ref.ts @@ -1,9 +1,6 @@ import { ElementRef } from './element-ref.js'; import { HydroActiveComponent } from './hydroactive-component.js'; -import { QueriedElement } from './query.js'; -import { type AttrSerializerToken, type ElementSerializerToken, type ResolveSerializer, type SerializerToken, resolveSerializer } from './serializer-tokens.js'; -import { type AttrSerializable, type AttrSerializer, type ElementSerializable, type ElementSerializer, type Serialized, bigintSerializer, booleanSerializer, numberSerializer, stringSerializer } from './serializers.js'; -import { type Signal, type WriteableSignal, effect, signal } from './signals.js'; +import { effect } from './signals.js'; import { UiScheduler } from './signals/schedulers/ui-scheduler.js'; /** @@ -15,12 +12,6 @@ export type OnConnect = () => OnDisconnect | void; /** The type of the function invoked on disconnect. */ export type OnDisconnect = () => void; -/** Elements whose text content is currently bound to a reactive signal. */ -const boundElements = new WeakSet(); - -/** Element attributes whose content is currently bound to a reactive signal. */ -const boundElementAttrs = new WeakMap>(); - /** * Provides an ergonomic API for accessing the internal content and lifecycle * of a HydroActive component. {@link ComponentRef} should be kept internal to @@ -129,284 +120,6 @@ export class ComponentRef { }); } - /** - * Creates a live binding to an element's text content. Returns a - * {@link WriteableSignal} initialized to the current state of the specified - * element in the DOM and bound such that all future writes to the signal are - * automatically propagated back to the DOM. - * - * * Uses the provided element directly, or queries the component for the - * given selector. - * * Reads and binds to the text content of the provided element. - * * Uses the provided serializer token to serialize to and deserialize from - * the DOM. - * - * MISCONCEPTION: This is *not* a two-way binding. It is a one-way binding - * with automatic initialization. If the bound element's text content is - * modified outside of this binding, that change will *not* be reflected - * automatically in the returned signal. - * - * @param elementOrSelector An {@link ElementRef} or a selector to look up in - * the component to get an element. Used to read and bind the return - * signal to. - * @param token A "token" which identifiers an {@link ElementSerializer} to - * serialize the `signal` result to an element. A token is one of: - * * A primitive serializer - {@link String}, {@link Boolean}, - * {@link Number}, {@link BigInt}. - * * An {@link ElementSerializer} object. - * * A {@link ElementSerializable} object. - * @returns A {@link WriteableSignal} initialized to the current text content - * of the specified element. When the signal is mutated, the value is - * automatically propagated back to the DOM. - */ - public live< - ElementOrSelector extends ElementRef | string, - Token extends ElementSerializerToken>, - >( - elementOrSelector: ElementOrSelector, - token: Token, - ): WriteableSignal>, - ElementSerializable> - >>> { - // Query for a selector if provided. - const element = elementOrSelector instanceof ElementRef - ? elementOrSelector as ElementRef> - : this.host.query(elementOrSelector); - - // Read the initial value from the DOM. - const initial = element.read(token as any); - - // Wrap the value in a reactive signal. - const value = signal(initial); - - // Bind the signal back to the DOM to reflect future changes. - this.bind(element as any, value, token); - - // Return a writeable version of the signal. - return value as any; - } - - - /** - * Creates a live binding to an element's attribute. Returns a - * {@link WriteableSignal} initialized to the current state of the specified - * element in the DOM and bound such that all future writes to the signal are - * automatically propagated back to the DOM. - * - * * Uses the provided element directly, or queries the component for the - * given selector. - * * Reads and binds to the named attribute of the provided element. - * * Uses the provided serializer token to serialize to and deserialize from - * the DOM. - * - * MISCONCEPTION: This is *not* a two-way binding. It is a one-way binding - * with automatic initialization. If the bound element's attribute is modified - * outside of this binding, that change will *not* be reflected automatically - * in the returned signal. - * - * Note that an attribute without a value such as `
` will - * return an empty string which is considered falsy. - * - * @param elementOrSelector An {@link ElementRef} or a selector to look up in - * the component to get an element. Used to read and bind the return - * signal to. - * @param name The name of the attribute to bind to. - * @param token A "token" which identifiers an {@link AttrSerializer} to - * serialize the `signal` result to a string. A token is one of: - * * A primitive serializer - {@link String}, {@link Boolean}, - * {@link Number}, {@link BigInt}. - * * An {@link AttrSerializer} object. - * * A {@link AttrSerializable} object. - * @returns A {@link WriteableSignal} initialized to the current value of the - * named attribute for the specified element. When the signal is mutated, - * the value is automatically propagated back to the DOM. - */ - public liveAttr>( - elementOrSelector: ElementRef | string, - name: string, - token: Token, - ): WriteableSignal, - AttrSerializable - >>> { - // Query for a selector if provided. - const element = elementOrSelector instanceof ElementRef - ? elementOrSelector - : this.host.query(elementOrSelector); - - // Read the initial value from the DOM. - const initial = element.attr(name, token); - - // Wrap the value in a reactive signal. - const value = signal(initial); - - // Bind the signal back to the DOM to reflect future changes. - this.bindAttr(element, name, value, token); - - // Return a writeable version of the signal. - return value; - } - - /** - * Invokes the given signal in a reactive context, serializes the result, and - * renders it to the provided element's text content. Automatically re-renders - * whenever a dependency of `signal` is modified. - * - * A default {@link ElementSerializer} is inferred from the return value of - * `signal` if no token is provided. - * - * @param elementOrSelector The element to render to or a selector of the - * element to render to. - * @param signal The signal to invoke in a reactive context. - * @param token A "token" which identifiers an {@link ElementSerializer} to - * serialize the `signal` result to an element. A token is one of: - * * A primitive serializer - {@link String}, {@link Boolean}, - * {@link Number}, {@link BigInt}. - * * An {@link ElementSerializer} object. - * * A {@link ElementSerializable} object. - */ - public bind< - Primitive extends string | number | boolean | bigint, - ElementOrSelector extends ElementRef | string, - >( - elementOrSelector: ElementOrSelector, - signal: Signal, - token?: ElementSerializerToken>, - ): void; - public bind | string>( - elementOrSelector: ElementOrSelector, - signal: Signal, - token: ElementSerializerToken>, - ): void; - public bind | string>( - elementOrSelector: ElementOrSelector, - signal: Signal, - token?: ElementSerializerToken>, - ): void { - this.#bindToDom( - elementOrSelector, - signal, - token, - /* boundCheck */ (element) => { - if (boundElements.has(element)) { - throw new Error(`Element is already bound to another signal, cannot bind it again.`); - } - boundElements.add(element); - }, - /* updateDom */ (element, serializer, value) => { - (serializer as ElementSerializer).serializeTo(value, element); - }, - ); - } - - /** - * Invokes the given signal in a reactive context, serializes the result, and - * renders it to the named attribute of the provided element. Automatically - * re-renders whenever a dependency of `signal` is modified. - * - * A default {@link AttrSerializer} is inferred from the return value of - * `signal` if no token is provided. - * - * @param elementOrSelector The element to render to or a selector of the - * element to render to. - * @param name The name of the attribute to bind to. - * @param signal The signal to invoke in a reactive context. - * @param token A "token" which identifiers an {@link AttrSerializer} to - * serialize the `signal` result to a string. A token is one of: - * * A primitive serializer - {@link String}, {@link Boolean}, - * {@link Number}, {@link BigInt}. - * * An {@link AttrSerializer} object. - * * A {@link AttrSerializable} object. - */ - public bindAttr( - elementOrSelector: ElementRef | string, - name: string, - signal: Signal, - token?: AttrSerializerToken, - ): void; - public bindAttr( - elementOrSelector: ElementRef | string, - name: string, - signal: Signal, - token: AttrSerializerToken, - ): void; - public bindAttr( - elementOrSelector: ElementRef | string, - name: string, - signal: Signal, - token?: AttrSerializerToken, - ): void { - this.#bindToDom( - elementOrSelector, - signal, - token, - /* boundCheck */ (element) => { - const boundAttrs = boundElementAttrs.get(element) ?? new Set(); - if (boundAttrs.has(name)) { - throw new Error(`Element attribute (${name}) is already bound to another signal, cannot bind it again.`); - } - boundAttrs.add(name); - boundElementAttrs.set(element, boundAttrs); - }, - /* updateDom */ (element, serializer, value) => { - const serial = (serializer as AttrSerializer).serialize(value); - element.setAttribute(name, serial); - }, - ); - } - - /** - * Creates an effect which invokes `updateDom` with the associated element and - * serialized value whenever the signal changes. - * - * Also calls `boundCheck` with the element immediately so the caller can - * determine whether or not a binding already exists. - */ - #bindToDom( - elementOrSelector: ElementRef | string, - signal: Signal, - token: SerializerToken | undefined, - boundCheck: (el: Element) => void, - updateDom: ( - el: Element, - serializer: ElementSerializer | AttrSerializer, - value: Value, - ) => void, - ): void { - // Query for a selector if provided. - const element = elementOrSelector instanceof ElementRef - ? elementOrSelector - : this.host.query(elementOrSelector); - - // Assert that the element is not already bound to another signal. - boundCheck(element.native); - - // Resolve an explicit serializer immediately, since that isn't dependent on - // the value and we don't want to do this for every invocation of effect. - const explicitSerializer = token - ? resolveSerializer(token) as - ElementSerializer | AttrSerializer - : undefined; - - this.effect(() => { - // Invoke the user-defined callback in a reactive context. - const value = signal(); - - // Infer a default serializer if necessary. - const serializer = explicitSerializer ?? inferSerializer(value); - if (!serializer) { - throw new Error(`No default serializer for type "${ - typeof value}". Either provide a primitive type (string, number, boolean, bigint) or provide an explicit serializer.`); - } - - // Update the DOM with the new value. - updateDom(element.native, serializer, value); - }); - } - /** * Creates an event listener for the given event which invokes the provided * handler callback function. This listener is automatically created and @@ -469,36 +182,6 @@ export class ComponentRef { } } -type PrimitiveSerializer = - ElementSerializer & AttrSerializer; - -/** - * Given the type of the provided value, returns a serializer which can - * serialize it or `undefined` if no serializer can. - */ -function inferSerializer(value: Value): - PrimitiveSerializer | undefined { - switch (typeof value) { - case 'string': return stringSerializer as any; - case 'number': return numberSerializer as any; - case 'boolean': return booleanSerializer as any; - case 'bigint': return bigintSerializer as any; - default: return undefined; - } -} - -/** - * Resolves an `ElementRef` or a selector string literal to the type of the - * referenced element. - */ -type ElementOf | string> = - ElementOrSelector extends ElementRef - ? El - : ElementOrSelector extends string - ? QueriedElement - : Element -; - // An attempt to capture all the event maps a user might reasonably encounter // in an element discovered by an element query inside a component. Almost // certainly not exhaustive.