Skip to content

Commit

Permalink
Merge pull request #3792 from neos/task/centralize-error-handling
Browse files Browse the repository at this point in the history
!!!FEATURE: Centralize error handling
  • Loading branch information
mhsdesign authored Dec 16, 2024
2 parents f1b2b8a + ebd76a5 commit b22d46b
Show file tree
Hide file tree
Showing 51 changed files with 1,075 additions and 333 deletions.
120 changes: 120 additions & 0 deletions packages/framework-observable-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# @neos-project/framework-observable-react

> React bindings for @neos-project/framework-observable
This package provides a set of React [hooks](https://react.dev/reference/react/hooks) to let components interact with `Observable`s.

## API

### `useLatestValueFrom`

```typescript
// Without default value:
function useLatestValueFrom<V>(observable$: Observable<V>): null | V;

// With default value:
function useLatestValueFrom<V, D>(
observable$: Observable<V>,
defaultValue: D
): D | V;
```

`useLatestValueFrom` is a way to bind a react component the latest value emitted from an `Observable`.

#### Parameters

| Name | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------- |
| `observable$` | The `Observable` to subscribe to |
| `defaultValue` (optional) | The value to default for when `observable$` hasn't emitted any values yet (defaults to `null`) |

#### Return Value

This hook returns the latest value from the provided `observable$`. If no value has been emitted from the observable yet, it returns `defaultValue` which itself defaults to `null`.

#### Example

This component will display the amount of seconds that have passed since it was first mounted:

```typescript
const clock$ = createObservable((next) => {
let i = 1;
const interval = setInterval(() => {
next(i++);
}, 1000);

return () => clearInterval(interval);
});

const MyComponent = () => {
const seconds = useLatestValueFrom(clock$, 0);

return <pre>{seconds} seconds passed</pre>;
};
```

You can combine this with `React.useMemo`, if you wish to create an ad-hoc observable:

```typescript
const MyComponent = (props) => {
const beats = useLatestValueFrom(
React.useMemo(
() =>
createObservable((next) => {
let i = 1;
const interval = setInterval(() => {
next(i++);
}, props.millisecondsPerBeat);

return () => clearInterval(interval);
}),
[props.millisecondsPerBeat]
),
0
);

return <pre>{beats} beats passed</pre>;
};
```

### `useLatestState`

```typescript
function useLatestState<V>(state$: State<V>): V;
```

`useLatestState` subscribes to a given state observable and keeps track of its latest value.

#### Parameters

| Name | Description |
| -------- | --------------------------------------- |
| `state$` | The `State` observable to keep track of |

#### Return Value

This hook returns the latest value from the given `State` observable. Initially it contains the current value of the `State` at the moment the component was first mounted.

#### Example

```typescript
const count$ = createState(0);

const MyComponent = () => {
const count = useLatestState(count$);
const handleInc = React.useCallback(() => {
count$.update((count) => count + 1);
}, []);
const handleDec = React.useCallback(() => {
count$.update((count) => count - 1);
}, []);

return (
<div>
<pre>Count {count}</pre>
<button onClick={handleInc}>+</button>
<button onClick={handleDec}>-</button>
</div>
);
};
```
12 changes: 12 additions & 0 deletions packages/framework-observable-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@neos-project/framework-observable-react",
"version": "",
"description": "React bindings for @neos-project/framework-observable",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@neos-project/framework-observable": "workspace:*",
"react": "^16.12.0"
},
"license": "GNU GPLv3"
}
11 changes: 11 additions & 0 deletions packages/framework-observable-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* 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 {useLatestState} from './useLatestState';
export {useLatestValueFrom} from './useLatestValueFrom';
16 changes: 16 additions & 0 deletions packages/framework-observable-react/src/useLatestState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* 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 {State} from '@neos-project/framework-observable';

import {useLatestValueFrom} from './useLatestValueFrom';

export function useLatestState<V>(state$: State<V>) {
return useLatestValueFrom(state$, state$.current);
}
41 changes: 41 additions & 0 deletions packages/framework-observable-react/src/useLatestValueFrom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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 React from 'react';

import type {Observable} from '@neos-project/framework-observable';

export function useLatestValueFrom<V>(observable$: Observable<V>): null | V;
export function useLatestValueFrom<V, D>(
observable$: Observable<V>,
defaultValue: D
): D | V;

export function useLatestValueFrom<V, D>(
observable$: Observable<V>,
defaultValue?: D
) {
const [value, setValue] = React.useState<null | D | V>(
defaultValue ?? null
);

React.useEffect(() => {
const subscription = observable$.subscribe({
next: (incomingValue) => {
if (incomingValue !== value) {
setValue(incomingValue);
}
}
});

return () => subscription.unsubscribe();
}, [observable$]);

return value;
}
152 changes: 152 additions & 0 deletions packages/framework-observable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# @neos-project/framework-observable

> Observable pattern implementation for the Neos UI
> [!NOTE]
> This package implements a pattern for which there is a WICG proposal:
> https://github.com/WICG/observable
>
> It is therefore likely that future versions of this package will use the web-native `Observable` primitive under the hood.
## API

### Observables

An `Observable` represents a sequence of values that can be *observed* from the outside. This is a powerful abstraction that allows to encapsule all kinds of value streams like:

- (DOM) Events
- Timeouts & Intervals
- Async operations & Promises
- Websockets
- etc.

An `Observable` can be created using the `createObservable` function like this:

```typescript
const numbers$ = createObservable((next) => {
next(1);
next(2);
next(3);
});
```

> [!NOTE]
> Suffixing variable names with `$` is a common naming convention to signify that a variable represents an observable.
Here, the `numbers$` observable represents the sequence of the numbers 1, 2 and 3. This observable can be subscribed to thusly:

```typescript
numbers$.subscribe((value) => {
console.log(value);
});
```

Because the `numbers$` observable emits its values immediately, the above subscription will immediately log:
```
1
2
3
```

An additional subscription would also immediately receive all 3 values. By default, oberservables are *lazy* and *single-cast*. This means, values are generated exclusively for each subscription, and the generation starts exactly when a subscriber is registered.

The usefulness of observables becomes more apparent when we introduce some asynchrony:
```typescript
const timedNumbers$ = createObservable((next) => {
let i = 1;
const interval = setInterval(() => {
next(i++);
}, 2000);

return () => clearInterval(interval);
});
```

This `timedNumbers$` observable will emit a new value every two seconds. This time, the callback used to facilitate the observable returns a function:
```typescript
// ..
return () => clearInterval(interval);
// ..
```

This function will be called when a subscription is cancelled. This is a way for observables to clean up after themselves.

If we now subscribe to `timedNumbers$` like this:
```typescript
const subscription = timedNumbers$.subscribe((value) => {
console.log(value);
});
```

The following values will be logged to the console:
```
1 (After 2 seconds)
2 (After 4 seconds)
3 (After 6 seconds)
4 (After 8 seconds)
...
```

This will go on forever, unless we call the `unsubscribe` on our `subscription` which has been the return value we've saved from `timedNumber$.subscribe(...)`. When we call `unsubscribe`, the cleanup function of the `timedNumbers$` observable will be called and so the interval will be cleared:
```typescript
subscription.unsubscribe();
```

That's all there is to it. With this small set of tools, `Observable`s can be used to encapsule all kinds of synchronous or asynchronous value streams.

They can be created from a Promise:
```typescript
async function someLongRunningOperation() {
// ...
}

const fromPromise$ = createObservable((next) => {
someLongRunningOperation().then(next);
});
```

Or DOM events:
```typescript
const clicks$ = createObservable((next) => {
const button = document.querySelector('button');
button.addEventListener('click', next);
return () => button.removeEventListener('click', next);
});
```

And there are many, many more possibilities.

### State

A `State` is a special `Observable` that can track a value over time. `State`s can be created using the `createState` function like this:

```typescript
const count$ = createState(0);
```

The `count$` state is now set to `0`. Unlike regular observables, a `State` instance can be queried for its current value:
```typescript
console.log(count$.current); // output: 0
```

Each `State` instance has an `update` method that can be used to push new values to the state observable. It takes a callback that receives the current value as its first paramater and returns the new value:

```typescript
count$.update((value) => value + 1);

console.log(count$.current); // output: 1
```

When a new subscriber is registered to a `State` instance, that subscriber immediately receives the current value:
```typescript
const count$ = createState(0);
count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet
count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet
count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet

count$.subscribe((value) => console.log(value)); // immediately logs: 3

count$.update((value) => value + 1); // logs: 4
```

Unlike regular `Observable`s, `State`s are multi-cast. This means that all subscribers receive updates at the same time, and every subscriber only receives updates that are published after the subscription has been registered.
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"
}
Loading

0 comments on commit b22d46b

Please sign in to comment.