import { ComponentType, Context as ContextOrig, MutableRefObject, Provider, ReactNode, createElement, createContext as createContextOrig, useContext as useContextOrig, useEffect, useLayoutEffect, useReducer, useRef, useState, } from 'react'; import { unstable_NormalPriority as NormalPriority, unstable_runWithPriority as runWithPriority, } from 'scheduler'; import { batchedUpdates } from './batchedUpdates'; const CONTEXT_VALUE = Symbol(); const ORIGINAL_PROVIDER = Symbol(); const isSSR = typeof window === 'undefined' || /ServerSideRendering/.test(window.navigator && window.navigator.userAgent); const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect; // for preact that doesn't have runWithPriority const runWithNormalPriority = runWithPriority ? (thunk: () => void) => { try { runWithPriority(NormalPriority, thunk); } catch (e: any) { if (e.message === 'Not implemented.') { thunk(); } else { throw e; } } } : (thunk: () => void) => thunk(); type Version = number; type Listener = ( action: { n: Version, p?: Promise, v?: Value } ) => void type ContextValue = { [CONTEXT_VALUE]: { /* "v"alue */ v: MutableRefObject; /* versio"n" */ n: MutableRefObject; /* "l"isteners */ l: Set>; /* "u"pdate */ u: (thunk: () => void, options?: { suspense: boolean }) => void; }; }; export interface Context { Provider: ComponentType<{ value: Value; children: ReactNode }>; displayName?: string; } const createProvider = ( ProviderOrig: Provider>, ) => { const ContextProvider = ({ value, children }: { value: Value; children: ReactNode }) => { const valueRef = useRef(value); const versionRef = useRef(0); const [resolve, setResolve] = useState<((v: Value) => void) | null>(null); if (resolve) { resolve(value); setResolve(null); } const contextValue = useRef>(); if (!contextValue.current) { const listeners = new Set>(); const update = (thunk: () => void, options?: { suspense: boolean }) => { batchedUpdates(() => { versionRef.current += 1; const action: Parameters>[0] = { n: versionRef.current, }; if (options?.suspense) { action.n *= -1; // this is intentional to make it temporary version action.p = new Promise((r) => { setResolve(() => (v: Value) => { action.v = v; delete action.p; r(v); }); }); } listeners.forEach((listener) => listener(action)); thunk(); }); }; contextValue.current = { [CONTEXT_VALUE]: { /* "v"alue */ v: valueRef, /* versio"n" */ n: versionRef, /* "l"isteners */ l: listeners, /* "u"pdate */ u: update, }, }; } useIsomorphicLayoutEffect(() => { valueRef.current = value; versionRef.current += 1; runWithNormalPriority(() => { (contextValue.current as ContextValue)[CONTEXT_VALUE].l.forEach((listener) => { listener({ n: versionRef.current, v: value }); }); }); }, [value]); return createElement(ProviderOrig, { value: contextValue.current }, children); }; return ContextProvider; }; const identity = (x: T) => x; /** * This creates a special context for `useContextSelector`. * * @example * import { createContext } from 'use-context-selector'; * * const PersonContext = createContext({ firstName: '', familyName: '' }); */ export function createContext(defaultValue: Value) { const context = createContextOrig>({ [CONTEXT_VALUE]: { /* "v"alue */ v: { current: defaultValue }, /* versio"n" */ n: { current: -1 }, /* "l"isteners */ l: new Set(), /* "u"pdate */ u: (f) => f(), }, }); (context as unknown as { [ORIGINAL_PROVIDER]: Provider>; })[ORIGINAL_PROVIDER] = context.Provider; (context as unknown as Context).Provider = createProvider(context.Provider); delete (context as any).Consumer; // no support for Consumer return context as unknown as 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 referentially changed. * * The selector should return referentially equal result for same input for better performance. * * @example * import { useContextSelector } from 'use-context-selector'; * * const firstName = useContextSelector(PersonContext, state => state.firstName); */ export function useContextSelector( context: Context, selector: (value: Value) => Selected, ) { const contextValue = useContextOrig( context as unknown as ContextOrig>, )[CONTEXT_VALUE]; if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { if (!contextValue) { throw new Error('useContextSelector requires special context'); } } const { /* "v"alue */ v: { current: value }, /* versio"n" */ n: { current: version }, /* "l"isteners */ l: listeners, } = contextValue; const selected = selector(value); const [state, dispatch] = useReducer(( prev: readonly [Value, Selected], action?: Parameters>[0], ) => { if (!action) { // case for `dispatch()` below return [value, selected] as const; } if ('p' in action) { throw action.p; } if (action.n === version) { if (Object.is(prev[1], selected)) { return prev; // bail out } return [value, selected] as const; } try { if ('v' in action) { if (Object.is(prev[0], action.v)) { return prev; // do not update } const nextSelected = selector(action.v); if (Object.is(prev[1], nextSelected)) { return prev; // do not update } return [action.v, nextSelected] as const; } } catch (e) { // ignored (stale props or some other reason) } return [...prev] as const; // schedule update }, [value, selected] as const); if (!Object.is(state[1], selected)) { // schedule re-render // this is safe because it's self contained dispatch(); } useIsomorphicLayoutEffect(() => { listeners.add(dispatch); return () => { listeners.delete(dispatch); }; }, [listeners]); return state[1]; } /** * This hook returns the entire context value. * Use this instead of React.useContext for consistent behavior. * * @example * import { useContext } from 'use-context-selector'; * * const person = useContext(PersonContext); */ export function useContext(context: Context) { return useContextSelector(context, identity); } /** * This hook returns an update function that accepts a thunk function * * Use this for a function that will change a value in * concurrent rendering in React 18. * Otherwise, there's no need to use this hook. * * @example * import { useContextUpdate } from 'use-context-selector'; * * const update = useContextUpdate(); * * // Wrap set state function * update(() => setState(...)); * * // Experimental suspense mode * update(() => setState(...), { suspense: true }); */ export function useContextUpdate(context: Context) { const contextValue = useContextOrig( context as unknown as ContextOrig>, )[CONTEXT_VALUE]; if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { if (!contextValue) { throw new Error('useContextUpdate requires special context'); } } const { u: update } = contextValue; return update; } /** * This is a Provider component for bridging multiple react roots * * @example * const valueToBridge = useBridgeValue(PersonContext); * return ( * * * {children} * * * ); */ export const BridgeProvider = ({ context, value, children }:{ context: Context; value: any; children: ReactNode; }) => { const { [ORIGINAL_PROVIDER]: ProviderOrig } = context as unknown as { [ORIGINAL_PROVIDER]: Provider; }; if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { if (!ProviderOrig) { throw new Error('BridgeProvider requires special context'); } } return createElement(ProviderOrig, { value }, children); }; /** * This hook return a value for BridgeProvider */ export const useBridgeValue = (context: Context) => { const bridgeValue = useContextOrig(context as unknown as ContextOrig>); if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { if (!bridgeValue[CONTEXT_VALUE]) { throw new Error('useBridgeValue requires special context'); } } return bridgeValue as any; };