From 47f2403e3a46d84b8d8f99c6e2bf41f2699e30df Mon Sep 17 00:00:00 2001 From: Fabio Moretti Date: Fri, 4 Mar 2022 02:25:56 +0100 Subject: [PATCH] Feature/slate selectors (#4841) * Added a redux inspired slate selector * added changeset --- .changeset/serious-papayas-smoke.md | 5 + packages/slate-react/src/components/slate.tsx | 26 +++- .../src/hooks/use-slate-selector.tsx | 139 ++++++++++++++++++ packages/slate-react/src/index.ts | 1 + 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 .changeset/serious-papayas-smoke.md create mode 100644 packages/slate-react/src/hooks/use-slate-selector.tsx diff --git a/.changeset/serious-papayas-smoke.md b/.changeset/serious-papayas-smoke.md new file mode 100644 index 000000000..549c57cc6 --- /dev/null +++ b/.changeset/serious-papayas-smoke.md @@ -0,0 +1,5 @@ +--- +'slate-react': minor +--- + +Added redux-style useSlateSelector to improve and prevent unneccessary rerendering with the useSlate hook diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index b1af68856..52fd41de1 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -4,6 +4,10 @@ import { ReactEditor } from '../plugin/react-editor' import { FocusedContext } from '../hooks/use-focused' import { EditorContext } from '../hooks/use-slate-static' import { SlateContext } from '../hooks/use-slate' +import { + getSelectorContext, + SlateSelectorContext, +} from '../hooks/use-slate-selector' import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' @@ -38,9 +42,15 @@ export const Slate = (props: { return [editor] }) + const { + selectorContext, + onChange: handleSelectorChange, + } = getSelectorContext(editor) + const onContextChange = useCallback(() => { onChange(editor.children) setContext([editor]) + handleSelectorChange(editor) }, [onChange]) EDITOR_TO_ON_CHANGE.set(editor, onContextChange) @@ -76,12 +86,14 @@ export const Slate = (props: { }, []) return ( - - - - {children} - - - + + + + + {children} + + + + ) } diff --git a/packages/slate-react/src/hooks/use-slate-selector.tsx b/packages/slate-react/src/hooks/use-slate-selector.tsx new file mode 100644 index 000000000..720f58f3a --- /dev/null +++ b/packages/slate-react/src/hooks/use-slate-selector.tsx @@ -0,0 +1,139 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + useRef, +} from 'react' +import { Editor } from 'slate' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' + +function isError(error: any): error is Error { + return error instanceof Error +} + +type EditorChangeHandler = (editor: Editor) => void +/** + * A React context for sharing the editor selector context in a way to control rerenders + */ + +export const SlateSelectorContext = createContext<{ + getSlate: () => Editor + addEventListener: (callback: EditorChangeHandler) => () => void +}>({} as any) + +const refEquality = (a: any, b: any) => a === b + +/** + * use redux style selectors to prevent rerendering on every keystroke. + * Bear in mind rerendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function. + * + * Example: + * ``` + * const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection)); + * ``` + */ +export function useSlateSelector( + selector: (editor: Editor) => T, + equalityFn: (a: T, b: T) => boolean = refEquality +) { + const [, forceRender] = useReducer(s => s + 1, 0) + const context = useContext(SlateSelectorContext) + if (!context) { + throw new Error( + `The \`useSlateSelector\` hook must be used inside the component's context.` + ) + } + const { getSlate, addEventListener } = context + + const latestSubscriptionCallbackError = useRef() + const latestSelector = useRef<(editor: Editor) => T>(() => null as any) + const latestSelectedState = useRef((null as any) as T) + let selectedState: T + + try { + if ( + selector !== latestSelector.current || + latestSubscriptionCallbackError.current + ) { + selectedState = selector(getSlate()) + } else { + selectedState = latestSelectedState.current + } + } catch (err) { + if (latestSubscriptionCallbackError.current && isError(err)) { + err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` + } + + throw err + } + useIsomorphicLayoutEffect(() => { + latestSelector.current = selector + latestSelectedState.current = selectedState + latestSubscriptionCallbackError.current = undefined + }) + + useIsomorphicLayoutEffect( + () => { + function checkForUpdates() { + try { + const newSelectedState = latestSelector.current(getSlate()) + + if (equalityFn(newSelectedState, latestSelectedState.current)) { + return + } + + latestSelectedState.current = newSelectedState + } catch (err) { + // we ignore all errors here, since when the component + // is re-rendered, the selectors are called again, and + // will throw again, if neither props nor store state + // changed + latestSubscriptionCallbackError.current = err + } + + forceRender() + } + + const unsubscribe = addEventListener(checkForUpdates) + + checkForUpdates() + + return () => unsubscribe() + }, + // don't rerender on equalityFn change since we want to be able to define it inline + [addEventListener, getSlate] + ) + + return selectedState +} + +/** + * Create selector context with editor updating on every editor change + */ +export function getSelectorContext(editor: Editor) { + const eventListeners = useRef([]).current + const slateRef = useRef<{ + editor: Editor + }>({ + editor, + }).current + const onChange = useCallback((editor: Editor) => { + slateRef.editor = editor + eventListeners.forEach((listener: EditorChangeHandler) => listener(editor)) + }, []) + + const selectorContext = useMemo(() => { + return { + getSlate: () => slateRef.editor, + addEventListener: (callback: EditorChangeHandler) => { + eventListeners.push(callback) + return () => { + eventListeners.splice(eventListeners.indexOf(callback), 1) + } + }, + } + }, [eventListeners, slateRef]) + return { selectorContext, onChange } +} diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index efc26f5e1..f652ddd59 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -24,6 +24,7 @@ export { useFocused } from './hooks/use-focused' export { useReadOnly } from './hooks/use-read-only' export { useSelected } from './hooks/use-selected' export { useSlate } from './hooks/use-slate' +export { useSlateSelector } from './hooks/use-slate-selector' // Plugin export { ReactEditor } from './plugin/react-editor'