Skip to content

Commit f3a23e4

Browse files
Add a definition for a generic signal to web core (#960)
* Add basic concept of a generic signal * add test coverage * add license headers * Address review comments
1 parent 2e8a176 commit f3a23e4

File tree

5 files changed

+233
-10
lines changed

5 files changed

+233
-10
lines changed

renderers/web_core/package-lock.json

Lines changed: 52 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

renderers/web_core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@
9090
"author": "Google",
9191
"license": "Apache-2.0",
9292
"devDependencies": {
93+
"@angular/core": "^21.2.5",
9394
"@types/node": "^24.11.0",
9495
"c8": "^11.0.0",
9596
"gts": "^7.0.0",
97+
"rxjs": "^7.8.2",
9698
"typescript": "^5.8.3",
9799
"wireit": "^0.15.0-pre.2"
98100
},
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import assert from 'node:assert';
18+
import {describe, it} from 'node:test';
19+
import {Signal as PSignal, computed as pComputed} from '@preact/signals-core';
20+
import {
21+
signal as aSignal,
22+
computed as aComputed,
23+
Signal as ASignal,
24+
WritableSignal as AWritableSignal,
25+
isSignal,
26+
} from '@angular/core';
27+
28+
import {FrameworkSignal} from './signals';
29+
30+
describe('FrameworkSignal', () => {
31+
// Test FrameworkSignal with two sample implemenations that wrap Angular and
32+
// Preact signals. Angular and Preact signals are good representitive samples,
33+
// because the two common patterns - `()` vs. `.value` - are represented by
34+
// Angular and Preact respectively.
35+
36+
describe('Angular variation', () => {
37+
const AngularSignal: FrameworkSignal<ASignal<any>, AWritableSignal<any>> = {
38+
computed: <T>(fn: () => T) => aComputed(fn),
39+
isSignal: (val: unknown) => isSignal(val),
40+
wrap: <T>(val: T) => aSignal(val),
41+
unwrap: <T>(val: ASignal<T>) => val(),
42+
set: <T>(signal: AWritableSignal<T>, value: T) => signal.set(value),
43+
};
44+
45+
it('round trip wraps and unwraps successfully', () => {
46+
const val = 'hello';
47+
const wrapped = AngularSignal.wrap(val);
48+
assert.strictEqual(AngularSignal.unwrap(wrapped), val);
49+
});
50+
51+
it('handles updates well', () => {
52+
const signal = AngularSignal.wrap('first');
53+
const computedVal = AngularSignal.computed(() => `prefix ${signal()}`);
54+
55+
assert.strictEqual(signal(), 'first');
56+
assert.strictEqual(AngularSignal.unwrap(signal), 'first');
57+
assert.strictEqual(computedVal(), 'prefix first');
58+
assert.strictEqual(AngularSignal.unwrap(computedVal), 'prefix first');
59+
60+
AngularSignal.set(signal, 'second');
61+
62+
assert.strictEqual(signal(), 'second');
63+
assert.strictEqual(AngularSignal.unwrap(signal), 'second');
64+
assert.strictEqual(computedVal(), 'prefix second');
65+
assert.strictEqual(AngularSignal.unwrap(computedVal), 'prefix second');
66+
});
67+
68+
describe('.isSignal()', () => {
69+
it('validates a signal', () => {
70+
const val = 'hello';
71+
const wrapped = AngularSignal.wrap(val);
72+
assert.ok(AngularSignal.isSignal(wrapped));
73+
});
74+
75+
it('rejects a non-signal', () => {
76+
assert.strictEqual(AngularSignal.isSignal('hello'), false);
77+
});
78+
});
79+
});
80+
81+
describe('Preact variation', () => {
82+
const PreactSignal: FrameworkSignal<PSignal> = {
83+
computed: <T>(fn: () => T) => pComputed(fn),
84+
isSignal: (val: unknown) => val instanceof PSignal,
85+
wrap: <T>(val: T) => new PSignal(val),
86+
unwrap: <T>(val: PSignal<T>) => val.value,
87+
set: <T>(signal: PSignal<T>, value: T) => (signal.value = value),
88+
};
89+
90+
it('round trip wraps and unwraps successfully', () => {
91+
const val = 'hello';
92+
const wrapped = PreactSignal.wrap(val);
93+
assert.strictEqual(PreactSignal.unwrap(wrapped), val);
94+
});
95+
96+
it('handles updates well', () => {
97+
const signal = PreactSignal.wrap('first');
98+
const computed = PreactSignal.computed(() => `prefix ${signal.value}`);
99+
100+
assert.strictEqual(signal.value, 'first');
101+
assert.strictEqual(PreactSignal.unwrap(signal), 'first');
102+
assert.strictEqual(computed.value, 'prefix first');
103+
assert.strictEqual(PreactSignal.unwrap(computed), 'prefix first');
104+
105+
PreactSignal.set(signal, 'second');
106+
107+
assert.strictEqual(signal.value, 'second');
108+
assert.strictEqual(PreactSignal.unwrap(signal), 'second');
109+
assert.strictEqual(computed.value, 'prefix second');
110+
assert.strictEqual(PreactSignal.unwrap(computed), 'prefix second');
111+
});
112+
113+
describe('.isSignal()', () => {
114+
it('validates a signal', () => {
115+
const val = 'hello';
116+
const wrapped = PreactSignal.wrap(val);
117+
assert.ok(PreactSignal.isSignal(wrapped));
118+
});
119+
120+
it('rejects a non-signal', () => {
121+
assert.strictEqual(PreactSignal.isSignal('hello'), false);
122+
});
123+
});
124+
});
125+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* A generic representation of a Signal that could come from any framework.
19+
* For any library building on top of A2UI's web core lib, this must be
20+
* implemented for their associated signals implementation.
21+
*/
22+
export interface FrameworkSignal<SignalType, WriteableSignalType = SignalType> {
23+
/**
24+
* Create a computed signal for this framework.
25+
*/
26+
computed<T>(fn: () => T): SignalType;
27+
28+
/**
29+
* Check if an arbitrary object is a framework signal.
30+
*/
31+
isSignal(val: unknown): val is SignalType;
32+
33+
/**
34+
* Wrap the value in a signal.
35+
*/
36+
wrap<T>(val: T): WriteableSignalType;
37+
38+
/**
39+
* Extract the value from a signal.
40+
*/
41+
unwrap<T>(val: SignalType): T;
42+
43+
/**
44+
* Sets the value of the provided framework signal.
45+
*/
46+
set<T>(signal: WriteableSignalType, value: T): void;
47+
}

renderers/web_core/tsconfig.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
"strict": true,
2929
"noUnusedLocals": false,
3030
"noUnusedParameters": true,
31-
"noFallthroughCasesInSwitch": true
31+
"noFallthroughCasesInSwitch": true,
32+
33+
"baseUrl": ".",
34+
"paths": {
35+
"rxjs/operators": ["./node_modules/rxjs/operators/index.js"]
36+
}
3237
},
33-
"include": ["src/**/*.ts", "src/**/*.json"]
38+
"include": ["src/**/*.ts", "src/**/*.json"],
3439
}

0 commit comments

Comments
 (0)