diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java index 71d0896ea1..4e241aa13e 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java @@ -23,27 +23,24 @@ public SignalsHandler(SecureSignalsRegistry registry) { /** * Subscribes to a signal. * - * @param signalProviderEndpointMethod + * @param providerEndpoint + * the endpoint that provides the signal + * @param providerMethod * the endpoint method that provides the signal * @param clientSignalId * the client signal id * * @return a Flux of JSON events */ - public Flux subscribe(String signalProviderEndpointMethod, - String clientSignalId) { + public Flux subscribe(String providerEndpoint, + String providerMethod, String clientSignalId) { try { - String[] endpointMethodParts = signalProviderEndpointMethod - .split("\\."); - var endpointName = endpointMethodParts[0]; - var methodName = endpointMethodParts[1]; - var signal = registry.get(clientSignalId); if (signal != null) { return signal.subscribe(); } - registry.register(clientSignalId, endpointName, methodName); + registry.register(clientSignalId, providerEndpoint, providerMethod); return registry.get(clientSignalId).subscribe(); } catch (Exception e) { return Flux.error(e); diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java index d933c894bc..42b1a69ff5 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java @@ -49,8 +49,8 @@ public void when_signalAlreadyRegistered_subscribe_returnsSubscriptionOfSameInst .put("type", "snapshot"); // first client subscribe to a signal, it registers the signal: - Flux firstFlux = signalsHandler.subscribe("endpoint.method", - CLIENT_SIGNAL_ID_1); + Flux firstFlux = signalsHandler.subscribe("endpoint", + "method", CLIENT_SIGNAL_ID_1); firstFlux.subscribe(next -> { assertNotNull(next); assertEquals(expectedSignalEventJson, next); @@ -59,8 +59,8 @@ public void when_signalAlreadyRegistered_subscribe_returnsSubscriptionOfSameInst }); // another client subscribes to the same signal: - Flux secondFlux = signalsHandler - .subscribe("endpoint.method", CLIENT_SIGNAL_ID_2); + Flux secondFlux = signalsHandler.subscribe("endpoint", + "method", CLIENT_SIGNAL_ID_2); secondFlux.subscribe(next -> { assertNotNull(next); assertEquals(expectedSignalEventJson, next); @@ -84,8 +84,8 @@ public void when_signalIsRegistered_update_notifiesTheSubscribers() var signalId = numberSignal.getId(); when(signalsRegistry.get(CLIENT_SIGNAL_ID_1)).thenReturn(numberSignal); - Flux firstFlux = signalsHandler.subscribe("endpoint.method", - CLIENT_SIGNAL_ID_1); + Flux firstFlux = signalsHandler.subscribe("endpoint", + "method", CLIENT_SIGNAL_ID_1); var setEvent = new ObjectNode(mapper.getNodeFactory()).put("value", 42) .put("id", UUID.randomUUID().toString()).put("type", "set"); diff --git a/packages/ts/generator-plugin-signals/src/SignalProcessor.ts b/packages/ts/generator-plugin-signals/src/SignalProcessor.ts index 640b6d6ac0..f48a356ce6 100644 --- a/packages/ts/generator-plugin-signals/src/SignalProcessor.ts +++ b/packages/ts/generator-plugin-signals/src/SignalProcessor.ts @@ -1,16 +1,17 @@ import type Plugin from '@vaadin/hilla-generator-core/Plugin.js'; -import { template, transform } from '@vaadin/hilla-generator-utils/ast.js'; +import { template, transform, traverse } from '@vaadin/hilla-generator-utils/ast.js'; import createSourceFile from '@vaadin/hilla-generator-utils/createSourceFile.js'; import DependencyManager from '@vaadin/hilla-generator-utils/dependencies/DependencyManager.js'; import PathManager from '@vaadin/hilla-generator-utils/dependencies/PathManager.js'; -import ts, { type FunctionDeclaration, type SourceFile } from 'typescript'; +import ts, { type FunctionDeclaration, type Identifier, type SourceFile } from 'typescript'; const HILLA_REACT_SIGNALS = '@vaadin/hilla-react-signals'; -const NUMBER_SIGNAL_CHANNEL = '$NUMBER_SIGNAL_CHANNEL$'; const CONNECT_CLIENT = '$CONNECT_CLIENT$'; +const METHOD_NAME = '$METHOD_NAME$'; +const SIGNAL = '$SIGNAL$'; -const signalImportPaths = ['com/vaadin/hilla/signals/NumberSignal']; +const signals = ['NumberSignal']; export default class SignalProcessor { readonly #dependencyManager: DependencyManager; @@ -31,54 +32,28 @@ export default class SignalProcessor { process(): SourceFile { this.#owner.logger.debug(`Processing signals: ${this.#service}`); const { imports } = this.#dependencyManager; - const numberSignalChannelId = imports.named.add(HILLA_REACT_SIGNALS, 'NumberSignalChannel'); const [, connectClientId] = imports.default.iter().find(([path]) => path.includes('connect-client'))!; - this.#processSignalImports(signalImportPaths); const initTypeId = imports.named.getIdentifier('@vaadin/hilla-frontend', 'EndpointRequestInit'); let initTypeUsageCount = 0; const [file] = ts.transform(this.#sourceFile, [ transform((tsNode) => { if (ts.isFunctionDeclaration(tsNode) && tsNode.name && this.#methods.has(tsNode.name.text)) { - const methodName = tsNode.name.text; + const signalId = this.#replaceSignalImport(tsNode); - const body = template( - ` -function dummy() { - return new ${NUMBER_SIGNAL_CHANNEL}('${this.#service}.${methodName}', ${CONNECT_CLIENT}).signal; + return template( + `function ${METHOD_NAME}() { + return new ${SIGNAL}(undefined, { client: ${CONNECT_CLIENT}, endpoint: '${this.#service}', method: '${tsNode.name.text}' }); }`, - (statements) => (statements[0] as FunctionDeclaration).body?.statements, + (statements) => statements, [ - transform((node) => - ts.isIdentifier(node) && node.text === NUMBER_SIGNAL_CHANNEL ? numberSignalChannelId : node, - ), + transform((node) => (ts.isIdentifier(node) && node.text === METHOD_NAME ? tsNode.name : node)), + transform((node) => (ts.isIdentifier(node) && node.text === SIGNAL ? signalId : node)), transform((node) => (ts.isIdentifier(node) && node.text === CONNECT_CLIENT ? connectClientId : node)), ], ); - - let returnType = tsNode.type; - if ( - returnType && - ts.isTypeReferenceNode(returnType) && - 'text' in returnType.typeName && - returnType.typeName.text === 'Promise' - ) { - if (returnType.typeArguments && returnType.typeArguments.length > 0) { - returnType = returnType.typeArguments[0]; - } - } - - return ts.factory.createFunctionDeclaration( - tsNode.modifiers?.filter((modifier) => modifier.kind !== ts.SyntaxKind.AsyncKeyword), - tsNode.asteriskToken, - tsNode.name, - tsNode.typeParameters, - tsNode.parameters.filter(({ name }) => !(ts.isIdentifier(name) && name.text === 'init')), - returnType, - ts.factory.createBlock(body ?? [], false), - ); } return tsNode; }), @@ -108,17 +83,31 @@ function dummy() { ); } - #processSignalImports(signalImports: readonly string[]) { + #replaceSignalImport(method: FunctionDeclaration): Identifier { const { imports } = this.#dependencyManager; - signalImports.forEach((signalImport) => { - const result = imports.default.iter().find(([path]) => path.includes(signalImport)); + if (method.type) { + const type = traverse(method.type, (node) => + ts.isIdentifier(node) && signals.includes(node.text) ? node : undefined, + ); + + if (type) { + const signalId = imports.named.getIdentifier(HILLA_REACT_SIGNALS, type.text); - if (result) { - const [path, id] = result; - imports.default.remove(path); - imports.named.add(HILLA_REACT_SIGNALS, id.text, true, id); + if (signalId) { + return signalId; + } + + const result = imports.default.iter().find(([_p, id]) => id.text === type.text); + + if (result) { + const [path] = result; + imports.default.remove(path); + return imports.named.add(HILLA_REACT_SIGNALS, type.text, false, type); + } } - }); + } + + throw new Error('Signal type not found'); } } diff --git a/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceMix.snap.ts b/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceMix.snap.ts index ffdf1de04f..877a588e36 100644 --- a/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceMix.snap.ts +++ b/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceMix.snap.ts @@ -1,7 +1,11 @@ import { EndpointRequestInit as EndpointRequestInit_1 } from "@vaadin/hilla-frontend"; -import { type NumberSignal as NumberSignal_1, NumberSignalChannel as NumberSignalChannel_1 } from "@vaadin/hilla-react-signals"; +import { NumberSignal as NumberSignal_1 } from "@vaadin/hilla-react-signals"; import client_1 from "./connect-client.default.js"; -function counter_1(): NumberSignal_1 { return new NumberSignalChannel_1("NumberSignalService.counter", client_1).signal; } +function counter_1() { + return new NumberSignal_1(undefined, { client: client_1, endpoint: "NumberSignalService", method: "counter" }); +} async function sayHello_1(name: string, init?: EndpointRequestInit_1): Promise { return client_1.call("NumberSignalService", "sayHello", { name }, init); } -function sharedValue_1(): NumberSignal_1 { return new NumberSignalChannel_1("NumberSignalService.sharedValue", client_1).signal; } +function sharedValue_1() { + return new NumberSignal_1(undefined, { client: client_1, endpoint: "NumberSignalService", method: "sharedValue" }); +} export { counter_1 as counter, sayHello_1 as sayHello, sharedValue_1 as sharedValue }; diff --git a/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceSignalOnly.snap.ts b/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceSignalOnly.snap.ts index 923598556d..01681ee518 100644 --- a/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceSignalOnly.snap.ts +++ b/packages/ts/generator-plugin-signals/test/fixtures/NumberSignalServiceSignalOnly.snap.ts @@ -1,5 +1,9 @@ -import { type NumberSignal as NumberSignal_1, NumberSignalChannel as NumberSignalChannel_1 } from "@vaadin/hilla-react-signals"; +import { NumberSignal as NumberSignal_1 } from "@vaadin/hilla-react-signals"; import client_1 from "./connect-client.default.js"; -function counter_1(): NumberSignal_1 { return new NumberSignalChannel_1("NumberSignalService.counter", client_1).signal; } -function sharedValue_1(): NumberSignal_1 { return new NumberSignalChannel_1("NumberSignalService.sharedValue", client_1).signal; } +function counter_1() { + return new NumberSignal_1(undefined, { client: client_1, endpoint: "NumberSignalService", method: "counter" }); +} +function sharedValue_1() { + return new NumberSignal_1(undefined, { client: client_1, endpoint: "NumberSignalService", method: "sharedValue" }); +} export { counter_1 as counter, sharedValue_1 as sharedValue }; diff --git a/packages/ts/generator-utils/src/ast.ts b/packages/ts/generator-utils/src/ast.ts index 09fd6ea265..8daf0e9126 100644 --- a/packages/ts/generator-utils/src/ast.ts +++ b/packages/ts/generator-utils/src/ast.ts @@ -60,3 +60,11 @@ export function transform( return ts.visitEachChild(root, visitor, context); }; } + +export function traverse(node: Node, visitor: (node: Node) => T | undefined): T | undefined { + function _visitor(n: Node): T | undefined { + return visitor(n) ?? ts.forEachChild(n, _visitor); + } + + return _visitor(node); +} diff --git a/packages/ts/generator-utils/src/createSourceFile.ts b/packages/ts/generator-utils/src/createSourceFile.ts index 3f5f22e4b3..145599ad37 100644 --- a/packages/ts/generator-utils/src/createSourceFile.ts +++ b/packages/ts/generator-utils/src/createSourceFile.ts @@ -1,6 +1,6 @@ import ts, { type SourceFile, type Statement } from 'typescript'; export default function createSourceFile(statements: readonly Statement[], fileName: string): SourceFile { - const sourceFile = ts.createSourceFile(fileName, '', ts.ScriptTarget.ES2019, undefined, ts.ScriptKind.TS); + const sourceFile = ts.createSourceFile(fileName, '', ts.ScriptTarget.ES2021, undefined, ts.ScriptKind.TS); return ts.factory.updateSourceFile(sourceFile, statements); } diff --git a/packages/ts/react-signals/.eslintrc b/packages/ts/react-signals/.eslintrc index d445a4e4e1..6ac92d4ca9 100644 --- a/packages/ts/react-signals/.eslintrc +++ b/packages/ts/react-signals/.eslintrc @@ -1,4 +1,5 @@ { + "extends": ["../../../.eslintrc"], "parserOptions": { "project": "./tsconfig.json" } diff --git a/packages/ts/react-signals/src/EventChannel.ts b/packages/ts/react-signals/src/EventChannel.ts deleted file mode 100644 index 41b85b8c31..0000000000 --- a/packages/ts/react-signals/src/EventChannel.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ConnectClient, Subscription } from '@vaadin/hilla-frontend'; -import { nanoid } from 'nanoid'; -import { NumberSignal, setInternalValue, type ValueSignal } from './Signals.js'; -import SignalsHandler from './SignalsHandler'; -import { type StateEvent, StateEventType } from './types.js'; - -/** - * The type that describes the needed information to - * subscribe and publish to a server-side signal instance. - */ -type SignalChannelDescriptor = Readonly<{ - signalProviderEndpointMethod: string; - subscribe(signalProviderEndpointMethod: string, clientSignalId: string): Subscription; - publish(clientSignalId: string, event: T): Promise; -}>; - -/** - * A generic class that represents a signal channel - * that can be used to communicate with a server-side - * signal instance. - * - * The signal channel is responsible for subscribing to - * the server-side signal and updating the local signal - * based on the received events. - * - * @typeParam T - The type of the signal value. - * @typeParam S - The type of the signal instance. - */ -abstract class SignalChannel> { - readonly #channelDescriptor: SignalChannelDescriptor; - readonly #signalsHandler: SignalsHandler; - readonly #id: string; - - readonly #internalSignal: S; - - constructor(signalProviderServiceMethod: string, connectClient: ConnectClient) { - this.#id = nanoid(); - this.#signalsHandler = new SignalsHandler(connectClient); - this.#channelDescriptor = { - signalProviderEndpointMethod: signalProviderServiceMethod, - subscribe: (signalProviderEndpointMethod: string, signalId: string) => - this.#signalsHandler.subscribe(signalProviderEndpointMethod, signalId), - publish: async (signalId: string, event: StateEvent) => this.#signalsHandler.update(signalId, event), - }; - - this.#internalSignal = this.createInternalSignal(async (event: StateEvent) => this.publish(event)); - - this.#connect(); - } - - #connect() { - this.#channelDescriptor - .subscribe(this.#channelDescriptor.signalProviderEndpointMethod, this.#id) - .onNext((stateEvent) => { - // Update signals based on the new value from the event: - this.#updateSignals(stateEvent); - }); - } - - #updateSignals(stateEvent: StateEvent): void { - if (stateEvent.type === StateEventType.SNAPSHOT) { - setInternalValue(this.#internalSignal, stateEvent.value); - } - } - - async publish(event: StateEvent): Promise { - await this.#channelDescriptor.publish(this.#id, event); - return true; - } - - /** - * Returns the signal instance to be used in components. - */ - get signal(): S { - return this.#internalSignal; - } - - /** - * Returns the id of the signal channel. - */ - get id(): string { - return this.#id; - } - - abstract createInternalSignal(publish: (event: StateEvent) => Promise, initialValue?: T): S; -} - -/** - * A signal channel that is used to communicate with a - * server-side signal instance that holds a number value. - */ -export class NumberSignalChannel extends SignalChannel { - override createInternalSignal(publish: (event: StateEvent) => Promise, initialValue?: number): NumberSignal { - return new NumberSignal(publish, initialValue); - } -} diff --git a/packages/ts/react-signals/src/FullStackSignal.ts b/packages/ts/react-signals/src/FullStackSignal.ts new file mode 100644 index 0000000000..ea68f45b9e --- /dev/null +++ b/packages/ts/react-signals/src/FullStackSignal.ts @@ -0,0 +1,158 @@ +import { computed, signal } from '@preact/signals-react'; +import type { ConnectClient, Subscription } from '@vaadin/hilla-frontend'; +import { nanoid } from 'nanoid'; +import { Signal } from './core.js'; + +const ENDPOINT = 'SignalsHandler'; + +/** + * Types of changes that can be produced or processed by a signal. + */ +export enum StateEventType { + SET = 'set', + SNAPSHOT = 'snapshot', +} + +/** + * An object that describes the change of the signal state. + */ +export type StateEvent = Readonly<{ + id: string; + type: StateEventType; + value: T; +}>; + +/** + * An object that describes a data object to connect to the signal provider + * service. + */ +export type ServerConnectionConfig = Readonly<{ + /** + * The client instance to be used for communication. + */ + client: ConnectClient; + + /** + * The name of the signal provider service endpoint. + */ + endpoint: string; + + /** + * The name of the signal provider service method. + */ + method: string; +}>; + +/** + * A server connection manager. + */ +class ServerConnection { + readonly #id: string; + readonly #config: ServerConnectionConfig; + #subscription?: Subscription>; + + constructor(id: string, config: ServerConnectionConfig) { + this.#config = config; + this.#id = id; + } + + get subscription() { + return this.#subscription; + } + + connect() { + const { client, endpoint, method } = this.#config; + + this.#subscription ??= client.subscribe(ENDPOINT, 'subscribe', { + providerEndpoint: endpoint, + providerMethod: method, + clientSignalId: this.#id, + }); + + return this.#subscription; + } + + async update(event: StateEvent): Promise { + await this.#config.client.call(ENDPOINT, 'update', { + clientSignalId: this.#id, + event, + }); + } + + disconnect() { + this.#subscription?.cancel(); + this.#subscription = undefined; + } +} + +/** + * A signal that holds a shared value. Each change to the value is propagated to + * the server-side signal provider. At the same time, each change received from + * the server-side signal provider is propagated to the local signal and it's + * subscribers. + * + * @internal + */ +export abstract class FullStackSignal extends Signal { + /** + * The unique identifier of the signal necessary to communicate with the + * server. + */ + readonly id = nanoid(); + + /** + * The server connection manager. + */ + readonly server: ServerConnection; + + /** + * Defines whether the signal is currently awaits a server-side response. + */ + readonly pending = computed(() => this.#pending.value); + + /** + * Defines whether the signal has an error. + */ + readonly error = computed(() => this.#error.value); + + readonly #pending = signal(false); + readonly #error = signal(undefined); + + constructor(value: T | undefined, config: ServerConnectionConfig) { + super(value); + this.server = new ServerConnection(this.id, config); + + // Paused at the very start to prevent the signal from sending the initial + // value to the server. + let paused = true; + + this.server.connect().onNext((event: StateEvent) => { + if (event.type === StateEventType.SNAPSHOT) { + paused = true; + this.value = event.value; + paused = false; + } + }); + + this.subscribe((v) => { + if (!paused) { + this.#pending.value = true; + this.#error.value = undefined; + this.server + .update({ + id: nanoid(), + type: StateEventType.SET, + value: v, + }) + .catch((error: unknown) => { + this.#error.value = error instanceof Error ? error : new Error(String(error)); + }) + .finally(() => { + this.#pending.value = false; + }); + } + }); + + paused = false; + } +} diff --git a/packages/ts/react-signals/src/Signals.ts b/packages/ts/react-signals/src/Signals.ts index 791e5f3dfe..c3436bf668 100644 --- a/packages/ts/react-signals/src/Signals.ts +++ b/packages/ts/react-signals/src/Signals.ts @@ -1,70 +1,9 @@ -import { nanoid } from 'nanoid'; -import { Signal } from './core.js'; -import { type StateEvent, StateEventType } from './types'; - -// eslint-disable-next-line import/no-mutable-exports -export let setInternalValue: (signal: ValueSignal, value: T) => void; +import { FullStackSignal } from './FullStackSignal.js'; /** - * A signal that holds a value. The underlying - * value of this signal is stored and updated as a - * shared value on the server. - * - * @internal + * A full-stack signal that holds an arbitrary value. */ -export abstract class ValueSignal extends Signal { - static { - setInternalValue = (signal: ValueSignal, value: unknown): void => signal.#setInternalValue(value); - } - - readonly #publish: (event: StateEvent) => Promise; - - /** - * Creates a new ValueSignal instance. - * @param publish - The function that publishes the - * value of the signal to the server. - * @param value - The initial value of the signal - * @defaultValue undefined - */ - constructor(publish: (event: StateEvent) => Promise, value?: T) { - super(value); - this.#publish = publish; - } - - /** - * Returns the value of the signal. - */ - override get value(): T { - return super.value; - } - - /** - * Publishes the new value to the server. - * Note that this method is not setting - * the signal's value. - * - * @param value - The new value of the signal - * to be published to the server. - */ - override set value(value: T) { - const id = nanoid(); - // set the local value to be used for latency compensation and offline support: - this.#setInternalValue(value); - // publish the update to the server: - this.#publish({ id, type: StateEventType.SET, value }).catch((error) => { - throw error; - }); - } - - /** - * Sets the value of the signal. - * @param value - The new value of the signal. - * @internal - */ - #setInternalValue(value: T): void { - super.value = value; - } -} +export class ValueSignal extends FullStackSignal {} /** * A signal that holds a number value. The underlying diff --git a/packages/ts/react-signals/src/SignalsHandler.ts b/packages/ts/react-signals/src/SignalsHandler.ts deleted file mode 100644 index d54929e5e4..0000000000 --- a/packages/ts/react-signals/src/SignalsHandler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ConnectClient, EndpointRequestInit, Subscription } from '@vaadin/hilla-frontend'; -import type { StateEvent } from './types'; - -/** - * SignalsHandler is a helper class for handling the - * communication of the full-stack signal instances - * and their server-side counterparts they are - * subscribed and publish their updates to. - */ -export default class SignalsHandler { - readonly #client: ConnectClient; - - constructor(client: ConnectClient) { - this.#client = client; - } - - subscribe(signalProviderEndpointMethod: string, clientSignalId: string): Subscription { - return this.#client.subscribe('SignalsHandler', 'subscribe', { signalProviderEndpointMethod, clientSignalId }); - } - - async update(clientSignalId: string, event: StateEvent, init?: EndpointRequestInit): Promise { - return this.#client.call('SignalsHandler', 'update', { clientSignalId, event }, init); - } -} diff --git a/packages/ts/react-signals/src/index.ts b/packages/ts/react-signals/src/index.ts index a0e10b38ce..f90c545d41 100644 --- a/packages/ts/react-signals/src/index.ts +++ b/packages/ts/react-signals/src/index.ts @@ -1,5 +1,4 @@ // eslint-disable-next-line import/export export * from './core.js'; -export { NumberSignalChannel } from './EventChannel.js'; -export { NumberSignal, ValueSignal } from './Signals.js'; -export * from './types.js'; +export { NumberSignal } from './Signals.js'; +export { FullStackSignal } from './FullStackSignal.js'; diff --git a/packages/ts/react-signals/src/types.ts b/packages/ts/react-signals/src/types.ts deleted file mode 100644 index 19e4a884ab..0000000000 --- a/packages/ts/react-signals/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Types of events that can be produced or processed by a signal. - */ -export enum StateEventType { - SET = 'set', - SNAPSHOT = 'snapshot', -} - -/** - * Event that describes the state of a signal. - */ -export type StateEvent = { - id: string; - type: StateEventType; - value: any; -}; diff --git a/packages/ts/react-signals/test/EventChannel.spec.tsx b/packages/ts/react-signals/test/EventChannel.spec.tsx deleted file mode 100644 index 83a72a685f..0000000000 --- a/packages/ts/react-signals/test/EventChannel.spec.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { expect, use } from '@esm-bundle/chai'; -import { render } from '@testing-library/react'; -import { ConnectClient, type Subscription } from '@vaadin/hilla-frontend'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { NumberSignal, NumberSignalChannel, type StateEvent, StateEventType } from '../src/index.js'; -import { nextFrame } from './utils.js'; - -use(sinonChai); - -function simulateReceivedEvent(connectSubscriptionMock: Subscription, event: StateEvent) { - const onNextCallback = (connectSubscriptionMock.onNext as sinon.SinonStub).getCall(0).args[0]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - onNextCallback(event); -} - -describe('@vaadin/hilla-react-signals', () => { - describe('NumberSignalChannel', () => { - let connectClientMock: sinon.SinonStubbedInstance; - let connectSubscriptionMock: Subscription; - - beforeEach(() => { - connectClientMock = sinon.createStubInstance(ConnectClient); - connectClientMock.call.resolves(); - connectSubscriptionMock = { - cancel: sinon.stub(), - context: sinon.stub().returnsThis(), - onComplete: sinon.stub().returnsThis(), - onError: sinon.stub().returnsThis(), - onNext: sinon.stub().returnsThis(), - }; - // Mock the subscribe method - connectClientMock.subscribe.returns(connectSubscriptionMock); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should create signal instance of type NumberSignal', () => { - const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock); - expect(numberSignalChannel.signal).to.be.instanceOf(NumberSignal); - expect(numberSignalChannel.signal.value).to.be.undefined; - }); - - it('should subscribe to signal provider endpoint', () => { - const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock); - expect(connectClientMock.subscribe).to.be.have.been.calledOnce; - expect(connectClientMock.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', { - clientSignalId: numberSignalChannel.id, - signalProviderEndpointMethod: 'testEndpoint', - }); - }); - - it('should publish updates to signals handler endpoint', () => { - const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock); - numberSignalChannel.signal.value = 42; - - expect(connectClientMock.call).to.be.have.been.calledOnce; - expect(connectClientMock.call).to.have.been.calledWithMatch( - 'SignalsHandler', - 'update', - { - clientSignalId: numberSignalChannel.id, - event: { type: StateEventType.SET, value: 42 }, - }, - undefined, - ); - }); - - it("should update signal's value based on the received event", () => { - const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock); - expect(numberSignalChannel.signal.value).to.be.undefined; - - // Simulate the event received from the server: - const snapshotEvent: StateEvent = { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 }; - simulateReceivedEvent(connectSubscriptionMock, snapshotEvent); - - // Check if the signal value is updated: - expect(numberSignalChannel.signal.value).to.equal(42); - }); - - it("should render signal's the updated value", async () => { - const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock); - const numberSignal = numberSignalChannel.signal; - simulateReceivedEvent(connectSubscriptionMock, { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 }); - - const result = render(Value is {numberSignal}); - await nextFrame(); - expect(result.container.textContent).to.equal('Value is 42'); - - simulateReceivedEvent(connectSubscriptionMock, { id: 'someId', type: StateEventType.SNAPSHOT, value: 99 }); - await nextFrame(); - expect(result.container.textContent).to.equal('Value is 99'); - }); - }); -}); diff --git a/packages/ts/react-signals/test/FullStackSignal.spec.tsx b/packages/ts/react-signals/test/FullStackSignal.spec.tsx new file mode 100644 index 0000000000..344bd81705 --- /dev/null +++ b/packages/ts/react-signals/test/FullStackSignal.spec.tsx @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { expect, use } from '@esm-bundle/chai'; +import { render } from '@testing-library/react'; +import { ConnectClient, type Subscription } from '@vaadin/hilla-frontend'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { type StateEvent, StateEventType } from '../src/FullStackSignal.js'; +import { NumberSignal } from '../src/index.js'; +import { nextFrame } from './utils.js'; + +use(sinonChai); + +describe('@vaadin/hilla-react-signals', () => { + describe('FullStackSignal', () => { + function simulateReceivedChange( + connectSubscriptionMock: sinon.SinonSpiedInstance>>, + event: StateEvent, + ) { + const [onNextCallback] = connectSubscriptionMock.onNext.firstCall.args; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + onNextCallback(event); + } + + let client: sinon.SinonStubbedInstance; + let subscription: sinon.SinonSpiedInstance>>; + let signal: NumberSignal; + + beforeEach(() => { + client = sinon.createStubInstance(ConnectClient); + client.call.resolves(); + + subscription = sinon.spy>>({ + cancel() {}, + context() { + return this; + }, + onComplete() { + return this; + }, + onError() { + return this; + }, + onNext() { + return this; + }, + }); + // Mock the subscribe method + client.subscribe.returns(subscription); + + signal = new NumberSignal(undefined, { client, endpoint: 'TestEndpoint', method: 'testMethod' }); + client.call.resetHistory(); + }); + + afterEach(() => { + sinon.resetHistory(); + }); + + it('should create signal instance of type NumberSignal', () => { + expect(signal).to.be.instanceOf(NumberSignal); + expect(signal.value).to.be.undefined; + }); + + it('should subscribe to signal provider endpoint', () => { + expect(client.subscribe).to.be.have.been.calledOnce; + expect(client.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', { + clientSignalId: signal.id, + providerEndpoint: 'TestEndpoint', + providerMethod: 'testMethod', + }); + }); + + it('should publish updates to signals handler endpoint', () => { + signal.value = 42; + + expect(client.call).to.be.have.been.calledOnce; + expect(client.call).to.have.been.calledWithMatch('SignalsHandler', 'update', { + clientSignalId: signal.id, + event: { type: StateEventType.SET, value: 42 }, + }); + }); + + it("should update signal's value based on the received event", () => { + expect(signal.value).to.be.undefined; + + // Simulate the event received from the server: + const snapshotEvent: StateEvent = { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 }; + simulateReceivedChange(subscription, snapshotEvent); + + // Check if the signal value is updated: + expect(signal.value).to.equal(42); + }); + + it('should render the updated value', async () => { + const numberSignal = signal; + simulateReceivedChange(subscription, { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 }); + + const result = render(Value is {numberSignal}); + await nextFrame(); + expect(result.container.textContent).to.equal('Value is 42'); + + simulateReceivedChange(subscription, { id: 'someId', type: StateEventType.SNAPSHOT, value: 99 }); + await nextFrame(); + expect(result.container.textContent).to.equal('Value is 99'); + }); + + it('should subscribe using client', () => { + expect(client.subscribe).to.be.have.been.calledOnce; + expect(client.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', { + clientSignalId: signal.id, + providerEndpoint: 'TestEndpoint', + providerMethod: 'testMethod', + }); + }); + + it('should publish the new value to the server when set', () => { + signal.value = 42; + expect(client.call).to.have.been.calledOnce; + expect(client.call).to.have.been.calledWithMatch('SignalsHandler', 'update', { + event: { type: StateEventType.SET, value: 42 }, + }); + + signal.value = 0; + + client.call.resetHistory(); + + signal.value += 1; + expect(client.call).to.have.been.calledOnce; + expect(client.call).to.have.been.calledWithMatch('SignalsHandler', 'update', { + event: { type: StateEventType.SET, value: 1 }, + }); + + const [, , params] = client.call.firstCall.args; + + expect(params!.event).to.have.property('id'); + }); + + it('should provide a way to access connection errors', async () => { + const error = new Error('Server error'); + client.call.rejects(error); + + signal.value = 42; + // Waiting for the ConnectionClient#call promise to resolve. + await nextFrame(); + + expect(signal.error).to.be.like({ value: error }); + + // No error after the correct update + client.call.resolves(); + signal.value = 50; + await nextFrame(); + expect(signal.error).to.be.like({ value: undefined }); + }); + + it('should provide a way to access the pending state', async () => { + expect(signal.pending).to.be.like({ value: false }); + signal.value = 42; + expect(signal.pending).to.be.like({ value: true }); + await nextFrame(); + expect(signal.pending).to.be.like({ value: false }); + }); + + it('should provide an internal server subscription', () => { + expect(signal.server.subscription).to.equal(subscription); + }); + + it('should disconnect from the server', () => { + signal.server.disconnect(); + expect(subscription.cancel).to.have.been.calledOnce; + }); + + it('should throw an error when the server call fails', () => { + client.call.rejects(new Error('Server error')); + signal.value = 42; + }); + }); +}); diff --git a/packages/ts/react-signals/test/Signals.spec.tsx b/packages/ts/react-signals/test/Signals.spec.tsx index 26dc7bde02..eaf60b3b23 100644 --- a/packages/ts/react-signals/test/Signals.spec.tsx +++ b/packages/ts/react-signals/test/Signals.spec.tsx @@ -1,75 +1,74 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { expect, use } from '@esm-bundle/chai'; import { render } from '@testing-library/react'; +import { ConnectClient, type Subscription } from '@vaadin/hilla-frontend'; import chaiLike from 'chai-like'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import type { StateEvent } from '../src'; import { effect } from '../src'; import { NumberSignal } from '../src'; +import type { ServerConnectionConfig, StateEvent } from '../src/FullStackSignal.js'; import { nextFrame } from './utils.js'; use(sinonChai); use(chaiLike); describe('@vaadin/hilla-react-signals', () => { - describe('NumberSignal', () => { - let publishSpy: sinon.SinonSpy; - - beforeEach(() => { - publishSpy = sinon.spy(async (_: StateEvent): Promise => Promise.resolve(true)); - }); - - afterEach(() => { - sinon.restore(); + let config: ServerConnectionConfig; + + beforeEach(() => { + const client = sinon.createStubInstance(ConnectClient); + client.call.resolves(); + + const subscription = sinon.spy>>({ + cancel() {}, + context() { + return this; + }, + onComplete() { + return this; + }, + onError() { + return this; + }, + onNext() { + return this; + }, }); + // Mock the subscribe method + client.subscribe.returns(subscription); + config = { client, endpoint: 'TestEndpoint', method: 'testMethod' }; + }); + describe('NumberSignal', () => { it('should retain default value as initialized', () => { - const numberSignal1 = new NumberSignal(publishSpy); + const numberSignal1 = new NumberSignal(undefined, config); expect(numberSignal1.value).to.be.undefined; - const numberSignal2 = new NumberSignal(publishSpy, undefined); - expect(numberSignal2.value).to.be.undefined; - - const numberSignal3 = new NumberSignal(publishSpy, 0); - expect(numberSignal3.value).to.equal(0); - - const numberSignal4 = new NumberSignal(publishSpy, 42.424242); - expect(numberSignal4.value).to.equal(42.424242); - - const numberSignal5 = new NumberSignal(publishSpy, -42.424242); - expect(numberSignal5.value).to.equal(-42.424242); - }); - - it('should publish the new value to the server when set', () => { - const numberSignal = new NumberSignal(publishSpy); - numberSignal.value = 42; - expect(publishSpy).to.have.been.calledOnce; - expect(publishSpy).to.have.been.calledWithMatch({ type: 'set', value: 42 }); + const numberSignal2 = new NumberSignal(0, config); + expect(numberSignal2.value).to.equal(0); - publishSpy.resetHistory(); + const numberSignal3 = new NumberSignal(42.424242, config); + expect(numberSignal3.value).to.equal(42.424242); - const numberSignal2 = new NumberSignal(publishSpy, 0); - // eslint-disable-next-line no-plusplus - numberSignal2.value++; - expect(publishSpy).to.have.been.calledOnce; - expect(publishSpy).to.have.been.calledWithMatch({ type: 'set', value: 1 }); + const numberSignal4 = new NumberSignal(-42.424242, config); + expect(numberSignal4.value).to.equal(-42.424242); }); it('should render value when signal is rendered', async () => { - const numberSignal = new NumberSignal(publishSpy, 42); + const numberSignal = new NumberSignal(42, config); const result = render(Value is {numberSignal}); await nextFrame(); expect(result.container.textContent).to.equal('Value is 42'); }); it('should set the underlying value locally without waiting for server confirmation', () => { - const numberSignal = new NumberSignal(publishSpy); + const numberSignal = new NumberSignal(undefined, config); expect(numberSignal.value).to.equal(undefined); numberSignal.value = 42; expect(numberSignal.value).to.equal(42); - const anotherNumberSignal = new NumberSignal(publishSpy); + const anotherNumberSignal = new NumberSignal(undefined, config); const results: Array = []; effect(() => { diff --git a/packages/ts/react-signals/test/SignalsHandler.spec.ts b/packages/ts/react-signals/test/SignalsHandler.spec.ts deleted file mode 100644 index c8dcff3326..0000000000 --- a/packages/ts/react-signals/test/SignalsHandler.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { expect, use } from '@esm-bundle/chai'; -import { ConnectClient } from '@vaadin/hilla-frontend'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { type StateEvent, StateEventType } from '../src/index.js'; -import SignalsHandler from '../src/SignalsHandler.js'; - -use(sinonChai); - -describe('@vaadin/hilla-react-signals', () => { - describe('signalsHandler', () => { - let connectClientMock: sinon.SinonStubbedInstance; - let signalsHandler: SignalsHandler; - - beforeEach(() => { - connectClientMock = sinon.createStubInstance(ConnectClient); - signalsHandler = new SignalsHandler(connectClientMock); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('subscribe should call client.subscribe', () => { - const signalProviderEndpointMethod = 'testEndpoint'; - const clientSignalId = 'testSignalId'; - signalsHandler.subscribe(signalProviderEndpointMethod, clientSignalId); - - expect(connectClientMock.subscribe).to.be.have.been.calledOnce; - expect(connectClientMock.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', { - signalProviderEndpointMethod, - clientSignalId, - }); - }); - - it('update should call client.call', async () => { - const clientSignalId = 'testSignalId'; - const event: StateEvent = { id: 'testEvent', type: StateEventType.SET, value: 10 }; - const init = {}; - - await signalsHandler.update(clientSignalId, event, init); - - expect(connectClientMock.call).to.be.have.been.calledOnce; - expect(connectClientMock.call).to.have.been.calledWith( - 'SignalsHandler', - 'update', - { clientSignalId, event }, - init, - ); - }); - }); -});