Skip to content

Commit

Permalink
Merge pull request #13 from dai-shi/more-hacked-provider
Browse files Browse the repository at this point in the history
A temporal workaround with useLayoutEffect in DEV
  • Loading branch information
dai-shi committed Feb 24, 2020
2 parents f6e6b98 + 526bea3 commit 41c0a1b
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 24 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Returns **React.Context**

This hook returns context selected value by selector.
It will only accept context created by `createContext`.
It will trigger re-render if only the selected value is referencially changed.
It will trigger re-render if only the selected value is referentially changed.

#### Parameters

Expand Down Expand Up @@ -150,6 +150,7 @@ Returns **any**

- Subscriptions are per-context basis. So, even if there are multiple context providers in a component tree, all components are subscribed to all providers. This may lead false positives (extra re-renders).
- In order to stop propagation, `children` of a context provider has to be either created outside of the provider or memoized with `React.memo`.
- Provider trigger re-renders only if the context value is referentially changed.
- Context consumers are not supported.
- The [stale props](https://react-redux.js.org/api/hooks#stale-props-and-zombie-children) issue can't be solved in userland. (workaround with try-catch)

Expand Down
48 changes: 48 additions & 0 deletions __tests__/02_tearing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { useState, StrictMode } from 'react';

import { render, fireEvent, cleanup } from '@testing-library/react';

import { createContext, useContextSelector } from '../src/index';

describe('tearing spec', () => {
afterEach(cleanup);

it('should not tear with parent', () => {
const initialState = {
count: 0,
};
const context = createContext(initialState);
const Counter = ({ parentCount }) => {
const count = useContextSelector(context, (v) => v.count);
if (parentCount !== count) throw new Error('tears!!!');
return (
<div>
<div>{parentCount}</div>
<div>{count}</div>
</div>
);
};
const Parent = () => {
const [state, setState] = useState(initialState);
const increment = () => setState((s) => ({
...s,
count: s.count + 1,
}));
return (
<context.Provider value={state}>
<Counter parentCount={state.count} />
<button type="button" onClick={increment}>+1</button>
</context.Provider>
);
};
const App = () => (
<StrictMode>
<Parent />
</StrictMode>
);
const { getAllByText } = render(<App />);
expect(() => {
fireEvent.click(getAllByText('+1')[0]);
}).not.toThrow();
});
});
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 21 additions & 13 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ const CONTEXT_LISTENERS = (
);

const createProvider = (OrigProvider, listeners) => React.memo(({ value, children }) => {
// we call listeners in render intentionally.
// listeners are not technically pure, but
// otherwise we can't get benefits from concurrent mode.
// we make sure to work with double or more invocation of listeners.
listeners.forEach((listener) => {
listener(value);
});
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
// we use layout effect to eliminate warnings.
// but, this leads tearing with startTransition.
React.useLayoutEffect(() => {
listeners.forEach((listener) => {
listener(value);
});
});
} else {
// we call listeners in render for optimization.
// although this is not a recommended pattern,
// so far this is only the way to make it as expected.
// we are looking for better solutions.
// https://github.com/dai-shi/use-context-selector/pull/12
listeners.forEach((listener) => {
listener(value);
});
}
return React.createElement(OrigProvider, { value }, children);
});

Expand All @@ -38,7 +49,7 @@ export const createContext = (defaultValue) => {
/**
* This hook returns context selected value by selector.
* It will only accept context created by `createContext`.
* It will trigger re-render if only the selected value is referencially changed.
* It will trigger re-render if only the selected value is referentially changed.
* @param {React.Context} context
* @param {Function} selector
* @returns {*}
Expand All @@ -47,12 +58,9 @@ export const createContext = (defaultValue) => {
*/
export const useContextSelector = (context, selector) => {
const listeners = context[CONTEXT_LISTENERS];
if (!listeners) {
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production') {
if (!listeners) {
throw new Error('useContextSelector requires special context');
} else {
// for production
throw new Error();
}
}
const [, forceUpdate] = React.useReducer((c) => c + 1, 0);
Expand Down

0 comments on commit 41c0a1b

Please sign in to comment.