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

feat(context-selector): add initial implementation #2292

Merged
merged 5 commits into from
Feb 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-context-selector/.gulp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../.gulp')
41 changes: 41 additions & 0 deletions packages/react-context-selector/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `@fluentui/react-context-selector`

React `useContextSelector()` and `useContextSelectors()` hooks in userland.

## Introduction

[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,
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.

[useContextSelector](https://github.com/reactjs/rfcs/pull/119) is recently proposed. While waiting for the process, this library provides the API in userland.

# Installation

**NPM**

```bash
npm install --save @fluentui/react-context-selector
```

**Yarn**

```bash
yarn add @fluentui/react-context-selector
```

## Technical memo

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.

## Limitations

- In order to stop propagation, `children` of a context provider has to be either created outside of the provider or memoized with `React.memo`.
- `<Consumer />` components 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)

## Related projects

The implementation is heavily inspired by:

- [use-context-selector](https://github.com/dai-shi/use-context-selector)
- [react-tracked](https://github.com/dai-shi/react-tracked)
1 change: 1 addition & 0 deletions packages/react-context-selector/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = api => require('@fluentui/internal-tooling/babel')(api)
1 change: 1 addition & 0 deletions packages/react-context-selector/gulpfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '../../gulpfile'
5 changes: 5 additions & 0 deletions packages/react-context-selector/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
...require('@fluentui/internal-tooling/jest'),
name: 'react-context-selector',
moduleNameMapper: require('lerna-alias').jest(),
}
40 changes: 40 additions & 0 deletions packages/react-context-selector/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@fluentui/react-context-selector",
"description": "React useContextSelector & useContextSelectors hooks in userland",
"version": "0.43.0",
"author": "Oleksandr Fediashov <[email protected]>",
"bugs": "https://github.com/microsoft/fluent-ui-react/issues",
"dependencies": {
"@babel/runtime": "^7.7.6"
},
"devDependencies": {
"@fluentui/internal-tooling": "^0.44.0",
"@types/react-is": "^16.7.1",
"lerna-alias": "^3.0.3-0",
"react": "^16.8.0",
"react-is": "^16.6.3"
},
"peerDependencies": {
"react": "^16.8.0"
},
"files": [
"dist"
],
"homepage": "https://github.com/microsoft/fluent-ui-react/tree/master/packages/react-context-selector",
"jsnext:main": "dist/es/index.js",
"license": "MIT",
"main": "dist/commonjs/index.js",
"module": "dist/es/index.js",
"publishConfig": {
"access": "public"
},
"repository": "microsoft/fluent-ui-react.git",
"scripts": {
"build": "gulp bundle:package:no-umd",
"clean": "gulp bundle:package:clean",
"test": "gulp test",
"test:watch": "gulp test:watch"
},
"sideEffects": false,
"types": "dist/es/index.d.ts"
}
78 changes: 78 additions & 0 deletions packages/react-context-selector/src/createContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react'
import { Context, ContextListener, ContextValue, CreateContextOptions } from './types'

// Stops React Context propagation
// https://github.com/facebook/react/blob/95bd7aad7daa80c381faa3215c80b0906ab5ead5/packages/react-reconciler/src/ReactFiberBeginWork.js#L2656
const calculateChangedBits = () => 0

const createProvider = <Value>(Original: React.Provider<ContextValue<Value>>) => {
const Provider: React.FC<React.ProviderProps<Value>> = props => {
const listeners = React.useRef<ContextListener<Value>[]>([])

// 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.current.forEach(listener => listener(props.value))

// Disables updates propogation for React Context as `value` is always shallow equal
const subscribe = React.useCallback((listener: ContextListener<Value>) => {
listeners.current.push(listener)

const unsubscribe = () => {
const index = listeners.current.indexOf(listener)
listeners.current.splice(index, 1)
}

return unsubscribe
}, [])

return React.createElement(
Original,
{ value: { subscribe, value: props.value } },
props.children,
)
}

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
Provider.displayName = 'ContextSelector.Provider'
}

return Provider
}

const createContext = <Value>(
defaultValue: Value,
options: CreateContextOptions = {},
): Context<Value> => {
const { strict = true } = options

const context = React.createContext<ContextValue<Value>>(
{
get subscribe() {
if (strict) {
/* istanbul ignore next */
throw new Error(
process.env.NODE_ENV === 'production'
? ''
: `Please use <Provider /> component from "@fluentui/react-context-selector"`,
)
}

/* istanbul ignore next */
return () => () => {}
},
value: defaultValue,
},
calculateChangedBits,
)
context.Provider = createProvider<Value>(context.Provider) as any

// We don't support Consumer API
delete context.Consumer

return (context as unknown) as Context<Value>
}

export default createContext
4 changes: 4 additions & 0 deletions packages/react-context-selector/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as createContext } from './createContext'
export { default as useContextSelector } from './useContextSelector'
export { default as useContextSelectors } from './useContextSelectors'
export * from './types'
19 changes: 19 additions & 0 deletions packages/react-context-selector/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react'

export type Context<Value> = React.Context<Value> & {
Provider: React.FC<React.ProviderProps<Value>>
Consumer: never
}

export type CreateContextOptions = {
strict?: boolean
}

export type ContextListener<Value> = (value: Value) => void

export type ContextSelector<Value, SelectedValue> = (value: Value) => SelectedValue

export type ContextValue<Value> = {
subscribe: (listener: ContextListener<Value>) => any
value: Value
}
63 changes: 63 additions & 0 deletions packages/react-context-selector/src/useContextSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react'

import { Context, ContextSelector, ContextValue } from './types'
import { useIsomorphicLayoutEffect } from './utils'

type UseSelectorRef<Value, SelectedValue> = {
selector: ContextSelector<Value, SelectedValue>
selected: SelectedValue
value: Value
}

/**
* 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.
*/
const useContextSelector = <Value, SelectedValue>(
context: Context<Value>,
selector: ContextSelector<Value, SelectedValue>,
): SelectedValue => {
const { subscribe, value } = React.useContext(
(context as unknown) as Context<ContextValue<Value>>,
)
const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void]

const ref = React.useRef<UseSelectorRef<Value, SelectedValue>>()
const selected = selector(value)

useIsomorphicLayoutEffect(() => {
ref.current = {
selector,
value,
selected,
}
})
useIsomorphicLayoutEffect(() => {
const callback = (nextState: Value) => {
try {
const reference: UseSelectorRef<Value, SelectedValue> = ref.current as NonNullable<
UseSelectorRef<Value, SelectedValue>
>

if (
reference.value === nextState ||
Object.is(reference.selected, reference.selector(nextState))
) {
// not changed
return
}
} catch (e) {
// ignored (stale props or some other reason)
}

forceUpdate()
}

return subscribe(callback)
}, [subscribe])

return selected
}

export default useContextSelector
82 changes: 82 additions & 0 deletions packages/react-context-selector/src/useContextSelectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as React from 'react'

import { Context, ContextSelector, ContextValue } from './types'
import { useIsomorphicLayoutEffect } from './utils'

type UseSelectorsRef<
Value,
Properties extends string,
Selectors extends Record<Properties, ContextSelector<Value, SelectedValue>>,
SelectedValue extends any
> = {
selectors: Selectors
value: Value
selected: Record<Properties, SelectedValue>
}

/**
* This hook returns context selected value by selectors.
* It will only accept context created by `createContext`.
* It will trigger re-render if only the selected value is referencially changed.
*/
const useContextSelectors = <
Value,
Properties extends string,
Selectors extends Record<Properties, ContextSelector<Value, SelectedValue>>,
SelectedValue extends any
>(
context: Context<Value>,
selectors: Selectors,
): Record<Properties, SelectedValue> => {
const { subscribe, value } = React.useContext(
(context as unknown) as Context<ContextValue<Value>>,
)
const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void]

const ref = React.useRef<UseSelectorsRef<Value, Properties, Selectors, SelectedValue>>()
const selected = {} as Record<Properties, SelectedValue>

Object.keys(selectors).forEach((key: Properties) => {
selected[key] = selectors[key](value)
})

useIsomorphicLayoutEffect(() => {
ref.current = {
selectors,
value,
selected,
}
})
useIsomorphicLayoutEffect(() => {
const callback = (nextState: Value) => {
try {
const reference: UseSelectorsRef<
Value,
Properties,
Selectors,
SelectedValue
> = ref.current as NonNullable<UseSelectorsRef<Value, Properties, Selectors, SelectedValue>>

if (
reference.value === nextState ||
Object.keys(reference.selected).every((key: Properties) =>
Object.is(reference.selected[key], reference.selectors[key](nextState)),
)
) {
// not changed
return
}
} catch (e) {
// ignored (stale props or some other reason)
}

forceUpdate()
}

return subscribe(callback)
}, [subscribe])

return selected
}

export default useContextSelectors
6 changes: 6 additions & 0 deletions packages/react-context-selector/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react'

// useLayoutEffect that does not show warning when server-side rendering, see Alex Reardon's article for more info
// @see https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? React.useLayoutEffect : /* istanbul ignore next */ React.useEffect
Loading