diff --git a/renderers/web_core/src/v0_9/reactivity/signals-testing.shared.ts b/renderers/web_core/src/v0_9/reactivity/signals-testing.shared.ts new file mode 100644 index 000000000..7d79005bd --- /dev/null +++ b/renderers/web_core/src/v0_9/reactivity/signals-testing.shared.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {FrameworkSignal} from './signals.js'; + +/** + * Shared verification tests for FrameworkSignal implementations. + */ +export function runFrameworkSignalTests(name: string, SignalImpl: FrameworkSignal) { + describe(`FrameworkSignal ${name}`, () => { + it('round trip wraps and unwraps successfully', () => { + const val = 'hello'; + const wrapped = SignalImpl.wrap(val); + assert.strictEqual(SignalImpl.unwrap(wrapped), val); + }); + + it('handles updates well', () => { + const signal = SignalImpl.wrap('first'); + const computedVal = SignalImpl.computed(() => `prefix ${SignalImpl.unwrap(signal)}`); + + assert.strictEqual(SignalImpl.unwrap(signal), 'first'); + assert.strictEqual(SignalImpl.unwrap(computedVal), 'prefix first'); + + SignalImpl.set(signal, 'second'); + + assert.strictEqual(SignalImpl.unwrap(signal), 'second'); + assert.strictEqual(SignalImpl.unwrap(computedVal), 'prefix second'); + }); + + describe('.isSignal()', () => { + it('validates a signal', () => { + const val = 'hello'; + const wrapped = SignalImpl.wrap(val); + assert.ok(SignalImpl.isSignal(wrapped)); + }); + + it('rejects a non-signal', () => { + assert.strictEqual(SignalImpl.isSignal('hello'), false); + }); + }); + }); +} diff --git a/renderers/web_core/src/v0_9/reactivity/signals.angular.test.ts b/renderers/web_core/src/v0_9/reactivity/signals.angular.test.ts new file mode 100644 index 000000000..b90a4439c --- /dev/null +++ b/renderers/web_core/src/v0_9/reactivity/signals.angular.test.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + signal, + computed, + isSignal, + effect, + Signal as NgSignal, + WritableSignal as NgWritableSignal, +} from '@angular/core'; + +import {FrameworkSignal} from './signals.js'; +import {runFrameworkSignalTests} from './signals-testing.shared.js'; + +declare module './signals.js' { + // Setup the appropriate types for Angular Signals + interface SignalKinds { + // @ts-ignore : Suppress cross-compilation interface overlap + readonly: NgSignal; + // @ts-ignore : Suppress cross-compilation interface overlap + writable: NgWritableSignal; + } +} + +// Test FrameworkSignal with Angular signals explicitly mapped over SignalKinds. +const AngularSignal = { + computed: (fn: () => T) => computed(fn), + isSignal: (val: unknown): val is NgSignal => isSignal(val), + wrap: (val: T) => signal(val), + unwrap: (val: NgSignal) => val(), + set: (sig: NgWritableSignal, value: T) => sig.set(value), + effect: (fn: () => void, cleanupCallback: () => void) => { + const e = effect(cleanupRegisterFn => { + cleanupRegisterFn(cleanupCallback); + fn(); + }); + return () => e.destroy(); + }, +} as unknown as FrameworkSignal; // Bypass Mono-compilation interface overlap +// The cast above is needed because tsc is merging all our test files together, +// and the SignalKinds interface is being declared multiple times, causing a +// type collision. Normally, the AngularSignal would `satisfies FrameworkSignal`, +// and the declaration of SignalKinds wouldn't need to suppress anything. + +runFrameworkSignalTests('Angular implementation', AngularSignal); diff --git a/renderers/web_core/src/v0_9/reactivity/signals.preact.test.ts b/renderers/web_core/src/v0_9/reactivity/signals.preact.test.ts new file mode 100644 index 000000000..b12eb5b50 --- /dev/null +++ b/renderers/web_core/src/v0_9/reactivity/signals.preact.test.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {computed, effect, Signal as PSignal} from '@preact/signals-core'; + +import {FrameworkSignal} from './signals.js'; +import {runFrameworkSignalTests} from './signals-testing.shared.js'; + +declare module './signals.js' { + interface SignalKinds { + // @ts-ignore : Suppress cross-compilation interface overlap + readonly: PSignal; + // @ts-ignore : Suppress cross-compilation interface overlap + writable: PSignal; + } +} + +// Test FrameworkSignal with Preact signals explicitly mapped over SignalKinds. +const PreactSignal = { + computed: (fn: () => T) => computed(fn), + isSignal: (val: unknown): val is PSignal => val instanceof PSignal, + wrap: (val: T) => new PSignal(val), + unwrap: (val: PSignal) => val.value, + set: (sig: PSignal, value: T) => (sig.value = value), + effect: (fn: () => void) => effect(fn), +} as unknown as FrameworkSignal; // Cast bypasses Mono-compilation interface overlap +// The cast above is needed because tsc is merging all our test files together, +// and the SignalKinds interface is being declared multiple times, causing a +// type collision. Normally, the AngularSignal would `satisfies FrameworkSignal`, +// and the declaration of SignalKinds wouldn't need to suppress anything. + +runFrameworkSignalTests('Preact implementation', PreactSignal); diff --git a/renderers/web_core/src/v0_9/reactivity/signals.test.ts b/renderers/web_core/src/v0_9/reactivity/signals.test.ts deleted file mode 100644 index 31bd52934..000000000 --- a/renderers/web_core/src/v0_9/reactivity/signals.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; -import { - Signal as PSignal, - computed as pComputed, - effect as pEffect, -} from '@preact/signals-core'; -import { - signal as aSignal, - computed as aComputed, - Signal as ASignal, - WritableSignal as AWritableSignal, - isSignal, - effect as aEffect, -} from '@angular/core'; - -import {FrameworkSignal} from './signals'; - -declare module './signals' { - interface SignalKinds { - angular: ASignal; - preact: PSignal; - } - interface WritableSignalKinds { - angular: AWritableSignal; - preact: PSignal; - } -} - -describe('FrameworkSignal', () => { - // Test FrameworkSignal with two sample implemenations that wrap Angular and - // Preact signals. Angular and Preact signals are good representitive samples, - // because the two common patterns - `()` vs. `.value` - are represented by - // Angular and Preact respectively. - - describe('Angular variation', () => { - const AngularSignal: FrameworkSignal<'angular'> = { - computed: (fn: () => T) => aComputed(fn), - isSignal: (val: unknown) => isSignal(val), - wrap: (val: T) => aSignal(val), - unwrap: (val: ASignal) => val(), - set: (signal: AWritableSignal, value: T) => signal.set(value), - effect: (fn: () => void, cleanupCallback: () => void) => { - const e = aEffect(cleanupRegisterFn => { - cleanupRegisterFn(cleanupCallback); - fn(); - }); - return () => e.destroy(); - }, - }; - - it('round trip wraps and unwraps successfully', () => { - const val = 'hello'; - const wrapped = AngularSignal.wrap(val); - assert.strictEqual(AngularSignal.unwrap(wrapped), val); - }); - - it('handles updates well', () => { - const signal = AngularSignal.wrap('first'); - const computedVal = AngularSignal.computed(() => `prefix ${signal()}`); - - assert.strictEqual(signal(), 'first'); - assert.strictEqual(AngularSignal.unwrap(signal), 'first'); - assert.strictEqual(computedVal(), 'prefix first'); - assert.strictEqual(AngularSignal.unwrap(computedVal), 'prefix first'); - - AngularSignal.set(signal, 'second'); - - assert.strictEqual(signal(), 'second'); - assert.strictEqual(AngularSignal.unwrap(signal), 'second'); - assert.strictEqual(computedVal(), 'prefix second'); - assert.strictEqual(AngularSignal.unwrap(computedVal), 'prefix second'); - }); - - describe('.isSignal()', () => { - it('validates a signal', () => { - const val = 'hello'; - const wrapped = AngularSignal.wrap(val); - assert.ok(AngularSignal.isSignal(wrapped)); - }); - - it('rejects a non-signal', () => { - assert.strictEqual(AngularSignal.isSignal('hello'), false); - }); - }); - }); - - describe('Preact variation', () => { - const PreactSignal: FrameworkSignal<'preact'> = { - computed: (fn: () => T) => pComputed(fn), - isSignal: (val: unknown) => val instanceof PSignal, - wrap: (val: T) => new PSignal(val), - unwrap: (val: PSignal) => val.value, - set: (signal: PSignal, value: T) => (signal.value = value), - effect: (fn: () => void) => pEffect(fn), - }; - - it('round trip wraps and unwraps successfully', () => { - const val = 'hello'; - const wrapped = PreactSignal.wrap(val); - assert.strictEqual(PreactSignal.unwrap(wrapped), val); - }); - - it('handles updates well', () => { - const signal = PreactSignal.wrap('first'); - const computed = PreactSignal.computed(() => `prefix ${signal.value}`); - - assert.strictEqual(signal.value, 'first'); - assert.strictEqual(PreactSignal.unwrap(signal), 'first'); - assert.strictEqual(computed.value, 'prefix first'); - assert.strictEqual(PreactSignal.unwrap(computed), 'prefix first'); - - PreactSignal.set(signal, 'second'); - - assert.strictEqual(signal.value, 'second'); - assert.strictEqual(PreactSignal.unwrap(signal), 'second'); - assert.strictEqual(computed.value, 'prefix second'); - assert.strictEqual(PreactSignal.unwrap(computed), 'prefix second'); - }); - - describe('.isSignal()', () => { - it('validates a signal', () => { - const val = 'hello'; - const wrapped = PreactSignal.wrap(val); - assert.ok(PreactSignal.isSignal(wrapped)); - }); - - it('rejects a non-signal', () => { - assert.strictEqual(PreactSignal.isSignal('hello'), false); - }); - }); - }); -}); diff --git a/renderers/web_core/src/v0_9/reactivity/signals.ts b/renderers/web_core/src/v0_9/reactivity/signals.ts index 3c31578e2..a3d205d60 100644 --- a/renderers/web_core/src/v0_9/reactivity/signals.ts +++ b/renderers/web_core/src/v0_9/reactivity/signals.ts @@ -14,37 +14,45 @@ * limitations under the License. */ -// SignalKinds and WritableSignalKinds are declared in such a way that +// SignalKinds is declared in such a way that // downstream library impls can dynamically provide their Signal implementations // in a type-safe way. Usage downstream might look something like: // // declare module '../reactivity/signals' { // interface SignalKinds { -// preact: Signal; -// } -// interface WritableSignalKinds { -// preact: Signal; +// readonly: Signal; +// writable: Signal; // } // } // // This , while unused, is required to pass through to a given Signal impl. // eslint-disable-next-line @typescript-eslint/no-unused-vars -export interface SignalKinds {} +export interface SignalKinds { + _phantom?: T; +} -// This , while unused, is required to pass through to a given Signal impl. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export interface WritableSignalKinds {} +/** + * A generic representation of a read-only Signal. + * Resolves to the specific framework's signal type if augmented. + */ +export type Signal = SignalKinds extends { readonly: infer R } ? R : unknown; + +/** + * A generic representation of a writable Signal. + * Resolves to the specific framework's signal type if augmented. + */ +export type WritableSignal = SignalKinds extends { writable: infer W } ? W : unknown; /** * A generic representation of a Signal that could come from any framework. * For any library building on top of A2UI's web core lib, this must be * implemented for their associated signals implementation. */ -export interface FrameworkSignal> { +export interface FrameworkSignal { /** * Create a computed signal for this framework. */ - computed(fn: () => T): SignalKinds[K]; + computed(fn: () => T): Signal; /** * Run a reactive effect. @@ -54,20 +62,20 @@ export interface FrameworkSignal> { /** * Check if an arbitrary object is a framework signal. */ - isSignal(val: unknown): val is SignalKinds[K]; + isSignal(val: unknown): val is Signal; /** * Wrap the value in a signal. */ - wrap(val: T): WritableSignalKinds[K]; + wrap(val: T): WritableSignal; /** * Extract the value from a signal. */ - unwrap(val: SignalKinds[K]): T; + unwrap(val: Signal): T; /** * Sets the value of the provided framework signal. */ - set(signal: WritableSignalKinds[K], value: T): void; + set(signal: WritableSignal, value: T): void; }