Skip to content

Commit b1c197f

Browse files
committed
Add "withContract" utility
1 parent c0b021c commit b1c197f

File tree

7 files changed

+248
-5
lines changed

7 files changed

+248
-5
lines changed

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {
1111
export {
1212
useSource as useSourceWithContract,
1313
usePureSource as usePureSourceWithContract,
14+
withContract,
1415
} from './withContract/useSource';
1516

1617
export {
@@ -31,6 +32,7 @@ export {
3132
export {
3233
useSource as useSourceWithContractClient,
3334
usePureSource as usePureSourceWithContractClient,
35+
withContract as withContractClient,
3436
} from './withContract/useSource/client';
3537

3638
export {
@@ -51,11 +53,13 @@ export {
5153
export {
5254
useSource as useSourceWithContractServer,
5355
usePureSource as usePureSourceWithContractServer,
56+
withContract as withContractServer,
5457
} from './withContract/useSource/server';
5558

5659
export {
5760
useSource as useAtomicSourceWithContractServer,
5861
usePureSource as useAtomicPureSourceWithContractServer,
5962
} from './withContract/useAtomicSource/server';
6063

64+
export { useFactory, usePureFactory } from './useFactory';
6165
export { shallowEqual } from './shallowEqual';

src/withContract/useSource/client.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,48 @@ export function usePureSource<Source>(
234234
// This overload returns [useSnapshot, source].
235235
return [useSourceSnapshot, source] as const;
236236
}
237+
238+
/**
239+
* Allows to use a global source safely inside any component.
240+
*
241+
* @param source - the source
242+
* @param contract - function to register a callback that is called whenever the source changes
243+
* @returns an hook to derive snapshots and the source
244+
*/
245+
export function withContract<Source>(
246+
source: Source,
247+
contract: (source: Source, onChange: () => void) => void
248+
) {
249+
const slice = new Slice();
250+
contract(source, slice.update);
251+
252+
/**
253+
* Hook that allows to consume a snapshot of the source.
254+
*
255+
* @param getSnapshot - function that returns a snapshot of the source
256+
* @param getSnapshotDeps - dependency list that defines the "getSnapshot" lifecycle
257+
* @returns the snapshot
258+
*/
259+
function useSourceSnapshot<Snapshot>(
260+
getSnapshot: (source: Source, currentSnapshot: Snapshot | null) => Snapshot,
261+
getSnapshotDeps: DependencyList = []
262+
) {
263+
const getSourceSnapshot = useCallback(
264+
(currentSnapshot: Snapshot | null) =>
265+
getSnapshot(source, currentSnapshot),
266+
// eslint-disable-next-line react-hooks/exhaustive-deps
267+
getSnapshotDeps
268+
);
269+
270+
const snapshot = useSnapshot(getSourceSnapshot, slice);
271+
272+
if (__DEV__) {
273+
// eslint-disable-next-line react-hooks/rules-of-hooks
274+
useDebugValue(snapshot);
275+
}
276+
277+
return snapshot;
278+
}
279+
280+
return [useSourceSnapshot, source, slice] as const;
281+
}

src/withContract/useSource/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import * as client from './client';
22
import * as server from './server';
33
import { isServer } from '../../isServer';
44

5-
export const [useSource, usePureSource] = !isServer
6-
? [client.useSource, client.usePureSource]
7-
: [server.useSource, server.usePureSource];
5+
export const [useSource, usePureSource, withContract] = !isServer
6+
? [client.useSource, client.usePureSource, client.withContract]
7+
: [server.useSource, server.usePureSource, server.withContract];

src/withContract/useSource/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,12 @@ export const usePureSource: typeof client.usePureSource = (
2020
source,
2121
] as const;
2222
};
23+
24+
export const withContract: typeof client.withContract = (source: any) => {
25+
return [
26+
(getSnapshot: (source: any, snapshot: null) => any) =>
27+
getSnapshot(source, null),
28+
source,
29+
null as any,
30+
] as const;
31+
};

test/exports.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as module from '../src';
22

33
it('correctly define the exports', () => {
4+
expect(module.useFactory).toBeDefined();
5+
expect(module.usePureFactory).toBeDefined();
46
expect(module.shallowEqual).toBeDefined();
57

68
expect(module.usePureSourceWithContract).toBeDefined();
@@ -15,6 +17,9 @@ it('correctly define the exports', () => {
1517
expect(module.useSourceWithSubscription).toBeDefined();
1618
expect(module.useSourceWithSubscriptionClient).toBeDefined();
1719
expect(module.useSourceWithSubscriptionServer).toBeDefined();
20+
expect(module.withContract).toBeDefined();
21+
expect(module.withContractClient).toBeDefined();
22+
expect(module.withContractServer).toBeDefined();
1823

1924
expect(module.useAtomicPureSourceWithContract).toBeDefined();
2025
expect(module.useAtomicPureSourceWithContractClient).toBeDefined();

test/utils/values.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function getValues() {
4545
return formattedValues;
4646
}
4747

48-
beforeEach(() => {
48+
afterEach(() => {
4949
// Cleans up the history.
5050
values = [];
5151
});

test/withContracts/useSource.spec.tsx

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { type DependencyList, useEffect } from 'react';
2-
import { useSource, usePureSource } from '../../src/withContract/useSource';
2+
import {
3+
useSource,
4+
usePureSource,
5+
withContract,
6+
} from '../../src/withContract/useSource';
37
import {
48
useSource as useSourceServer,
59
usePureSource as usePureSourceServer,
10+
withContract as withContractServer,
611
} from '../../src/withContract/useSource/server';
712
import { Source } from '../utils/source';
813
import { act, create } from '../utils/renderer';
@@ -645,3 +650,178 @@ describe('how usePureSource works', () => {
645650
]);
646651
});
647652
});
653+
654+
describe('how withContract works', () => {
655+
const [useSnapshot, source] = withContract(
656+
// Generate the source.
657+
new Source(0),
658+
// Subscribe to changes.
659+
(source, onChange) => source.addChangeListener(onChange)
660+
);
661+
662+
// App component.
663+
function App({
664+
getSnapshotDeps = undefined as undefined | DependencyList,
665+
getSnapshotOffset = 0,
666+
}) {
667+
// Consume the snapshot.
668+
const value = useSnapshot(
669+
(source) => source.getValue() + getSnapshotOffset,
670+
getSnapshotDeps
671+
);
672+
673+
// Yield render value.
674+
yieldValue(`render`, `snapshot(${value})`);
675+
676+
useEffect(() => {
677+
// Value 2 triggers a new update with value 3.
678+
if (value === 2) {
679+
source.setValue(3);
680+
}
681+
682+
// Yield effect values.
683+
yieldValue(`effect`, `source(${source.getValue()}) snapshot(${value})`);
684+
});
685+
686+
return null;
687+
}
688+
689+
beforeEach(() => {
690+
source.setValue(0);
691+
});
692+
693+
it('getSnapshot changes every time a dependency changes', () => {
694+
const root = create({ strictMode: true });
695+
696+
// Mounts the App with no dependencies.
697+
act(() => root.update(<App getSnapshotOffset={0} getSnapshotDeps={[0]} />));
698+
699+
expect(getValues()).toEqual([
700+
'render: snapshot(0)',
701+
'render: snapshot(0)',
702+
// First lifecycle.
703+
'effect: source(0) snapshot(0)',
704+
// Second lifecycle.
705+
'effect: source(0) snapshot(0)',
706+
]);
707+
708+
// Triggers an update.
709+
act(() => root.update(<App getSnapshotOffset={1} getSnapshotDeps={[1]} />));
710+
711+
expect(getValues()).toEqual([
712+
'render: snapshot(1)',
713+
'render: snapshot(1)',
714+
'effect: source(0) snapshot(1)',
715+
]);
716+
717+
act(() => root.unmount());
718+
});
719+
720+
it('getSnapshot is stable if no getSnapshot dependencies are provided', () => {
721+
const root = create({ strictMode: true });
722+
723+
// Mounts the App with no dependencies.
724+
act(() => root.update(<App getSnapshotOffset={0} />));
725+
726+
expect(getValues()).toEqual([
727+
'render: snapshot(0)',
728+
'render: snapshot(0)',
729+
// First lifecycle.
730+
'effect: source(0) snapshot(0)',
731+
// Second lifecycle.
732+
'effect: source(0) snapshot(0)',
733+
]);
734+
735+
// Triggers an update.
736+
act(() => root.update(<App getSnapshotOffset={1} />));
737+
738+
expect(getValues()).toEqual([
739+
'render: snapshot(0)',
740+
'render: snapshot(0)',
741+
'effect: source(0) snapshot(0)',
742+
]);
743+
744+
act(() => root.unmount());
745+
});
746+
747+
it('correctly dispatches snapshot updates', () => {
748+
const root = create({ strictMode: true });
749+
750+
// Mount the App.
751+
act(() => root.update(<App getSnapshotOffset={0} />));
752+
753+
expect(getValues()).toEqual([
754+
'render: snapshot(0)',
755+
'render: snapshot(0)',
756+
// First lifecycle.
757+
'effect: source(0) snapshot(0)',
758+
// Second lifecycle
759+
'effect: source(0) snapshot(0)',
760+
]);
761+
762+
// Dispatch an update with a new value.
763+
act(() => source.setValue(1));
764+
765+
expect(getValues()).toEqual([
766+
'render: snapshot(1)',
767+
'render: snapshot(1)',
768+
'effect: source(1) snapshot(1)',
769+
]);
770+
771+
// Dispatch an update with the current value.
772+
act(() => source.setValue(1));
773+
774+
expect(getValues()).toEqual([]);
775+
776+
// Re-render with no changes.
777+
act(() => root.update(<App />));
778+
779+
expect(getValues()).toEqual([
780+
'render: snapshot(1)',
781+
'render: snapshot(1)',
782+
'effect: source(1) snapshot(1)',
783+
]);
784+
785+
// Update with the special value 2, The component will trigger a new update
786+
// with the value 3 inside an effect.
787+
act(() => source.setValue(2));
788+
789+
expect(getValues()).toEqual([
790+
'render: snapshot(2)',
791+
'render: snapshot(2)',
792+
'effect: source(3) snapshot(2)',
793+
'render: snapshot(3)',
794+
'render: snapshot(3)',
795+
'effect: source(3) snapshot(3)',
796+
]);
797+
798+
act(() => root.unmount());
799+
});
800+
801+
it('acts correctly during ssr', () => {
802+
const [useSnapshot] = withContractServer(
803+
// Generate the source.
804+
new Source(0),
805+
// Subscribe to changes.
806+
(source, onChange) => {
807+
yieldValue(`never`, `this is never called`);
808+
source.addChangeListener(onChange);
809+
}
810+
);
811+
812+
const App = () => {
813+
const snapshot = useSnapshot(() => null);
814+
815+
yieldValue(`render`, `snapshot(${snapshot})`);
816+
return null;
817+
};
818+
819+
const root = create();
820+
// Mounts the App.
821+
act(() => root.update(<App />));
822+
823+
expect(getValues()).toEqual(['render: snapshot(null)']);
824+
825+
act(() => root.unmount());
826+
});
827+
});

0 commit comments

Comments
 (0)