From 4c6329ee876d87b59d387445a129dfe01c8be87d Mon Sep 17 00:00:00 2001 From: Ava Mattie <6314286+ava-cassiopeia@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:27:23 +0000 Subject: [PATCH 1/4] Add basic concept of a generic signal --- .../web_core/src/v0_9/catalog/signals.test.ts | 53 +++++++++++++++++++ .../web_core/src/v0_9/catalog/signals.ts | 31 +++++++++++ 2 files changed, 84 insertions(+) create mode 100644 renderers/web_core/src/v0_9/catalog/signals.test.ts create mode 100644 renderers/web_core/src/v0_9/catalog/signals.ts diff --git a/renderers/web_core/src/v0_9/catalog/signals.test.ts b/renderers/web_core/src/v0_9/catalog/signals.test.ts new file mode 100644 index 000000000..507f18910 --- /dev/null +++ b/renderers/web_core/src/v0_9/catalog/signals.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {Signal, computed} from '@preact/signals-core'; + +import {FrameworkSignal} from './signals'; + +describe('FrameworkSignal', () => { + describe('Preact variation', () => { + // Sample Preact impl. + const PreactSignal: FrameworkSignal = { + computed: (fn: () => T) => computed(fn), + isSignal: (val: unknown) => val instanceof Signal, + wrap: (val: T) => new Signal(val), + unwrap: (val: Signal) => val.value, + set: (signal: Signal, value: T) => (signal.value = value), + }; + + 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/catalog/signals.ts b/renderers/web_core/src/v0_9/catalog/signals.ts new file mode 100644 index 000000000..725bfc42d --- /dev/null +++ b/renderers/web_core/src/v0_9/catalog/signals.ts @@ -0,0 +1,31 @@ +/** + * 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 { + /** + * Create a computed signal for this framework. + */ + computed(fn: () => T): SignalType; + + /** + * Check if an arbitrary object is a framework signal. + */ + isSignal(val: unknown): val is SignalType; + + /** + * Wrap the value in a signal. + */ + wrap(val: T): SignalType; + + /** + * Extract the value from a signal. + */ + unwrap(val: SignalType): T; + + /** + * Sets the value of the provided framework signal. + */ + set(signal: SignalType, value: T): void; +} From f0f19755edb9a1d4f25a19a4aae3aa9a91e55fdc Mon Sep 17 00:00:00 2001 From: Ava Mattie <6314286+ava-cassiopeia@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:02:02 +0000 Subject: [PATCH 2/4] add test coverage --- renderers/web_core/package-lock.json | 62 +++++++++++++--- renderers/web_core/package.json | 2 + .../web_core/src/v0_9/catalog/signals.test.ts | 72 ++++++++++++++++--- renderers/web_core/tsconfig.json | 9 ++- 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/renderers/web_core/package-lock.json b/renderers/web_core/package-lock.json index 28b79d4b8..d327b95ad 100644 --- a/renderers/web_core/package-lock.json +++ b/renderers/web_core/package-lock.json @@ -15,13 +15,40 @@ "zod-to-json-schema": "^3.25.1" }, "devDependencies": { + "@angular/core": "^21.2.5", "@types/node": "^24.11.0", "c8": "^11.0.0", "gts": "^7.0.0", + "rxjs": "^7.8.2", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" } }, + "node_modules/@angular/core": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.5.tgz", + "integrity": "sha512-JgHU134Adb1wrpyGC9ozcv3hiRAgaFTvJFn1u9OU/AVXyxu4meMmVh2hp5QhAvPnv8XQdKWWIkAY+dbpPE6zKA==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.5", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1931,6 +1958,24 @@ "node": ">=8.0.0" } }, + "node_modules/inquirer/node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/inquirer/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2960,15 +3005,12 @@ } }, "node_modules/rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" + "tslib": "^2.1.0" } }, "node_modules/safer-buffer": { @@ -3304,9 +3346,9 @@ } }, "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, "node_modules/type-check": { diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 0a9785aeb..4feabd5c4 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -90,9 +90,11 @@ "author": "Google", "license": "Apache-2.0", "devDependencies": { + "@angular/core": "^21.2.5", "@types/node": "^24.11.0", "c8": "^11.0.0", "gts": "^7.0.0", + "rxjs": "^7.8.2", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" }, diff --git a/renderers/web_core/src/v0_9/catalog/signals.test.ts b/renderers/web_core/src/v0_9/catalog/signals.test.ts index 507f18910..70b5c3c59 100644 --- a/renderers/web_core/src/v0_9/catalog/signals.test.ts +++ b/renderers/web_core/src/v0_9/catalog/signals.test.ts @@ -1,18 +1,74 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {Signal, computed} from '@preact/signals-core'; +import {Signal as PSignal, computed as pComputed} from '@preact/signals-core'; +import { + signal as aSignal, + computed as aComputed, + Signal as ASignal, + WritableSignal as AWritableSignal, + isSignal, +} from '@angular/core'; import {FrameworkSignal} from './signals'; 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> = { + 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), + }; + + 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', () => { - // Sample Preact impl. - const PreactSignal: FrameworkSignal = { - computed: (fn: () => T) => computed(fn), - isSignal: (val: unknown) => val instanceof Signal, - wrap: (val: T) => new Signal(val), - unwrap: (val: Signal) => val.value, - set: (signal: Signal, value: T) => (signal.value = value), + const PreactSignal: FrameworkSignal = { + 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), }; it('round trip wraps and unwraps successfully', () => { diff --git a/renderers/web_core/tsconfig.json b/renderers/web_core/tsconfig.json index 806f6b05f..bf3d12fb7 100644 --- a/renderers/web_core/tsconfig.json +++ b/renderers/web_core/tsconfig.json @@ -28,7 +28,12 @@ "strict": true, "noUnusedLocals": false, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "rxjs/operators": ["./node_modules/rxjs/operators/index.js"] + } }, - "include": ["src/**/*.ts", "src/**/*.json"] + "include": ["src/**/*.ts", "src/**/*.json"], } From fd3187467372a1f679547e64833cf3803ee35d45 Mon Sep 17 00:00:00 2001 From: Ava Mattie <6314286+ava-cassiopeia@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:04:10 +0000 Subject: [PATCH 3/4] add license headers --- .../web_core/src/v0_9/catalog/signals.test.ts | 16 ++++++++++++++++ renderers/web_core/src/v0_9/catalog/signals.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/renderers/web_core/src/v0_9/catalog/signals.test.ts b/renderers/web_core/src/v0_9/catalog/signals.test.ts index 70b5c3c59..13b43d844 100644 --- a/renderers/web_core/src/v0_9/catalog/signals.test.ts +++ b/renderers/web_core/src/v0_9/catalog/signals.test.ts @@ -1,3 +1,19 @@ +/** + * 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} from '@preact/signals-core'; diff --git a/renderers/web_core/src/v0_9/catalog/signals.ts b/renderers/web_core/src/v0_9/catalog/signals.ts index 725bfc42d..7c8b0cd70 100644 --- a/renderers/web_core/src/v0_9/catalog/signals.ts +++ b/renderers/web_core/src/v0_9/catalog/signals.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + /** * 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 From 1794cc445ac59c2e64c1230ce84b78023948df6f Mon Sep 17 00:00:00 2001 From: Ava Mattie <6314286+ava-cassiopeia@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:29:51 +0000 Subject: [PATCH 4/4] Address review comments --- .../src/v0_9/{catalog => reactivity}/signals.test.ts | 2 +- .../web_core/src/v0_9/{catalog => reactivity}/signals.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename renderers/web_core/src/v0_9/{catalog => reactivity}/signals.test.ts (98%) rename renderers/web_core/src/v0_9/{catalog => reactivity}/signals.ts (87%) diff --git a/renderers/web_core/src/v0_9/catalog/signals.test.ts b/renderers/web_core/src/v0_9/reactivity/signals.test.ts similarity index 98% rename from renderers/web_core/src/v0_9/catalog/signals.test.ts rename to renderers/web_core/src/v0_9/reactivity/signals.test.ts index 13b43d844..6f02f4b17 100644 --- a/renderers/web_core/src/v0_9/catalog/signals.test.ts +++ b/renderers/web_core/src/v0_9/reactivity/signals.test.ts @@ -34,7 +34,7 @@ describe('FrameworkSignal', () => { // Angular and Preact respectively. describe('Angular variation', () => { - const AngularSignal: FrameworkSignal> = { + const AngularSignal: FrameworkSignal, AWritableSignal> = { computed: (fn: () => T) => aComputed(fn), isSignal: (val: unknown) => isSignal(val), wrap: (val: T) => aSignal(val), diff --git a/renderers/web_core/src/v0_9/catalog/signals.ts b/renderers/web_core/src/v0_9/reactivity/signals.ts similarity index 87% rename from renderers/web_core/src/v0_9/catalog/signals.ts rename to renderers/web_core/src/v0_9/reactivity/signals.ts index 7c8b0cd70..c636a5066 100644 --- a/renderers/web_core/src/v0_9/catalog/signals.ts +++ b/renderers/web_core/src/v0_9/reactivity/signals.ts @@ -19,7 +19,7 @@ * 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. */ @@ -33,7 +33,7 @@ export interface FrameworkSignal { /** * Wrap the value in a signal. */ - wrap(val: T): SignalType; + wrap(val: T): WriteableSignalType; /** * Extract the value from a signal. @@ -43,5 +43,5 @@ export interface FrameworkSignal { /** * Sets the value of the provided framework signal. */ - set(signal: SignalType, value: T): void; + set(signal: WriteableSignalType, value: T): void; }