Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: improve subscription api #3

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions packages/history-utility/docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

- [History](modules.md#history)
- [HistoryNode](modules.md#historynode)
- [SkipSubscribeOrCallback](modules.md#skipsubscribeorcallback)

### Functions

Expand All @@ -27,15 +28,16 @@

#### Type declaration

| Name | Type | Description |
| :------ | :----------------------------------------------- | :-------------------------------------------------------------- |
| `index` | `number` | the history index of the current snapshot |
| `nodes` | [`HistoryNode`](modules.md#historynode)\<`T`\>[] | the nodes of the history for each change |
| `wip?` | `Snapshot`\<`T`\> | field for holding sandbox changes; used to avoid infinite loops |
| Name | Type | Description |
| :------------ | :----------------------------------------------- | :-------------------------------------------------------------- |
| `index` | `number` | the history index of the current snapshot |
| `nodes` | [`HistoryNode`](modules.md#historynode)\<`T`\>[] | the nodes of the history for each change |
| `unsubscribe` | `ReturnType`\<typeof `subscribe`\> | a function to stop the internal subscription process |
| `wip?` | `Snapshot`\<`T`\> | field for holding sandbox changes; used to avoid infinite loops |

#### Defined in

[packages/history-utility/src/history-utility.ts:26](https://github.com/valtiojs/valtio-history/blob/86c1430/packages/history-utility/src/history-utility.ts#L26)
[packages/history-utility/src/history-utility.ts:26](https://github.com/valtiojs/valtio-history/blob/30951d0/packages/history-utility/src/history-utility.ts#L26)

---

Expand All @@ -59,22 +61,33 @@

#### Defined in

[packages/history-utility/src/history-utility.ts:10](https://github.com/valtiojs/valtio-history/blob/86c1430/packages/history-utility/src/history-utility.ts#L10)
[packages/history-utility/src/history-utility.ts:10](https://github.com/valtiojs/valtio-history/blob/30951d0/packages/history-utility/src/history-utility.ts#L10)

---

### SkipSubscribeOrCallback

Ƭ **SkipSubscribeOrCallback**: `boolean` \| `SubscribeCallback`

A field to either enable/disable the internal subscribe functionality.
Optionally a callback function can be provided to hook into the
internal subscribe handler.

#### Defined in

[packages/history-utility/src/history-utility.ts:52](https://github.com/valtiojs/valtio-history/blob/30951d0/packages/history-utility/src/history-utility.ts#L52)

## Functions

### proxyWithHistory

▸ **proxyWithHistory**\<`V`\>(`initialValue`, `skipSubscribe?`): `Object`
▸ **proxyWithHistory**\<`V`\>(`initialValue`, `skipSubscribeOrCallback?`): `Object`

This creates a new proxy with history support (ProxyHistoryObject).
It includes following main properties:<br>

- value: any value (does not have to be an object)<br>
- history: an object holding the history of snapshots and other metadata<br>
- history.index: the history index of the current snapshot<br>
- history.nodes: the nodes of the history for each change<br>
- history.wip: field for holding sandbox changes; used to avoid infinite loops<br>
- canUndo: a function to return true if undo is available <br>
- undo: a function to go back history <br>
- canRedo: a function to return true if redo is available <br>
Expand All @@ -97,10 +110,10 @@ Notes: <br>

#### Parameters

| Name | Type | Default value | Description |
| :-------------- | :-------- | :------------ | :---------------------------------------------------------------- |
| `initialValue` | `V` | `undefined` | any object to track |
| `skipSubscribe` | `boolean` | `false` | determines if the internal subscribe behaviour should be skipped. |
| Name | Type | Default value | Description |
| :------------------------ | :-------------------------------------------------------------- | :------------ | :----------------------------------------------------------------------------------------------------------------- |
| `initialValue` | `V` | `undefined` | any object to track |
| `skipSubscribeOrCallback` | [`SkipSubscribeOrCallback`](modules.md#skipsubscribeorcallback) | `false` | determines if the internal subscribe behaviour should be skipped. Optionally, a callback function can be provided. |

#### Returns

Expand All @@ -116,7 +129,7 @@ proxyObject
| `getCurrentChangeDate` | () => `undefined` \| `Date` | get the date when a node was entered into history. |
| `getNode` | (`index`: `number`) => `undefined` \| \{ `createdAt`: `Date` ; `snapshot`: `Snapshot`\<`V`\> ; `updatedAt?`: `Date` } | utility method to get a history node. The snapshot within this node is already cloned and will not affect the original value if updated. |
| `goTo` | (`index`: `number`) => `void` | a function to go to a specific index in history |
| `history` | [`History`](modules.md#history)\<`V`\> & `AsRef` | an object holding the history of snapshots and other metadata <br> - history.index: the history index to the current snapshot <br> - history.nodes: the nodes of the history for each change <br> - history.wip: field for holding sandbox changes; used to avoid infinite loops<br> |
| `history` | [`History`](modules.md#history)\<`V`\> & `AsRef` | an object holding the history of snapshots and other metadata <br> - history.index: the history index to the current snapshot <br> - history.nodes: the nodes of the history for each change <br> - history.wip: field for holding sandbox changes; used to avoid infinite loops <br> - history.unsubscribe: a function to stop the internal subscription process <br> |
| `redo` | () => `void` | a function to go forward in history |
| `remove` | (`index`: `number`) => `undefined` \| [`HistoryNode`](modules.md#historynode)\<`V`\> | The remove method is only invoked when there are more than one nodes and when a valid index is provided. If the current index is removed, An index greater than the current index will be preferred as the next value. |
| `replace` | (`index`: `number`, `value`: `INTERNAL_Snapshot`\<`V`\>) => `void` | utility to replace a value in history. The history changes will not be affected, only the value to be replaced. If a base value is needed to operate on, the `getNode` utility can be used to retrieve a cloned historyNode. <br> <br> Notes: <br> - No operations are done on the value provided to this utility. <br> - This is an advanced method, please ensure the value provided is a snapshot of the same type of the value being tracked. <br> |
Expand All @@ -136,4 +149,4 @@ const state = proxyWithHistory({

#### Defined in

[packages/history-utility/src/history-utility.ts:94](https://github.com/valtiojs/valtio-history/blob/86c1430/packages/history-utility/src/history-utility.ts#L94)
[packages/history-utility/src/history-utility.ts:105](https://github.com/valtiojs/valtio-history/blob/30951d0/packages/history-utility/src/history-utility.ts#L105)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { HistoryNode, proxyWithHistory } from '../history-utility';

Expand All @@ -7,6 +7,10 @@ const mapNumbers = (node: HistoryNode<{ count: number }>) =>

describe('proxyWithHistory: vanilla', () => {
describe('basic', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should provide basic history functionality', async () => {
const state = proxyWithHistory({ count: 0 });
await Promise.resolve();
Expand All @@ -25,6 +29,27 @@ describe('proxyWithHistory: vanilla', () => {
expect(state.value.count).toEqual(1);
});

it('should call subscribe callback when provided', async () => {
const callback = vi.fn();
const state = proxyWithHistory({ count: 0 }, callback);
await Promise.resolve();
expect(state.value.count).toEqual(0);

state.value.count += 1;
await Promise.resolve();
expect(state.value.count).toEqual(1);

state.undo();
await Promise.resolve();
expect(state.value.count).toEqual(0);

state.redo();
await Promise.resolve();
expect(state.value.count).toEqual(1);

expect(callback).toHaveBeenCalledTimes(3);
});

it('should provide basic sequential undo functionality', async () => {
const state = proxyWithHistory({ count: 0 });
await Promise.resolve();
Expand Down
49 changes: 33 additions & 16 deletions packages/history-utility/src/history-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,21 @@ export type History<T> = {
* the history index of the current snapshot
*/
index: number;
/**
* a function to stop the internal subscription process
*/
unsubscribe: ReturnType<typeof subscribe>;
};

type SubscribeOps = Parameters<Parameters<typeof subscribe>[1]>[0];
type SubscribeCallback = (ops: SubscribeOps, historySaved: boolean) => void;
/**
* A field to either enable/disable the internal subscribe functionality.
* Optionally a callback function can be provided to hook into the
* internal subscribe handler.
*/
export type SkipSubscribeOrCallback = boolean | SubscribeCallback;

const isObject = (value: unknown): value is object =>
!!value && typeof value === 'object';

Expand All @@ -64,9 +77,6 @@ const deepClone = <T>(value: T): T => {
* It includes following main properties:<br>
* - value: any value (does not have to be an object)<br>
* - history: an object holding the history of snapshots and other metadata<br>
* - history.index: the history index of the current snapshot<br>
* - history.nodes: the nodes of the history for each change<br>
* - history.wip: field for holding sandbox changes; used to avoid infinite loops<br>
* - canUndo: a function to return true if undo is available <br>
* - undo: a function to go back history <br>
* - canRedo: a function to return true if redo is available <br>
Expand All @@ -82,7 +92,8 @@ const deepClone = <T>(value: T): T => {
* - Suspense/promise is not supported. <br>
*
* @param initialValue - any object to track
* @param skipSubscribe - determines if the internal subscribe behaviour should be skipped.
* @param skipSubscribeOrCallback - determines if the internal subscribe behaviour should be skipped. Optionally,
* a callback function can be provided.
* @returns proxyObject
*
* @example
Expand All @@ -91,7 +102,10 @@ const deepClone = <T>(value: T): T => {
* count: 1,
* })
*/
export function proxyWithHistory<V>(initialValue: V, skipSubscribe = false) {
export function proxyWithHistory<V>(
initialValue: V,
skipSubscribeOrCallback: SkipSubscribeOrCallback = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may consider making the second argument option object for future extension.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup true

) {
const proxyObject = proxy({
/**
* any value to be tracked (does not have to be an object)
Expand All @@ -101,12 +115,14 @@ export function proxyWithHistory<V>(initialValue: V, skipSubscribe = false) {
* an object holding the history of snapshots and other metadata <br>
* - history.index: the history index to the current snapshot <br>
* - history.nodes: the nodes of the history for each change <br>
* - history.wip: field for holding sandbox changes; used to avoid infinite loops<br>
* - history.wip: field for holding sandbox changes; used to avoid infinite loops <br>
* - history.unsubscribe: a function to stop the internal subscription process <br>
*/
history: ref<History<V>>({
wip: undefined, // to avoid infinite loop
nodes: [],
index: -1,
unsubscribe: () => {},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if having unsubscribe here is a reasonable abstraction. I'd prefer paring subscribe and unsubscribe. I don't have a good idea, because it subscribe behind the scene, but if we have unsubscribe here, we would feel like supporting more edge cases like subscribing twice, unsubscribing twice and so on.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i actually tried this but i would have to wrap unsubscribe with ref as well which didnt work out for me

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is because it is assigned after subscribe and artificially changes the history nodes

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer either of these patterns:

const unsubscribe = foo.subscribe(...);
const unsubscribe = subscribe(foo, ...);
subscribe(foo, ...); unsubscribe(foo, ...);

in our case, the last one would be possible. but at the same time, it wouldn't look very nice.

back up a little bit, what's the motivation of unsubscribe in this case, and what are the use cases?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly added the unsubscribe method as a way to do cleanup for eg. if a component in react unmounts an d the subscription is no longer needed.

if this is not needed then i guess we can remove it for now.

if it is needed then maybe we move both subscribe and unsubscribe into history

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One solution I could think of without changing the code is:

  • note clarify that the default behavior is not able to clean up.
  • if one needs to unsubscribe, they should use skipSubscribe=true and manually subscribe().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i actually tried this but i would have to wrap unsubscribe with ref as well which didnt work out for me

I think you can define unsubscribe method, and internally call a function in the history ref. That seems acceptable.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

K I will stash this change then and split the pr

Copy link
Collaborator Author

@lwhiteley lwhiteley Jan 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if one needs to unsubscribe, they should use skipSubscribe=true and manually subscribe().

Will go with this for now.

in the next PR (#5 ), I however, extracted the utility to check if the history save should happen. this should reduce the amount of functionality to copy/rewrite for the consumer

}),
/**
* get the date when a node was entered into history.
Expand Down Expand Up @@ -196,14 +212,15 @@ export function proxyWithHistory<V>(initialValue: V, skipSubscribe = false) {
*/
subscribe: () =>
subscribe(proxyObject, (ops) => {
if (
ops.every(
(op) =>
op[1][0] === 'value' &&
(op[0] !== 'set' || op[2] !== proxyObject.history.wip)
)
) {
proxyObject.saveHistory();
const shouldSaveHistory = ops.every(
(op) =>
op[1][0] === 'value' &&
(op[0] !== 'set' || op[2] !== proxyObject.history.wip)
);

if (shouldSaveHistory) proxyObject.saveHistory();
if (typeof skipSubscribeOrCallback === 'function') {
skipSubscribeOrCallback(ops, shouldSaveHistory);
}
}),

Expand Down Expand Up @@ -283,8 +300,8 @@ export function proxyWithHistory<V>(initialValue: V, skipSubscribe = false) {

proxyObject.saveHistory();

if (!skipSubscribe) {
proxyObject.subscribe();
if (skipSubscribeOrCallback !== true) {
proxyObject.history.unsubscribe = proxyObject.subscribe();
}

return proxyObject;
Expand Down