Skip to content

Commit

Permalink
perf: use a listener to propagate decorate changes down tree
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonphillips committed May 30, 2022
1 parent b162d7f commit 6f835cb
Show file tree
Hide file tree
Showing 8 changed files with 363 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-dots-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': patch
---

Use decoration listener context to better propagate decorate changes.
18 changes: 16 additions & 2 deletions packages/slate-react/src/components/android/android-editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { DefaultPlaceholder, ReactEditor } from '../..'
import { ReadOnlyContext } from '../../hooks/use-read-only'
import { useSlate } from '../../hooks/use-slate'
import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect'
import { DecorateContext } from '../../hooks/use-decorate'
import {
DecorateContext,
getDecorateContext,
} from '../../hooks/use-decorations'
import {
DOMElement,
isDOMElement,
Expand Down Expand Up @@ -268,6 +271,17 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
[onDOMSelectionChange]
)

const initialDecorate = useRef(true)
const { decorateContext, onDecorateChange } = getDecorateContext(decorate)
useIsomorphicLayoutEffect(() => {
// don't force extra update on very first render
if (initialDecorate.current) {
initialDecorate.current = false
return
}
onDecorateChange(decorate)
}, [decorate])

// Listen on the native `beforeinput` event to get real "Level 2" events. This
// is required because React's `beforeinput` is fake and never really attaches
// to the real event sadly. (2019/11/01)
Expand Down Expand Up @@ -343,7 +357,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {

return (
<ReadOnlyContext.Provider value={readOnly}>
<DecorateContext.Provider value={decorate}>
<DecorateContext.Provider value={decorateContext}>
<Component
key={contentKey}
role={readOnly ? undefined : 'textbox'}
Expand Down
16 changes: 13 additions & 3 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Text,
Transforms,
Path,
RangeRef,
} from 'slate'
import getDirection from 'direction'
import debounce from 'lodash/debounce'
Expand All @@ -33,7 +32,7 @@ import { ReactEditor } from '..'
import { ReadOnlyContext } from '../hooks/use-read-only'
import { useSlate } from '../hooks/use-slate'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import { DecorateContext } from '../hooks/use-decorate'
import { DecorateContext, getDecorateContext } from '../hooks/use-decorations'
import {
DOMElement,
DOMNode,
Expand Down Expand Up @@ -319,6 +318,17 @@ export const Editable = (props: EditableProps) => {
[onDOMSelectionChange]
)

const initialDecorate = useRef(true)
const { decorateContext, onDecorateChange } = getDecorateContext(decorate)
useIsomorphicLayoutEffect(() => {
// don't force extra update on very first render
if (initialDecorate.current) {
initialDecorate.current = false
return
}
onDecorateChange(decorate)
}, [decorate])

// Listen on the native `beforeinput` event to get real "Level 2" events. This
// is required because React's `beforeinput` is fake and never really attaches
// to the real event sadly. (2019/11/01)
Expand Down Expand Up @@ -620,7 +630,7 @@ export const Editable = (props: EditableProps) => {

return (
<ReadOnlyContext.Provider value={readOnly}>
<DecorateContext.Provider value={decorate}>
<DecorateContext.Provider value={decorateContext}>
<Component
role={readOnly ? undefined : 'textbox'}
{...attributes}
Expand Down
22 changes: 13 additions & 9 deletions packages/slate-react/src/components/element.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { Fragment, useRef } from 'react'
import React, { Fragment, useRef, useMemo } from 'react'
import getDirection from 'direction'
import { Editor, Node, Range, Element as SlateElement } from 'slate'
import { Editor, Path, Node, Range, Element as SlateElement } from 'slate'

import Text from './text'
import useChildren from '../hooks/use-children'
import { useDecorations } from '../hooks/use-decorations'
import { ReactEditor, useSlateStatic, useReadOnly } from '..'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import {
Expand All @@ -26,14 +27,16 @@ import { IS_ANDROID } from '../utils/environment'
* Element.
*/

const Element = (props: {
export interface ElementProps {
decorations: Range[]
element: SlateElement
renderElement?: (props: RenderElementProps) => JSX.Element
renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
selection: Range | null
}) => {
}

const Element = (props: ElementProps) => {
const {
decorations,
element,
Expand All @@ -44,11 +47,12 @@ const Element = (props: {
} = props
const ref = useRef<HTMLElement>(null)
const editor = useSlateStatic()
const ds = useDecorations(element)
const readOnly = useReadOnly()
const isInline = editor.isInline(element)
const key = ReactEditor.findKey(editor, element)
let children: React.ReactNode = useChildren({
decorations,
decorations: [...ds, ...decorations],
node: element,
renderElement,
renderPlaceholder,
Expand Down Expand Up @@ -143,8 +147,9 @@ const Element = (props: {
return content
}

const MemoizedElement = React.memo(Element, (prev, next) => {
return (
const MemoizedElement = React.memo(
Element,
(prev, next) =>
prev.element === next.element &&
prev.renderElement === next.renderElement &&
prev.renderLeaf === next.renderLeaf &&
Expand All @@ -153,8 +158,7 @@ const MemoizedElement = React.memo(Element, (prev, next) => {
(!!prev.selection &&
!!next.selection &&
Range.equals(prev.selection, next.selection)))
)
})
)

/**
* The default element renderer.
Expand Down
22 changes: 13 additions & 9 deletions packages/slate-react/src/components/text.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useRef } from 'react'
import { Range, Element, Text as SlateText } from 'slate'
import React, { useRef, useMemo } from 'react'
import { Range, Path, Element, Text as SlateText } from 'slate'

import Leaf from './leaf'
import { ReactEditor, useSlateStatic } from '..'
import { RenderLeafProps, RenderPlaceholderProps } from './editable'
import { useDecorations } from '../hooks/use-decorations'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import {
NODE_TO_ELEMENT,
Expand All @@ -18,14 +19,16 @@ import { IS_ANDROID } from '../utils/environment'
* Text.
*/

const Text = (props: {
export interface TextProps {
decorations: Range[]
isLast: boolean
parent: Element
renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
text: SlateText
}) => {
}

const Text = (props: TextProps) => {
const {
decorations,
isLast,
Expand All @@ -36,7 +39,8 @@ const Text = (props: {
} = props
const editor = useSlateStatic()
const ref = useRef<HTMLSpanElement>(null)
const leaves = SlateText.decorations(text, decorations)
const ds = useDecorations(text)
const leaves = SlateText.decorations(text, [...ds, ...decorations])
const key = ReactEditor.findKey(editor, text)
const children = []

Expand Down Expand Up @@ -78,14 +82,14 @@ const Text = (props: {
)
}

const MemoizedText = React.memo(Text, (prev, next) => {
return (
const MemoizedText = React.memo(
Text,
(prev, next) =>
next.parent === prev.parent &&
next.isLast === prev.isLast &&
next.renderLeaf === prev.renderLeaf &&
next.text === prev.text &&
isDecoratorRangeListEqual(next.decorations, prev.decorations)
)
})
)

export default MemoizedText
4 changes: 1 addition & 3 deletions packages/slate-react/src/hooks/use-children.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import ElementComponent from '../components/element'
import TextComponent from '../components/text'
import { ReactEditor } from '..'
import { useSlateStatic } from './use-slate-static'
import { useDecorate } from './use-decorate'
import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps'
import {
RenderElementProps,
Expand Down Expand Up @@ -34,7 +33,6 @@ const useChildren = (props: {
renderLeaf,
selection,
} = props
const decorate = useDecorate()
const editor = useSlateStatic()
const path = ReactEditor.findPath(editor, node)
const children = []
Expand All @@ -49,7 +47,7 @@ const useChildren = (props: {
const key = ReactEditor.findKey(editor, n)
const range = Editor.range(editor, p)
const sel = selection && Range.intersection(range, selection)
const ds = decorate([n, p])
const ds = []

for (const dec of decorations) {
const d = Range.intersection(dec, range)
Expand Down
139 changes: 139 additions & 0 deletions packages/slate-react/src/hooks/use-decorations.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 { Node, NodeEntry, BaseRange } from 'slate'
import { ReactEditor } from '..'
import { useSlateStatic } from './use-slate-static'
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
import { isDecoratorRangeListEqual } from '../utils/range-list'

function isError(error: any): error is Error {
return error instanceof Error
}

type DecorationsList = (BaseRange & { placeholder?: string | undefined })[]
type DecorateFn = (entry: NodeEntry) => DecorationsList
type DecorateChangeHandler = (decorate: DecorateFn) => void

/**
* A React context for decorate context in a way to control rerenders
*/

export const DecorateContext = createContext<{
getDecorate: () => DecorateFn
addEventListener: (callback: DecorateChangeHandler) => () => void
}>({} as any)

/**
* use redux style selectors to prevent rerendering on every decorate change
*/
export function useDecorations(node: Node) {
const [iteration, forceRender] = useReducer(s => s + 1, 0)
const editor = useSlateStatic()
const context = useContext(DecorateContext)

if (!context) {
throw new Error(
`The \`useDecorations\` hook must be used inside the <Slate> component's context.`
)
}
const { getDecorate, addEventListener } = context

const latestSubscriptionCallbackError = useRef<Error | undefined>()
const latestNode = useRef<Node>(node)
const latestDecorationState = useRef<DecorationsList>([])
let decorationState: DecorationsList

try {
if (
iteration === 0 ||
node !== latestNode.current ||
latestSubscriptionCallbackError.current
) {
const path = ReactEditor.findPath(editor, node)
decorationState = getDecorate()([node, path])
} else {
decorationState = latestDecorationState.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(() => {
latestNode.current = node
latestDecorationState.current = decorationState
latestSubscriptionCallbackError.current = undefined
})

useIsomorphicLayoutEffect(
() => {
function checkForUpdates() {
try {
const path = ReactEditor.findPath(editor, latestNode.current)
const newDecorationState = getDecorate()([latestNode.current, path])

if (
isDecoratorRangeListEqual(
newDecorationState,
latestDecorationState.current
)
) {
return
}

latestDecorationState.current = newDecorationState
} 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)
return () => unsubscribe()
},
// don't rerender on equalityFn change since we want to be able to define it inline
[addEventListener, getDecorate]
)

return decorationState
}

/**
* Create decoration context with updating on every decorator change
*/
export function getDecorateContext(decorate: DecorateFn) {
const eventListeners = useRef<DecorateChangeHandler[]>([]).current
const decorateRef = useRef<{ decorate: DecorateFn }>({ decorate }).current
const onDecorateChange = useCallback((decorate: DecorateFn) => {
decorateRef.decorate = decorate
eventListeners.forEach((listener: DecorateChangeHandler) =>
listener(decorate)
)
}, [])

const decorateContext = useMemo(() => {
return {
getDecorate: () => decorateRef.decorate,
addEventListener: (callback: DecorateChangeHandler) => {
eventListeners.push(callback)
return () => {
eventListeners.splice(eventListeners.indexOf(callback), 1)
}
},
}
}, [eventListeners, decorateRef])
return { decorateContext, onDecorateChange }
}
Loading

0 comments on commit 6f835cb

Please sign in to comment.