Skip to content

Commit

Permalink
Feature/slate selectors (ianstormtaylor#4841)
Browse files Browse the repository at this point in the history
* Added a redux inspired slate selector

* added changeset
  • Loading branch information
fmorett authored Mar 4, 2022
1 parent f0530d2 commit 47f2403
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/serious-papayas-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': minor
---

Added redux-style useSlateSelector to improve and prevent unneccessary rerendering with the useSlate hook
26 changes: 19 additions & 7 deletions packages/slate-react/src/components/slate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -76,12 +86,14 @@ export const Slate = (props: {
}, [])

return (
<SlateContext.Provider value={context}>
<EditorContext.Provider value={editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
<SlateSelectorContext.Provider value={selectorContext}>
<SlateContext.Provider value={context}>
<EditorContext.Provider value={editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
</SlateSelectorContext.Provider>
)
}
139 changes: 139 additions & 0 deletions packages/slate-react/src/hooks/use-slate-selector.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(
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 <Slate> component's context.`
)
}
const { getSlate, addEventListener } = context

const latestSubscriptionCallbackError = useRef<Error | undefined>()
const latestSelector = useRef<(editor: Editor) => T>(() => null as any)
const latestSelectedState = useRef<T>((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<EditorChangeHandler[]>([]).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 }
}
1 change: 1 addition & 0 deletions packages/slate-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 47f2403

Please sign in to comment.