Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.

Commit f975942

Browse files
authored
feat(context-selector): add initial implementation (#2292)
* feat(context-selector): add initial implementation * fix review comments * remove todo * bump version to align with other packages
1 parent cf90f04 commit f975942

16 files changed

+603
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('../../.gulp')
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# `@fluentui/react-context-selector`
2+
3+
React `useContextSelector()` and `useContextSelectors()` hooks in userland.
4+
5+
## Introduction
6+
7+
[React Context](https://reactjs.org/docs/context.html) and [`useContext()`](https://reactjs.org/docs/hooks-reference.html#usecontext) is often used to avoid prop drilling,
8+
however it's known that there's a performance issue. When a context value is changed, all components that are subscribed with `useContext()` will re-render.
9+
10+
[useContextSelector](https://github.com/reactjs/rfcs/pull/119) is recently proposed. While waiting for the process, this library provides the API in userland.
11+
12+
# Installation
13+
14+
**NPM**
15+
16+
```bash
17+
npm install --save @fluentui/react-context-selector
18+
```
19+
20+
**Yarn**
21+
22+
```bash
23+
yarn add @fluentui/react-context-selector
24+
```
25+
26+
## Technical memo
27+
28+
React context by nature triggers propagation of component re-rendering if a value is changed. To avoid this, this library uses undocumented feature of `calculateChangedBits`. It then uses a subscription model to force update when a component needs to re-render.
29+
30+
## Limitations
31+
32+
- In order to stop propagation, `children` of a context provider has to be either created outside of the provider or memoized with `React.memo`.
33+
- `<Consumer />` components are not supported.
34+
- 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)
35+
36+
## Related projects
37+
38+
The implementation is heavily inspired by:
39+
40+
- [use-context-selector](https://github.com/dai-shi/use-context-selector)
41+
- [react-tracked](https://github.com/dai-shi/react-tracked)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = api => require('@fluentui/internal-tooling/babel')(api)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import '../../gulpfile'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
...require('@fluentui/internal-tooling/jest'),
3+
name: 'react-context-selector',
4+
moduleNameMapper: require('lerna-alias').jest(),
5+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@fluentui/react-context-selector",
3+
"description": "React useContextSelector & useContextSelectors hooks in userland",
4+
"version": "0.43.0",
5+
"author": "Oleksandr Fediashov <[email protected]>",
6+
"bugs": "https://github.com/microsoft/fluent-ui-react/issues",
7+
"dependencies": {
8+
"@babel/runtime": "^7.7.6"
9+
},
10+
"devDependencies": {
11+
"@fluentui/internal-tooling": "^0.44.0",
12+
"@types/react-is": "^16.7.1",
13+
"lerna-alias": "^3.0.3-0",
14+
"react": "^16.8.0",
15+
"react-is": "^16.6.3"
16+
},
17+
"peerDependencies": {
18+
"react": "^16.8.0"
19+
},
20+
"files": [
21+
"dist"
22+
],
23+
"homepage": "https://github.com/microsoft/fluent-ui-react/tree/master/packages/react-context-selector",
24+
"jsnext:main": "dist/es/index.js",
25+
"license": "MIT",
26+
"main": "dist/commonjs/index.js",
27+
"module": "dist/es/index.js",
28+
"publishConfig": {
29+
"access": "public"
30+
},
31+
"repository": "microsoft/fluent-ui-react.git",
32+
"scripts": {
33+
"build": "gulp bundle:package:no-umd",
34+
"clean": "gulp bundle:package:clean",
35+
"test": "gulp test",
36+
"test:watch": "gulp test:watch"
37+
},
38+
"sideEffects": false,
39+
"types": "dist/es/index.d.ts"
40+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as React from 'react'
2+
import { Context, ContextListener, ContextValue, CreateContextOptions } from './types'
3+
4+
// Stops React Context propagation
5+
// https://github.com/facebook/react/blob/95bd7aad7daa80c381faa3215c80b0906ab5ead5/packages/react-reconciler/src/ReactFiberBeginWork.js#L2656
6+
const calculateChangedBits = () => 0
7+
8+
const createProvider = <Value>(Original: React.Provider<ContextValue<Value>>) => {
9+
const Provider: React.FC<React.ProviderProps<Value>> = props => {
10+
const listeners = React.useRef<ContextListener<Value>[]>([])
11+
12+
// We call listeners in render intentionally. Listeners are not technically pure, but
13+
// otherwise we can't get benefits from concurrent mode.
14+
//
15+
// We make sure to work with double or more invocation of listeners.
16+
listeners.current.forEach(listener => listener(props.value))
17+
18+
// Disables updates propogation for React Context as `value` is always shallow equal
19+
const subscribe = React.useCallback((listener: ContextListener<Value>) => {
20+
listeners.current.push(listener)
21+
22+
const unsubscribe = () => {
23+
const index = listeners.current.indexOf(listener)
24+
listeners.current.splice(index, 1)
25+
}
26+
27+
return unsubscribe
28+
}, [])
29+
30+
return React.createElement(
31+
Original,
32+
{ value: { subscribe, value: props.value } },
33+
props.children,
34+
)
35+
}
36+
37+
/* istanbul ignore else */
38+
if (process.env.NODE_ENV !== 'production') {
39+
Provider.displayName = 'ContextSelector.Provider'
40+
}
41+
42+
return Provider
43+
}
44+
45+
const createContext = <Value>(
46+
defaultValue: Value,
47+
options: CreateContextOptions = {},
48+
): Context<Value> => {
49+
const { strict = true } = options
50+
51+
const context = React.createContext<ContextValue<Value>>(
52+
{
53+
get subscribe() {
54+
if (strict) {
55+
/* istanbul ignore next */
56+
throw new Error(
57+
process.env.NODE_ENV === 'production'
58+
? ''
59+
: `Please use <Provider /> component from "@fluentui/react-context-selector"`,
60+
)
61+
}
62+
63+
/* istanbul ignore next */
64+
return () => () => {}
65+
},
66+
value: defaultValue,
67+
},
68+
calculateChangedBits,
69+
)
70+
context.Provider = createProvider<Value>(context.Provider) as any
71+
72+
// We don't support Consumer API
73+
delete context.Consumer
74+
75+
return (context as unknown) as Context<Value>
76+
}
77+
78+
export default createContext
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as createContext } from './createContext'
2+
export { default as useContextSelector } from './useContextSelector'
3+
export { default as useContextSelectors } from './useContextSelectors'
4+
export * from './types'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from 'react'
2+
3+
export type Context<Value> = React.Context<Value> & {
4+
Provider: React.FC<React.ProviderProps<Value>>
5+
Consumer: never
6+
}
7+
8+
export type CreateContextOptions = {
9+
strict?: boolean
10+
}
11+
12+
export type ContextListener<Value> = (value: Value) => void
13+
14+
export type ContextSelector<Value, SelectedValue> = (value: Value) => SelectedValue
15+
16+
export type ContextValue<Value> = {
17+
subscribe: (listener: ContextListener<Value>) => any
18+
value: Value
19+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as React from 'react'
2+
3+
import { Context, ContextSelector, ContextValue } from './types'
4+
import { useIsomorphicLayoutEffect } from './utils'
5+
6+
type UseSelectorRef<Value, SelectedValue> = {
7+
selector: ContextSelector<Value, SelectedValue>
8+
selected: SelectedValue
9+
value: Value
10+
}
11+
12+
/**
13+
* This hook returns context selected value by selector.
14+
* It will only accept context created by `createContext`.
15+
* It will trigger re-render if only the selected value is referencially changed.
16+
*/
17+
const useContextSelector = <Value, SelectedValue>(
18+
context: Context<Value>,
19+
selector: ContextSelector<Value, SelectedValue>,
20+
): SelectedValue => {
21+
const { subscribe, value } = React.useContext(
22+
(context as unknown) as Context<ContextValue<Value>>,
23+
)
24+
const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void]
25+
26+
const ref = React.useRef<UseSelectorRef<Value, SelectedValue>>()
27+
const selected = selector(value)
28+
29+
useIsomorphicLayoutEffect(() => {
30+
ref.current = {
31+
selector,
32+
value,
33+
selected,
34+
}
35+
})
36+
useIsomorphicLayoutEffect(() => {
37+
const callback = (nextState: Value) => {
38+
try {
39+
const reference: UseSelectorRef<Value, SelectedValue> = ref.current as NonNullable<
40+
UseSelectorRef<Value, SelectedValue>
41+
>
42+
43+
if (
44+
reference.value === nextState ||
45+
Object.is(reference.selected, reference.selector(nextState))
46+
) {
47+
// not changed
48+
return
49+
}
50+
} catch (e) {
51+
// ignored (stale props or some other reason)
52+
}
53+
54+
forceUpdate()
55+
}
56+
57+
return subscribe(callback)
58+
}, [subscribe])
59+
60+
return selected
61+
}
62+
63+
export default useContextSelector

0 commit comments

Comments
 (0)