Skip to content

Commit 8f35825

Browse files
committed
Fixed executor subscription
1 parent 8bf9af3 commit 8f35825

File tree

3 files changed

+128
-45
lines changed

3 files changed

+128
-45
lines changed

README.md

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,13 @@ npm install --save-prod react-executor
112112

113113
# Introduction
114114

115-
An executor executes a task, stores the execution result, and provides access to it. Tasks are callbacks that return a
116-
value or throw an error.
115+
An executor runs a task, stores the execution result, and provides access to that result. Tasks are callbacks that
116+
return a value or throw an error.
117117

118118
An [`Executor`](https://smikhalevski.github.io/react-executor/interfaces/react-executor.Executor.html) is created and
119119
managed by
120-
an [`ExecutorManager`](https://smikhalevski.github.io/react-executor/classes/react-executor.ExecutorManager.html)
121-
which controls the executor lifecycle:
120+
an [`ExecutorManager`](https://smikhalevski.github.io/react-executor/classes/react-executor.ExecutorManager.html),
121+
which controls the executor's lifecycle:
122122

123123
```ts
124124
import { ExecutorManager } from 'react-executor';
@@ -129,12 +129,12 @@ const rookyExecutor = manager.getOrCreate('rooky');
129129
// ⮕ Executor<any>
130130
```
131131

132-
Each executor has a unique key in the scope of the manager. Here we created the new executor with the key `'rooky'`.
133-
Managers create a new executor when you call
132+
Each executor has a unique key within the manager's scope. In this example, we created a new executor with the key
133+
`'rooky'`. The manager creates a new executor when you call
134134
[`getOrCreate`](https://smikhalevski.github.io/react-executor/classes/react-executor.ExecutorManager.html#getorcreate)
135-
with a new key. Each consequent call with that key returns the same executor.
135+
with a previously unused key. Subsequent calls with the same key return the same executor instance.
136136

137-
If you want to retrieve an existing executor by its key and don't want to create a new executor if it doesn't exist, use
137+
If you want to retrieve an existing executor by its key and avoid creating a new one if it doesn't exist, use
138138
[`get`](https://smikhalevski.github.io/react-executor/classes/react-executor.ExecutorManager.html#get):
139139

140140
```ts
@@ -145,7 +145,7 @@ manager.get('rooky');
145145
// ⮕ Executor<any>
146146
```
147147

148-
The executor we created is unsettled, which means it neither stores a value, nor a task failure reason:
148+
The executor we created is _unsettled_, meaning it stores neither a value nor a failure reason:
149149

150150
```ts
151151
rookyExecutor.isSettled;
@@ -168,19 +168,19 @@ bobbyExecutor.value;
168168
// ⮕ 42
169169
```
170170

171-
An initial value can be a task which is executed, a promise which the executor awaits, or any other value that instantly
172-
fulfills the executor. Read more in the [Execute a task](#execute-a-task) and in
171+
An initial value may be a task (which will be executed), a promise (which the executor will await), or any other value
172+
that immediately fulfills the executor. Read more in the [Execute a task](#execute-a-task) and in
173173
the [Settle an executor](#settle-an-executor) sections.
174174

175-
When an executor is created, you can provide an array of plugins:
175+
When creating an executor, you can also provide an array of plugins:
176176

177177
```ts
178178
import retryRejected from 'react-executor/plugin/retryRejected';
179179

180180
const rookyExecutor = manager.getOrCreate('rooky', 42, [retryRejected()]);
181181
```
182182

183-
Plugins can subscribe to [executor events](#events-and-lifecycle) or alter the executor instance. Read more about
183+
Plugins can subscribe to [executor events](#events-and-lifecycle) or modify the executor instance. Read more about
184184
plugins in the [Plugins](#plugins) section.
185185

186186
## Executor keys
@@ -2215,24 +2215,20 @@ To detect a global pending state we can rely on events published by
22152215
an [`ExecutorManager`](https://smikhalevski.github.io/react-executor/classes/react-executor.ExecutorManager.html):
22162216

22172217
```ts
2218-
function useGlobalPending(predicate = (executor: Executor) => true): boolean {
2218+
function useIsPending(predicate = (executor: Executor) => true): boolean {
22192219
const manager = useExecutorManager();
22202220
const [isPending, setPending] = useState(false);
22212221

22222222
useEffect(() => {
2223-
const listener = (event: ExecutorEvent) => {
2224-
setPending(
2225-
Array.from(manager)
2226-
.filter(predicate)
2227-
.some(executor => executor.isPending)
2228-
);
2223+
const syncIsPending = (event: ExecutorEvent) => {
2224+
setPending(Array.from(manager).some(executor => predicate(executor) && executor.isPending));
22292225
};
22302226

22312227
// 1️⃣ Ensure isPending is up-to-date after mount
2232-
listener();
2228+
syncIsPending();
22332229

22342230
// 2️⃣ Sync isPending when any event is published
2235-
return manager.subscribe(listener);
2231+
return manager.subscribe(syncIsPending);
22362232
}, [manager]);
22372233

22382234
return isPending;
@@ -2242,7 +2238,7 @@ function useGlobalPending(predicate = (executor: Executor) => true): boolean {
22422238
Now a global pending indicator can be shown when _any_ executor is pending:
22432239

22442240
```tsx
2245-
const isPending = useGlobalPending();
2241+
const isPending = useIsPending();
22462242

22472243
isPending && <LoadingIndicator />;
22482244
```
@@ -2254,17 +2250,14 @@ be marked as such, for example with an annotation:
22542250
const accountExecutor = useExecutor(
22552251
'account',
22562252

2257-
async () => {
2258-
const response = await fetch('/account');
2259-
return response.json();
2260-
},
2253+
() => fetch('/account'),
22612254

22622255
// 1️⃣ Annotate an executor once via a plugin
22632256
[executor => executor.annotate({ isFetching: true })]
22642257
);
22652258

22662259
// 2️⃣ Get global pending status for executors that are fetching data
2267-
const isPending = useGlobalPending(executor => executor.annotations.isFetching);
2260+
const isPending = useIsPending(executor => executor.annotations.isFetching);
22682261
```
22692262

22702263
<!--/ARTICLE-->
Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import type { Executor } from './types.js';
2+
import type { Executor, ExecutorState } from './types.js';
33

44
/**
55
* Re-renders the component when an executor's state is changed.
@@ -12,30 +12,23 @@ export function useExecutorSubscription<Value>(executor: Executor<Value>): Execu
1212
React.useDebugValue(executor, getExecutorStateSnapshot);
1313

1414
if (typeof React.useSyncExternalStore === 'function') {
15-
const subscribe = React.useCallback(executor.subscribe.bind(executor), [executor]);
15+
const getSnapshot = () => getExecutorId(executor) + '.' + executor.version;
1616

17-
const getSnapshot = () => executor.version;
17+
React.useSyncExternalStore(executor.subscribe, getSnapshot, getSnapshot);
1818

19-
React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
20-
21-
React.useEffect(executor.activate.bind(executor), [executor]);
19+
React.useEffect(executor.activate, [executor]);
2220

2321
return executor;
2422
}
2523

2624
const [, setVersion] = React.useState(executor.version);
2725

2826
React.useEffect(() => {
29-
let version = executor.version;
30-
3127
const deactivate = executor.activate();
32-
const unsubscribe = executor.subscribe(() => {
33-
if (version < executor.version) {
34-
setVersion((version = executor.version));
35-
}
36-
});
3728

38-
setVersion(version);
29+
const unsubscribe = executor.subscribe(() => setVersion(executor.version));
30+
31+
setVersion(executor.version);
3932

4033
return () => {
4134
unsubscribe();
@@ -46,6 +39,14 @@ export function useExecutorSubscription<Value>(executor: Executor<Value>): Execu
4639
return executor;
4740
}
4841

49-
function getExecutorStateSnapshot(executor: Executor) {
42+
const executorIds = new WeakMap<Executor, number>();
43+
44+
let executorCount = 0;
45+
46+
function getExecutorId(executor: Executor): number {
47+
return executorIds.get(executor) || (executorIds.set(executor, ++executorCount), executorCount);
48+
}
49+
50+
function getExecutorStateSnapshot(executor: Executor): ExecutorState {
5051
return executor.getStateSnapshot();
5152
}

src/test/useExecutor.test.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
*/
44

55
import { beforeEach, expect, test, vi } from 'vitest';
6-
import { act, renderHook } from '@testing-library/react';
6+
import { act, render, renderHook } from '@testing-library/react';
77
import React, { StrictMode } from 'react';
8-
import { ExecutorManager, ExecutorManagerProvider, useExecutor } from '../main/index.js';
8+
import { ExecutorEvent, ExecutorManager, ExecutorManagerProvider, useExecutor } from '../main/index.js';
99

1010
let testIndex = 0;
1111
let executorKey: string;
1212

13+
vi.useFakeTimers();
14+
1315
beforeEach(() => {
16+
vi.clearAllTimers();
17+
1418
executorKey = 'executor' + testIndex++;
1519
});
1620

@@ -165,3 +169,88 @@ test('re-renders after task execute', async () => {
165169

166170
expect(renderMock).toHaveBeenCalledTimes(6);
167171
});
172+
173+
test('deactivates an executor when key changes after parent component render', async () => {
174+
const task = vi.fn();
175+
const listenerMock = vi.fn();
176+
177+
const manager = new ExecutorManager();
178+
179+
const Parent = (props: { executorKey: string }) => (
180+
<ExecutorManagerProvider value={manager}>
181+
<Child executorKey={props.executorKey} />
182+
</ExecutorManagerProvider>
183+
);
184+
185+
const Child = (props: { executorKey: string }) => {
186+
useExecutor(props.executorKey, task, [executor => executor.subscribe(listenerMock)]);
187+
return null;
188+
};
189+
190+
const { rerender } = await act(() => render(<Parent executorKey={'xxx'} />));
191+
192+
expect(manager['_executors'].size).toBe(1);
193+
194+
expect(manager.get('xxx')!.isActive).toBe(true);
195+
196+
expect(listenerMock).toHaveBeenCalledTimes(4);
197+
expect(listenerMock).toHaveBeenNthCalledWith(1, {
198+
type: 'attached',
199+
target: manager.get('xxx')!,
200+
version: 0,
201+
payload: undefined,
202+
} satisfies ExecutorEvent);
203+
expect(listenerMock).toHaveBeenNthCalledWith(2, {
204+
type: 'pending',
205+
target: manager.get('xxx')!,
206+
version: 1,
207+
payload: undefined,
208+
} satisfies ExecutorEvent);
209+
expect(listenerMock).toHaveBeenNthCalledWith(3, {
210+
type: 'activated',
211+
target: manager.get('xxx')!,
212+
version: 1,
213+
payload: undefined,
214+
} satisfies ExecutorEvent);
215+
expect(listenerMock).toHaveBeenNthCalledWith(4, {
216+
type: 'fulfilled',
217+
target: manager.get('xxx')!,
218+
version: 2,
219+
payload: undefined,
220+
} satisfies ExecutorEvent);
221+
222+
act(() => rerender(<Parent executorKey={'yyy'} />));
223+
224+
vi.runAllTimers();
225+
226+
expect(manager['_executors'].size).toBe(2);
227+
228+
expect(manager.get('xxx')!.isActive).toBe(false);
229+
expect(manager.get('yyy')!.isActive).toBe(true);
230+
231+
expect(listenerMock).toHaveBeenCalledTimes(8);
232+
expect(listenerMock).toHaveBeenNthCalledWith(5, {
233+
type: 'attached',
234+
target: manager.get('yyy')!,
235+
version: 0,
236+
payload: undefined,
237+
} satisfies ExecutorEvent);
238+
expect(listenerMock).toHaveBeenNthCalledWith(6, {
239+
type: 'pending',
240+
target: manager.get('yyy')!,
241+
version: 1,
242+
payload: undefined,
243+
} satisfies ExecutorEvent);
244+
expect(listenerMock).toHaveBeenNthCalledWith(7, {
245+
type: 'deactivated',
246+
target: expect.objectContaining({ key: 'xxx' }),
247+
version: 2,
248+
payload: undefined,
249+
} satisfies ExecutorEvent);
250+
expect(listenerMock).toHaveBeenNthCalledWith(8, {
251+
type: 'activated',
252+
target: manager.get('yyy')!,
253+
version: 1,
254+
payload: undefined,
255+
} satisfies ExecutorEvent);
256+
});

0 commit comments

Comments
 (0)