Skip to content

Commit a361688

Browse files
authored
Side effect bindProps should be executed by not excessively memoizing things (#100)
* Add tests * Clean up no collision prop * Add tests * Simplify * Add test for side effect * Add entry * Fix typing for react@16 * Fix ESLint
1 parent 4f9e7bf commit a361688

File tree

6 files changed

+369
-49
lines changed

6 files changed

+369
-49
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Changed
1515

16-
- (Preview) 💢 Changed signature to return wrapped return value, instead of plain `ComponentType`, by [@compulim](https://github.com/compulim) in PR [#91](https://github.com/compulim/react-chain-of-responsibility/pull/91), [#92](https://github.com/compulim/react-chain-of-responsibility/pull/92), and [#99](https://github.com/compulim/react-chain-of-responsibility/pull/99)
16+
- (Preview) 💢 Changed signature to return wrapped return value, instead of plain `ComponentType`, by [@compulim](https://github.com/compulim) in PR [#91](https://github.com/compulim/react-chain-of-responsibility/pull/91), [#92](https://github.com/compulim/react-chain-of-responsibility/pull/92), [#99](https://github.com/compulim/react-chain-of-responsibility/pull/99), and [#100](https://github.com/compulim/react-chain-of-responsibility/pull/100)
1717
- Use `handler-chain` package, by [@compulim](https://github.com/compulim) in PR [#93](https://github.com/compulim/react-chain-of-responsibility/pull/93)
1818
- Bumped dependencies, in PR [#97](https://github.com/compulim/react-chain-of-responsibility/pull/97)
1919
- Development dependencies

packages/react-chain-of-responsibility/src/preview/createChainOfResponsibilityAsRenderCallback.tsx

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ type InferProviderProps<T extends InferenceHelper<any, any, any>> = T['~types'][
145145
// eslint-disable-next-line @typescript-eslint/no-explicit-any
146146
type InferRequest<T extends InferenceHelper<any, any, any>> = T['~types']['request'];
147147

148+
function createComponentHandlerResult<Props extends BaseProps>(
149+
render: (overridingProps?: Partial<Props> | undefined) => ReactNode
150+
): ComponentHandlerResult<Props> {
151+
return Object.freeze({ [DO_NOT_CREATE_THIS_OBJECT_YOURSELF]: undefined, render });
152+
}
153+
148154
function createChainOfResponsibility<
149155
Request = void,
150156
Props extends BaseProps = { readonly children?: never },
@@ -172,21 +178,19 @@ function createChainOfResponsibility<
172178

173179
function reactComponent<P extends Props>(
174180
component: ComponentType<P>,
181+
// For `bindProps` of type function, do not do side-effect in it, it may not be always called in all scenarios.
175182
bindProps?:
176183
| (Partial<Props> & Omit<P, keyof Props>)
177184
| ((props: Props) => Partial<Props> & Omit<P, keyof Props>)
178185
| undefined
179186
): ComponentHandlerResult<Props> {
180-
return Object.freeze({
181-
[DO_NOT_CREATE_THIS_OBJECT_YOURSELF]: undefined,
182-
render: (overridingProps?: Partial<Props> | undefined) => (
183-
<ComponentWithProps
184-
bindProps={bindProps}
185-
component={component as ComponentType<Props>}
186-
overridingProps={overridingProps}
187-
/>
188-
)
189-
});
187+
return createComponentHandlerResult((overridingProps?: Partial<Props> | undefined) => (
188+
<ComponentWithProps
189+
bindProps={bindProps}
190+
component={component as ComponentType<Props>}
191+
overridingProps={overridingProps}
192+
/>
193+
));
190194
}
191195

192196
const ComponentWithProps = memo(function ComponentWithProps({
@@ -212,8 +216,6 @@ function createChainOfResponsibility<
212216
return <Component {...props} {...(typeof bindProps === 'function' ? bindProps(props) : bindProps)} />;
213217
});
214218

215-
const RENDER_CALLBACK_SYMBOL = `REACT_CHAIN_OF_RESPONSIBILITY:DO_NOT_USE_THIS_RENDER_CALLBACK`;
216-
217219
const useBuildRenderCallback: () => UseBuildRenderCallback<Request, Props> = () => {
218220
const { enhancer } = useContext(BuildContext);
219221

@@ -234,49 +236,35 @@ function createChainOfResponsibility<
234236
return;
235237
}
236238

237-
return Object.freeze({
238-
// Convert fallback component as renderer.
239-
[DO_NOT_CREATE_THIS_OBJECT_YOURSELF]: undefined,
240-
render: () => (
241-
// Currently, there are no ways to set `bindProps` to `fallbackComponent`.
242-
// `fallbackComponent` do not need `overridingProps` because it is the last one in the chain, it would not have the next() function.
243-
<ComponentWithProps component={fallbackComponent} />
244-
)
245-
});
239+
// `fallbackComponent` do not need `overridingProps` because it is the last one in the chain, it would not have the next() function.
240+
return reactComponent(fallbackComponent);
246241
})(request);
247242

248243
return (
249244
result &&
250-
((props: Props) => (
245+
((originalProps: Props) => (
251246
// This is render function, we cannot call any hooks here.
252-
<RenderCallbackAsComponent
253-
{...props} // Spreading the props to leverage React.memo()
254-
{...{
255-
// TODO: Verify if `result.render` is stable or not, and check performance
256-
[RENDER_CALLBACK_SYMBOL]: result.render
257-
}}
258-
/>
247+
<BuildRenderCallback originalProps={originalProps} render={result.render} />
259248
))
260249
);
261250
},
262251
[enhancer]
263252
);
264253
};
265254

266-
type RenderCallbackAsComponentProps = Props & {
267-
// First render function does not need overrideProps.
268-
// Override props is for upstreamer to override props before passing to downsteamers.
269-
readonly [RENDER_CALLBACK_SYMBOL]: () => ReactNode;
255+
type BuildRenderCallbackProps = {
256+
readonly originalProps: Props;
257+
readonly render: () => ReactNode;
270258
};
271259

272-
const RenderCallbackAsComponent = memo(function RenderFunction({
273-
[RENDER_CALLBACK_SYMBOL]: render,
274-
...props
275-
}: RenderCallbackAsComponentProps) {
276-
const context = useMemo<RenderContextType<Props>>(() => Object.freeze({ originalProps: props as Props }), [props]);
260+
// Do not memoize <BuildRenderCallback>.
261+
// `bindProps` may have side effect and we want to be re-rendered to capture the side-effect.
262+
// To prevent wasted render, web devs should memoize it themselves.
263+
function BuildRenderCallback({ originalProps, render }: BuildRenderCallbackProps) {
264+
const context = useMemo<RenderContextType<Props>>(() => Object.freeze({ originalProps }), [originalProps]);
277265

278266
return <RenderContext.Provider value={context}>{render()}</RenderContext.Provider>;
279-
});
267+
}
280268

281269
function ChainOfResponsibilityProvider({ children, init, middleware }: ProviderProps<Request, Props, Init>) {
282270
if (!Array.isArray(middleware) || middleware.some(middleware => typeof middleware !== 'function')) {
@@ -352,7 +340,7 @@ function createChainOfResponsibility<
352340
return Object.freeze({
353341
Provider: MemoizedChainOfResponsibilityProvider as typeof MemoizedChainOfResponsibilityProvider &
354342
InferenceHelper<Request, Props, Init>,
355-
Proxy: memo<ProxyProps<Request, Props>>(ChainOfResponsibilityProxy),
343+
Proxy: ChainOfResponsibilityProxy,
356344
reactComponent,
357345
useBuildRenderCallback
358346

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/** @jest-environment jsdom */
2+
/// <reference types="@types/jest" />
3+
4+
import { scenario } from '@testduet/given-when-then';
5+
import { render } from '@testing-library/react';
6+
import React, { Fragment } from 'react';
7+
8+
import createChainOfResponsibility, { type InferMiddleware } from '../createChainOfResponsibilityAsRenderCallback';
9+
10+
type Props = { readonly children?: never };
11+
type Request = void;
12+
13+
scenario('side effect in bindProps', bdd => {
14+
bdd
15+
.given('a TestComponent using chain of responsiblity', () => {
16+
const { Provider, Proxy, reactComponent, useBuildRenderCallback } = createChainOfResponsibility<Request, Props>();
17+
18+
let count = 0;
19+
const renderCall = jest.fn();
20+
21+
const MyComponent = function MyComponent({ value }: Props & { readonly value: number }) {
22+
renderCall();
23+
24+
return <Fragment>Hello, World! ({value})</Fragment>;
25+
};
26+
27+
const middleware: readonly InferMiddleware<typeof Provider>[] = [
28+
// `bindProps` has side effect.
29+
() => () => () => reactComponent(MyComponent, { value: ++count })
30+
];
31+
32+
return {
33+
middleware,
34+
MyComponent,
35+
Provider,
36+
Proxy,
37+
renderCall,
38+
useBuildRenderCallback
39+
};
40+
})
41+
.and.oneOf([
42+
[
43+
'rendered using useBuildRenderCallback()',
44+
({ middleware, Provider, renderCall, useBuildRenderCallback }) => {
45+
function MyProxy() {
46+
const result = useBuildRenderCallback()()?.({});
47+
48+
return result ? <Fragment>{result}</Fragment> : null;
49+
}
50+
51+
return {
52+
renderCall,
53+
TestComponent: function TestComponent() {
54+
return (
55+
<Provider middleware={middleware}>
56+
<MyProxy />
57+
</Provider>
58+
);
59+
}
60+
};
61+
}
62+
],
63+
[
64+
'rendered using <Proxy>',
65+
({ middleware, Provider, Proxy, renderCall }) => {
66+
return {
67+
renderCall,
68+
TestComponent: function TestComponent() {
69+
return (
70+
<Provider middleware={middleware}>
71+
<Proxy request={undefined} />
72+
</Provider>
73+
);
74+
}
75+
};
76+
}
77+
]
78+
])
79+
80+
// ---
81+
82+
.when('the component is rendered', ({ TestComponent }) => render(<TestComponent />))
83+
.then('textContent should match', (_, { container }) =>
84+
expect(container).toHaveProperty('textContent', 'Hello, World! (1)')
85+
)
86+
.and('should have rendered once', ({ renderCall }) => expect(renderCall).toHaveBeenCalledTimes(1))
87+
88+
// ---
89+
90+
.when('the component is rendered again', ({ TestComponent }, result) => {
91+
result.rerender(<TestComponent />);
92+
93+
return result;
94+
})
95+
.then('textContent should match', (_, { container }) =>
96+
expect(container).toHaveProperty('textContent', 'Hello, World! (2)')
97+
)
98+
.and('should have rendered twice', ({ renderCall }) => expect(renderCall).toHaveBeenCalledTimes(2));
99+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/** @jest-environment jsdom */
2+
/// <reference types="@types/jest" />
3+
4+
import { scenario } from '@testduet/given-when-then';
5+
import { render } from '@testing-library/react';
6+
import React, { Fragment, type ComponentType } from 'react';
7+
8+
import createChainOfResponsibility, { type InferMiddleware } from '../createChainOfResponsibilityAsRenderCallback.tsx';
9+
10+
type Props = { readonly children?: never };
11+
type Request = number;
12+
13+
scenario('stability test with changing fallbackComponent', bdd => {
14+
bdd
15+
.given('a TestComponent using chain of responsiblity', () => {
16+
const { Provider, useBuildRenderCallback } = createChainOfResponsibility<Request, Props>();
17+
18+
const alohaCall = jest.fn();
19+
20+
const Aloha = function MyComponent() {
21+
alohaCall();
22+
23+
return <Fragment>Aloha!</Fragment>;
24+
};
25+
26+
const helloWorldCall = jest.fn();
27+
28+
const HelloWorld = function MyComponent() {
29+
helloWorldCall();
30+
31+
return <Fragment>Hello, World!</Fragment>;
32+
};
33+
34+
const middleware: readonly InferMiddleware<typeof Provider>[] = [];
35+
36+
function MyProxy({ fallbackComponent }: { readonly fallbackComponent: ComponentType<Props> }) {
37+
// We are using `useBuildRenderCallback` for less memoization.
38+
const result = useBuildRenderCallback()(1, { fallbackComponent })?.({});
39+
40+
return result ? <Fragment>{result}</Fragment> : null;
41+
}
42+
43+
return {
44+
Aloha,
45+
alohaCall,
46+
HelloWorld,
47+
helloWorldCall,
48+
TestComponent: function TestComponent({
49+
fallbackComponent
50+
}: {
51+
readonly fallbackComponent: ComponentType<Props>;
52+
}) {
53+
return (
54+
<Provider middleware={middleware}>
55+
<MyProxy fallbackComponent={fallbackComponent} />
56+
</Provider>
57+
);
58+
}
59+
};
60+
})
61+
62+
// ---
63+
64+
.when('the component is rendered with fallback component of <HelloWorld>', ({ HelloWorld, TestComponent }) =>
65+
render(<TestComponent fallbackComponent={HelloWorld} />)
66+
)
67+
.then('textContent should match', (_, { container }) =>
68+
expect(container).toHaveProperty('textContent', 'Hello, World!')
69+
)
70+
.and('<HelloWorld> should have been rendered once', ({ helloWorldCall }) =>
71+
expect(helloWorldCall).toHaveBeenCalledTimes(1)
72+
)
73+
74+
// ---
75+
76+
.when('the component is rendered again with same fallback component', ({ HelloWorld, TestComponent }) =>
77+
render(<TestComponent fallbackComponent={HelloWorld} />)
78+
)
79+
.then('textContent should match', (_, { container }) =>
80+
expect(container).toHaveProperty('textContent', 'Hello, World!')
81+
)
82+
.and('<HelloWorld> should not have been rendered again', ({ helloWorldCall }) =>
83+
expect(helloWorldCall).toHaveBeenCalledTimes(1)
84+
)
85+
86+
// ---
87+
88+
.when('the component is rendered with fallback component of <Aloha>', ({ Aloha, TestComponent }) =>
89+
render(<TestComponent fallbackComponent={Aloha} />)
90+
)
91+
.then('textContent should match', (_, { container }) => expect(container).toHaveProperty('textContent', 'Aloha!'))
92+
.and('<Aloha> should have been rendered once', ({ alohaCall }) => expect(alohaCall).toHaveBeenCalledTimes(1));
93+
});

0 commit comments

Comments
 (0)