Skip to content

Commit

Permalink
TASK: Add package @neos-project/framework-observable
Browse files Browse the repository at this point in the history
This introduces a very basic implementation of the observable pattern
for the Neos UI.

This will allow us to replace redux opportunistically in various places.
  • Loading branch information
grebaldi committed Jun 11, 2024
1 parent 7cd6b2d commit 439f382
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 0 deletions.
8 changes: 8 additions & 0 deletions packages/framework-observable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@neos-project/framework-observable",
"version": "",
"description": "Observable pattern implementation for the Neos UI",
"private": true,
"main": "./src/index.ts",
"license": "GNU GPLv3"
}
94 changes: 94 additions & 0 deletions packages/framework-observable/src/Observable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/
import {createObservable} from './Observable';

describe('Observable', () => {
test('emit some values and subscribe', () => {
const observable$ = createObservable((next) => {
next(1);
next(2);
next(3);
});
const subscriber = {
next: jest.fn()
};

observable$.subscribe(subscriber);

expect(subscriber.next).toHaveBeenCalledTimes(3);
expect(subscriber.next).toHaveBeenNthCalledWith(1, 1);
expect(subscriber.next).toHaveBeenNthCalledWith(2, 2);
expect(subscriber.next).toHaveBeenNthCalledWith(3, 3);
});

test('emit some values and subscribe a couple of times', () => {
const observable$ = createObservable((next) => {
next(1);
next(2);
next(3);
});
const subscriber1 = {
next: jest.fn()
};
const subscriber2 = {
next: jest.fn()
};
const subscriber3 = {
next: jest.fn()
};

observable$.subscribe(subscriber1);
observable$.subscribe(subscriber2);
observable$.subscribe(subscriber3);

expect(subscriber1.next).toHaveBeenCalledTimes(3);
expect(subscriber1.next).toHaveBeenNthCalledWith(1, 1);
expect(subscriber1.next).toHaveBeenNthCalledWith(2, 2);
expect(subscriber1.next).toHaveBeenNthCalledWith(3, 3);

expect(subscriber2.next).toHaveBeenCalledTimes(3);
expect(subscriber2.next).toHaveBeenNthCalledWith(1, 1);
expect(subscriber2.next).toHaveBeenNthCalledWith(2, 2);
expect(subscriber2.next).toHaveBeenNthCalledWith(3, 3);

expect(subscriber3.next).toHaveBeenCalledTimes(3);
expect(subscriber3.next).toHaveBeenNthCalledWith(1, 1);
expect(subscriber3.next).toHaveBeenNthCalledWith(2, 2);
expect(subscriber3.next).toHaveBeenNthCalledWith(3, 3);
});

test('emit no values, subscribe and unsubscribe', () => {
const unsubscribe = jest.fn();
const observable$ = createObservable(() => {
return unsubscribe;
});
const subscriber = {
next: jest.fn()
};

const subscription = observable$.subscribe(subscriber);
subscription.unsubscribe();

expect(subscriber.next).toHaveBeenCalledTimes(0);
expect(unsubscribe).toHaveBeenCalledTimes(1);
});

test('emit no values, subscribe and unsubscribe with void observer', () => {
const observable$ = createObservable(() => {});
const subscriber = {
next: jest.fn()
};

const subscription = observable$.subscribe(subscriber);

expect(() => subscription.unsubscribe()).not.toThrow();
expect(subscriber.next).toHaveBeenCalledTimes(0);
});
});
51 changes: 51 additions & 0 deletions packages/framework-observable/src/Observable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/
import type {Subscriber} from './Subscriber';
import type {Subscription} from './Subscription';
import type {Observer} from './Observer';

/**
* An Observable emits values over time. You can attach a subscriber to it
* using the Observable's `subscribe` method, or you can perform operations
* producing new Observables via its `pipe` method.
*/
export interface Observable<V> {
subscribe: (subscriber: Subscriber<V>) => Subscription;
}

/**
* An ObservablePipeOperation is a function that takes an observable and
* returns a new observable. It can be passed to any Observable's `pipe`
* method.
*/
export interface ObservablePipeOperation<I, O> {
(observable: Observable<I>): Observable<O>;
}

/**
* Creates an Observable from the given Observer.
*/
export function createObservable<V>(observer: Observer<V>): Observable<V> {
const observable: Observable<V> = {
subscribe(subscriber) {
return Object.freeze({
unsubscribe: observer(
subscriber.next,
subscriber.error ?? noop
) ?? noop
});
}
};

return Object.freeze(observable);
}

function noop() {
}
19 changes: 19 additions & 0 deletions packages/framework-observable/src/Observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

/**
* An Observer is a function that emits values via its `next` callback. It can
* return a function that handles all logic that must be performed when a
* Subscription is cancelled (e.g. clearTimeout or similar cancellation
* effects).
*/
export interface Observer<V> {
(next: (value: V) => void, fail: (error: any) => void): void | (() => void);
}
67 changes: 67 additions & 0 deletions packages/framework-observable/src/State.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/
import {createState} from './State';

describe('State', () => {
test('get current value', () => {
const state$ = createState(0);

expect(state$.current).toBe(0);

state$.update((value) => value + 1);
expect(state$.current).toBe(1);

state$.update((value) => value + 1);
expect(state$.current).toBe(2);

state$.update((value) => value + 1);
expect(state$.current).toBe(3);
});

test('subscribe to state updates: subscriber receives current value immediately', () => {
const state$ = createState(0);
const subscriber1 = {
next: jest.fn()
};
const subscriber2 = {
next: jest.fn()
};

state$.subscribe(subscriber1);
expect(subscriber1.next).toHaveBeenCalledTimes(1);
expect(subscriber1.next).toHaveBeenNthCalledWith(1, 0);

state$.update((value) => value + 1);
state$.update((value) => value + 1);
state$.update((value) => value + 1);

state$.subscribe(subscriber2);
expect(subscriber2.next).toHaveBeenCalledTimes(1);
expect(subscriber2.next).toHaveBeenNthCalledWith(1, 3);
});

test('subscribe to state updates: subscriber receives all updates', () => {
const state$ = createState(0);
const subscriber = {
next: jest.fn()
};

state$.subscribe(subscriber);
state$.update((value) => value + 1);
state$.update((value) => value + 1);
state$.update((value) => value + 1);

expect(subscriber.next).toHaveBeenCalledTimes(4);
expect(subscriber.next).toHaveBeenNthCalledWith(1, 0);
expect(subscriber.next).toHaveBeenNthCalledWith(2, 1);
expect(subscriber.next).toHaveBeenNthCalledWith(3, 2);
expect(subscriber.next).toHaveBeenNthCalledWith(4, 3);
});
});
61 changes: 61 additions & 0 deletions packages/framework-observable/src/State.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/
import {createObservable, Observable} from './Observable';

/**
* A State is a special kind of Observable that keeps track of a value over
* time.
*
* It has a public readonly `current` property that allows you to ask for
* its current value at any point in time. A new subscriber to the State
* Observable will also immediately receive the current value at the time of
* subscription.
*
* Via the `update` method, a State's value can be modified. When called,
* Subscribers to the state are immediately informed about the new value.
*/
export interface State<V> extends Observable<V> {
readonly current: V;
update: (updateFn: (current: V) => V) => void;
}

/**
* Creates a new State with the given initial value.
*/
export function createState<V>(initialValue: V): State<V> {
let currentState = initialValue;
const listeners = new Set<(value: V) => void>();
const state: State<V> = {
...createObservable((next) => {
listeners.add(next);
next(currentState);

return () => listeners.delete(next);
}),

get current() {
return currentState;
},

update(updateFn) {
const nextState = updateFn(currentState);

if (currentState !== nextState) {
currentState = nextState;

for (const next of listeners) {
next(currentState);
}
}
}
};

return Object.freeze(state);
}
19 changes: 19 additions & 0 deletions packages/framework-observable/src/Subscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

/**
* A Subscriber can be attached to an Observable. It receives values from the
* Observable in its `next` callback function. It may also provide an optional
* `error` callback, that will only be called if the Observable emits an Error.
*/
export interface Subscriber<V> {
next: (value: V) => void;
error?: (error: Error) => void;
}
19 changes: 19 additions & 0 deletions packages/framework-observable/src/Subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

/**
* When attaching a Subscriber to an Observable, a Subscription is returned.
* The `unsubscribe` method of the Subscription allows you to detach the
* Subscriber from the Observable again, after which the Subscriber no longer
* receives any values emitted from the Observable.
*/
export interface Subscription {
unsubscribe: () => void;
}
14 changes: 14 additions & 0 deletions packages/framework-observable/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* This file is part of the Neos.Neos.Ui package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/
export type {Observable} from './Observable';
export {createObservable} from './Observable';

export type {State} from './State';
export {createState} from './State';
6 changes: 6 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2277,6 +2277,12 @@ __metadata:
languageName: node
linkType: hard

"@neos-project/framework-observable@workspace:*, @neos-project/framework-observable@workspace:packages/framework-observable":
version: 0.0.0-use.local
resolution: "@neos-project/framework-observable@workspace:packages/framework-observable"
languageName: unknown
linkType: soft

"@neos-project/jest-preset-neos-ui@workspace:*, @neos-project/jest-preset-neos-ui@workspace:packages/jest-preset-neos-ui":
version: 0.0.0-use.local
resolution: "@neos-project/jest-preset-neos-ui@workspace:packages/jest-preset-neos-ui"
Expand Down

0 comments on commit 439f382

Please sign in to comment.