Skip to content

Commit c135359

Browse files
authored
Add allowPropagateDuringRender (#2)
* Add allowPropagateDuringRender * Fix tests
1 parent 89e6ee8 commit c135359

File tree

9 files changed

+140
-127
lines changed

9 files changed

+140
-127
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- First public release
13+
- Added `allowPropagateDuringRender` option as a safety measure to prevent multiple re-render, by [@compulim](https://github.com/compulim) in PR [#2](https://github.com/compulim/use-propagate/pull/2)
1314

1415
### Changed
1516

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ export function createPropagation<T>(): {
6868

6969
When propagating a value via `useContext`, subscribing nodes will be re-rendered. This behavior may not be desirable for events and certain type of scenarios.
7070

71+
### Why I should not call propagate callback function during render-time?
72+
73+
When the propagate callback function is called during rendering, a warning message will be printed and propagation will be stopped.
74+
75+
This is a safety measure to prevent multiple re-render and potential deadlock situation if listeners save the value to a state and trigger another re-render.
76+
77+
If listeners are controlled and would never trigger re-render, you can pass `allowPropagateDuringRender: true` option to ignore this safety measure.
78+
7179
### How to get response from the listener or wait for the listener to complete?
7280

7381
Modifies the passing value by following the [`FetchEvent.respondWith`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith) or [`ExtendableEvent.waitUntil`](https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil) pattern.
@@ -87,6 +95,8 @@ const MyComponent = () => {
8795
};
8896
```
8997

98+
Please make sure the propagate callback function is not called during render as it could cause multiple re-render and potential deadlock situation.
99+
90100
## Contributions
91101

92102
Like us? [Star](https://github.com/compulim/use-propagate/stargazers) us.

packages/integration-test/importDefault.test.mjs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,22 @@ describe('A propagation', () => {
1818

1919
describe('when render initially', () => {
2020
let listener;
21-
let result;
21+
let propagate;
2222

2323
beforeEach(() => {
2424
listener = jest.fn();
2525

26-
result = renderHook(
27-
({ value }) => {
28-
const propagate = usePropagate();
26+
renderHook(() => {
27+
propagate = usePropagate();
2928

30-
useListen(listener);
31-
32-
if (typeof value !== 'undefined') {
33-
propagate(value);
34-
}
35-
},
36-
{ initialProps: {} }
37-
);
29+
useListen(listener);
30+
});
3831
});
3932

4033
test('listener should not fire', () => expect(listener).toHaveBeenCalledTimes(0));
4134

4235
describe('when usePropagate() is called', () => {
43-
beforeEach(() => result.rerender({ value: 'Hello, World!' }));
36+
beforeEach(() => propagate('Hello, World!'));
4437

4538
test('listener should be called once', () => expect(listener).toHaveBeenCalledTimes(1));
4639
test('listener should have been called with the value', () =>

packages/integration-test/requireDefault.test.cjs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,22 @@ describe('A propagation', () => {
1818

1919
describe('when render initially', () => {
2020
let listener;
21-
let result;
21+
let propagate;
2222

2323
beforeEach(() => {
2424
listener = jest.fn();
2525

26-
result = renderHook(
27-
({ value }) => {
28-
const propagate = usePropagate();
26+
renderHook(() => {
27+
propagate = usePropagate();
2928

30-
useListen(listener);
31-
32-
if (typeof value !== 'undefined') {
33-
propagate(value);
34-
}
35-
},
36-
{ initialProps: {} }
37-
);
29+
useListen(listener);
30+
});
3831
});
3932

4033
test('listener should not fire', () => expect(listener).toHaveBeenCalledTimes(0));
4134

4235
describe('when usePropagate() is called', () => {
43-
beforeEach(() => result.rerender({ value: 'Hello, World!' }));
36+
beforeEach(() => propagate('Hello, World!'));
4437

4538
test('listener should be called once', () => expect(listener).toHaveBeenCalledTimes(1));
4639
test('listener should have been called with the value', () =>

packages/integration-test/simple.test.mjs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
/** @jest-environment jsdom */
22

3-
import { act, renderHook } from '@testing-library/react';
3+
import { act } from '@testing-library/react';
44
import { createPropagation } from 'use-propagate';
55

6+
const renderHook =
7+
// eslint-disable-next-line @typescript-eslint/no-var-requires
8+
require('@testing-library/react').renderHook ||
9+
// eslint-disable-next-line @typescript-eslint/no-var-requires
10+
require('@testing-library/react-hooks').renderHook;
11+
612
test('When a value is being propagated, listener should receive the value', () => {
7-
const { Provider, useListen, usePropagate } = createPropagation();
13+
const { useListen, usePropagate } = createPropagation();
814
const listener = jest.fn();
15+
let propagate;
916

10-
const result = renderHook(
11-
({ value }) => {
12-
const propagate = usePropagate();
13-
14-
useListen(listener);
17+
renderHook(() => {
18+
propagate = usePropagate();
1519

16-
typeof value === 'undefined' || propagate(value);
17-
},
18-
{ initialProps: {}, wrapper: Provider }
19-
);
20+
useListen(listener);
21+
});
2022

2123
expect(listener).toHaveBeenCalledTimes(0);
2224

23-
act(() => result.rerender({ value: 'Hello, World!' }));
25+
act(() => propagate('Hello, World!'));
2426

2527
expect(listener).toHaveBeenCalledTimes(1);
2628
expect(listener).toHaveBeenNthCalledWith(1, 'Hello, World!');

packages/use-propagate/src/createPropagation.spec.tsx

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,32 +31,25 @@ describe('A propagation', () => {
3131

3232
describe('when render initially', () => {
3333
let listener: jest.Mock<void, [number]>;
34-
let result: RenderHookResult<void, Props>;
34+
let propagate: (value: number) => void;
3535

3636
beforeEach(() => {
3737
listener = jest.fn<void, [number]>();
3838

39-
result = renderHook<void, Props>(
40-
({ value }) => {
41-
const propagate = usePropagate();
39+
renderHook<void, Props>(() => {
40+
propagate = usePropagate();
4241

43-
useListen(listener);
44-
45-
if (typeof value !== 'undefined') {
46-
propagate(value);
47-
}
48-
},
49-
{ initialProps: {} }
50-
);
42+
useListen(listener);
43+
});
5144
});
5245

5346
test('listener should not fire', () => expect(listener).toHaveBeenCalledTimes(0));
5447

5548
describe('when usePropagate() is called', () => {
56-
beforeEach(() => result.rerender({ value: 1 }));
49+
beforeEach(() => act(() => propagate(123)));
5750

5851
test('listener should be called once', () => expect(listener).toHaveBeenCalledTimes(1));
59-
test('listener should have been called with the value', () => expect(listener).toHaveBeenNthCalledWith(1, 1));
52+
test('listener should have been called with the value', () => expect(listener).toHaveBeenNthCalledWith(1, 123));
6053
});
6154
});
6255

@@ -139,4 +132,62 @@ describe('A propagation', () => {
139132
});
140133
});
141134
});
135+
136+
describe('when propagated during render-time', () => {
137+
let listener: jest.Mock<void, [number]>;
138+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
139+
let spy: jest.SpyInstance<void, any[], any>;
140+
141+
beforeEach(() => {
142+
spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
143+
listener = jest.fn<void, [number]>();
144+
145+
renderHook<void, Props>(() => {
146+
useListen(listener);
147+
usePropagate()(123);
148+
});
149+
});
150+
151+
afterEach(() => spy.mockRestore());
152+
153+
test('should warn', () => {
154+
expect(spy).toHaveBeenCalledTimes(1);
155+
expect(spy).toHaveBeenNthCalledWith(1, expect.stringMatching(/^use-propagate:\s/u));
156+
});
157+
158+
test('should not call listener', () => expect(listener).toHaveBeenCalledTimes(0));
159+
});
160+
});
161+
162+
describe('A propagation with allowPropagateDuringRender set to true', () => {
163+
let useListen: ReturnType<typeof createPropagation<number>>['useListen'];
164+
let usePropagate: ReturnType<typeof createPropagation<number>>['usePropagate'];
165+
166+
beforeEach(() => {
167+
({ useListen, usePropagate } = createPropagation<number>({ allowPropagateDuringRender: true }));
168+
});
169+
170+
describe('when propagated during render-time', () => {
171+
let listener: jest.Mock<void, [number]>;
172+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
173+
let spy: jest.SpyInstance<void, any[], any>;
174+
175+
beforeEach(() => {
176+
spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
177+
listener = jest.fn<void, [number]>();
178+
179+
renderHook<void, Props>(() => {
180+
useListen(listener);
181+
usePropagate()(123);
182+
});
183+
});
184+
185+
afterEach(() => spy.mockRestore());
186+
187+
test('should not warn', () => expect(spy).toHaveBeenCalledTimes(0));
188+
test('should have called the listener', () => {
189+
expect(listener).toHaveBeenCalledTimes(1);
190+
expect(listener).toHaveBeenNthCalledWith(1, 123);
191+
});
192+
});
142193
});

packages/use-propagate/src/createPropagation.tsx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,53 @@
1-
import { useEffect } from 'react';
1+
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react';
22
import { useRefFrom } from 'use-ref-from';
33

4-
import removeInline from './private/removeInline';
5-
64
type Listener<T> = (value: T) => void;
75

8-
export default function createPropagation<T>() {
6+
type Init = {
7+
/**
8+
* `true` to allows calling propagate callback function during render-time, otherwise, `false` (default).
9+
*
10+
* Propagation during render-time is normally discouraged. If listeners save the value into a state, multiple re-render could occur.
11+
* This option prevents render deadlock by disallowing propagation during render-time.
12+
*/
13+
allowPropagateDuringRender?: boolean | undefined;
14+
};
15+
16+
export default function createPropagation<T>(init: Init = {}) {
917
type Fn = Listener<T>;
1018

11-
const listeners: Fn[] = [];
19+
let rendering: boolean = false;
20+
21+
const { allowPropagateDuringRender } = init;
22+
const listeners: Set<Fn> = new Set();
23+
24+
const addListener = (listener: Fn): void => void listeners.add(listener);
25+
const removeListener = (listener: Fn): void => void listeners.delete(listener);
26+
const usePropagate = () => {
27+
rendering = true;
28+
29+
useLayoutEffect(() => {
30+
rendering = false;
31+
});
1232

13-
const addListener = (listener: Fn): void => void listeners.push(listener);
14-
const removeListener = (listener: Fn): void => removeInline(listeners, listener);
15-
const usePropagate = () => (value: T) => listeners.forEach(listener => listener(value));
33+
return (value: T) => {
34+
if (rendering && !allowPropagateDuringRender) {
35+
return console.warn(
36+
'use-propagate: The propagate callback function should not be called while rendering, ignoring the call.'
37+
);
38+
}
39+
40+
listeners.forEach(listener => listener(value));
41+
};
42+
};
1643

1744
return {
1845
useListen: (listener: Fn) => {
1946
const listenerRef = useRefFrom(listener);
47+
const wrappingListener = useCallback((value: T) => listenerRef.current(value), [listenerRef]);
2048

21-
useEffect(() => {
22-
const wrappingListener = (value: T) => listenerRef.current(value);
23-
24-
addListener(wrappingListener);
25-
26-
return () => removeListener(wrappingListener);
27-
}, [listenerRef]);
49+
useMemo(() => addListener(wrappingListener), [wrappingListener]);
50+
useEffect(() => () => removeListener(wrappingListener), [wrappingListener]);
2851
},
2952
usePropagate
3053
};

packages/use-propagate/src/private/removeInline.spec.ts

Lines changed: 0 additions & 51 deletions
This file was deleted.

packages/use-propagate/src/private/removeInline.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)