Skip to content

Commit 35c0409

Browse files
committed
Fixed executor subscription
1 parent 8bf9af3 commit 35c0409

File tree

3 files changed

+107
-42
lines changed

3 files changed

+107
-42
lines changed

README.md

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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-->

src/main/useExecutorSubscription.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import * as React from 'react';
21
import type { Executor } from './types.js';
2+
import { useDebugValue, useEffect, useState } from 'react';
33

44
/**
55
* Re-renders the component when an executor's state is changed.
@@ -9,33 +9,16 @@ import type { Executor } from './types.js';
99
* @param executor The executor to subscribe to.
1010
*/
1111
export function useExecutorSubscription<Value>(executor: Executor<Value>): Executor<Value> {
12-
React.useDebugValue(executor, getExecutorStateSnapshot);
12+
const [, setVersion] = useState(executor.version);
1313

14-
if (typeof React.useSyncExternalStore === 'function') {
15-
const subscribe = React.useCallback(executor.subscribe.bind(executor), [executor]);
16-
17-
const getSnapshot = () => executor.version;
18-
19-
React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
20-
21-
React.useEffect(executor.activate.bind(executor), [executor]);
22-
23-
return executor;
24-
}
25-
26-
const [, setVersion] = React.useState(executor.version);
27-
28-
React.useEffect(() => {
29-
let version = executor.version;
14+
useDebugValue(executor, getExecutorStateSnapshot);
3015

16+
useEffect(() => {
3117
const deactivate = executor.activate();
32-
const unsubscribe = executor.subscribe(() => {
33-
if (version < executor.version) {
34-
setVersion((version = executor.version));
35-
}
36-
});
3718

38-
setVersion(version);
19+
const unsubscribe = executor.subscribe(() => setVersion(executor.version));
20+
21+
setVersion(executor.version);
3922

4023
return () => {
4124
unsubscribe();

src/test/useExecutor.test.tsx

Lines changed: 92 additions & 3 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

@@ -163,5 +167,90 @@ test('re-renders after task execute', async () => {
163167
expect(executor.reason).toBeUndefined();
164168
expect(executor.task).toBe(task);
165169

166-
expect(renderMock).toHaveBeenCalledTimes(6);
170+
expect(renderMock).toHaveBeenCalledTimes(4);
171+
});
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);
167256
});

0 commit comments

Comments
 (0)