1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-31 10:51:44 +02:00

Experimental chunking optimisation and other performance improvements (#5871)

* Chunking optimization

* Fix comments

* Remove redundant `insertionsMinusRemovals` variable

* Fix typo

* Unblock Netlify builds

* Add placeholder

* Upgrade Playwright (fixes crash when debugging)

* Fix `autoFocus` not working

* Fix huge document test

* Fix the previous issue without changing `useSlateSelector`

* Retry `test:integration`

* Re-implement `useSlateWithV`

* Retry `test:integration`

* Update docs

* Update JS examples to match TS examples

* Upload Playwright's `test-results` directory in CI to access traces

* Change trace mode to `retain-on-first-failure`

* Fix: `Locator.fill(text)` is flaky on Editable

* Add changesets

* Increase minimum `slate-dom` version

* Update changeset

* Update 09-performance.md

* Deprecate the `useSlateWithV` hook

* Fix errors and improve clarity in 09-performance.md

* Minimum `slate-dom` version is now 0.116

* Update `yarn.lock`
This commit is contained in:
Joe Anderson
2025-06-07 00:42:11 +01:00
committed by GitHub
parent 583d28fe13
commit fb87646e86
65 changed files with 5234 additions and 876 deletions

View File

@@ -16,9 +16,9 @@ import {
Element,
Node,
NodeEntry,
Range,
Transforms,
createEditor,
DecoratedRange,
} from 'slate'
import { withHistory } from 'slate-history'
import {
@@ -27,7 +27,6 @@ import {
RenderElementProps,
RenderLeafProps,
Slate,
useSlate,
useSlateStatic,
withReact,
} from 'slate-react'
@@ -48,13 +47,12 @@ const CodeLineType = 'code-line'
const CodeHighlightingExample = () => {
const [editor] = useState(() => withHistory(withReact(createEditor())))
const decorate = useDecorate(editor)
const decorate = useDecorate()
const onKeyDown = useOnKeydown(editor)
return (
<Slate editor={editor} initialValue={initialValue}>
<ExampleToolbar />
<SetNodeToDecorations />
<Editable
decorate={decorate}
renderElement={ElementWrapper}
@@ -166,48 +164,27 @@ const renderLeaf = (props: RenderLeafProps) => {
)
}
const useDecorate = (editor: CustomEditor) => {
return useCallback(
([node, path]: NodeEntry) => {
if (Element.isElement(node) && node.type === CodeLineType) {
const ranges = editor.nodeToDecorations?.get(node) || []
return ranges
}
const useDecorate = () => {
return useCallback(([node, path]: NodeEntry) => {
if (Element.isElement(node) && node.type === CodeBlockType) {
return decorateCodeBlock([node, path])
}
return []
},
[editor.nodeToDecorations]
)
return []
}, [])
}
interface TokenRange extends Range {
token: boolean
[key: string]: unknown
}
type EditorWithDecorations = CustomEditor & {
nodeToDecorations: Map<Element, TokenRange[]>
}
const getChildNodeToDecorations = ([
const decorateCodeBlock = ([
block,
blockPath,
]: NodeEntry<CodeBlockElement>): Map<Element, TokenRange[]> => {
const nodeToDecorations = new Map<Element, TokenRange[]>()
]: NodeEntry<CodeBlockElement>): DecoratedRange[] => {
const text = block.children.map(line => Node.string(line)).join('\n')
const language = block.language
const tokens = Prism.tokenize(text, Prism.languages[language])
const tokens = Prism.tokenize(text, Prism.languages[block.language])
const normalizedTokens = normalizeTokens(tokens) // make tokens flat and grouped by line
const blockChildren = block.children as Element[]
const decorations: DecoratedRange[] = []
for (let index = 0; index < normalizedTokens.length; index++) {
const tokens = normalizedTokens[index]
const element = blockChildren[index]
if (!nodeToDecorations.has(element)) {
nodeToDecorations.set(element, [])
}
let start = 0
for (const token of tokens) {
@@ -219,41 +196,19 @@ const getChildNodeToDecorations = ([
const end = start + length
const path = [...blockPath, index, 0]
const range = {
decorations.push({
anchor: { path, offset: start },
focus: { path, offset: end },
token: true,
...Object.fromEntries(token.types.map(type => [type, true])),
}
nodeToDecorations.get(element)!.push(range)
})
start = end
}
}
return nodeToDecorations
}
// precalculate editor.nodeToDecorations map to use it inside decorate function then
const SetNodeToDecorations = () => {
const editor = useSlate() as EditorWithDecorations
const blockEntries = Array.from(
Editor.nodes<CodeBlockElement>(editor, {
at: [],
mode: 'highest',
match: n => Element.isElement(n) && n.type === CodeBlockType,
})
)
const nodeToDecorations = mergeMaps(
...blockEntries.map(getChildNodeToDecorations)
)
editor.nodeToDecorations = nodeToDecorations
return null
return decorations
}
const useOnKeydown = (editor: CustomEditor) => {
@@ -306,18 +261,6 @@ const LanguageSelect = (props: LanguageSelectProps) => {
)
}
const mergeMaps = <K, V>(...maps: Map<K, V>[]) => {
const map = new Map<K, V>()
for (const m of maps) {
for (const item of m) {
map.set(...item)
}
}
return map
}
const toChildren = (content: string): CustomText[] => [{ text: content }]
const toCodeLines = (content: string): CodeLineElement[] =>
content

View File

@@ -117,6 +117,7 @@ const UrlInput = ({ url, onChange }: UrlInputProps) => {
const [value, setValue] = React.useState(url)
return (
<input
type="text"
value={value}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{

View File

@@ -1,54 +1,520 @@
import { faker } from '@faker-js/faker'
import React, { useCallback, useMemo } from 'react'
import { createEditor, Descendant } from 'slate'
import { Editable, RenderElementProps, Slate, withReact } from 'slate-react'
import React, {
CSSProperties,
Dispatch,
useCallback,
useEffect,
useState,
} from 'react'
import { createEditor as slateCreateEditor, Descendant, Editor } from 'slate'
import {
CustomEditor,
HeadingElement,
ParagraphElement,
} from './custom-types.d'
Editable,
RenderElementProps,
RenderChunkProps,
Slate,
withReact,
useSelected,
} from 'slate-react'
const HEADINGS = 100
const PARAGRAPHS = 7
const initialValue: Descendant[] = []
import { HeadingElement, ParagraphElement } from './custom-types.d'
for (let h = 0; h < HEADINGS; h++) {
const heading: HeadingElement = {
type: 'heading-one',
children: [{ text: faker.lorem.sentence() }],
const SUPPORTS_EVENT_TIMING =
typeof window !== 'undefined' && 'PerformanceEventTiming' in window
const SUPPORTS_LOAF_TIMING =
typeof window !== 'undefined' &&
'PerformanceLongAnimationFrameTiming' in window
interface Config {
blocks: number
chunking: boolean
chunkSize: number
chunkDivs: boolean
chunkOutlines: boolean
contentVisibilityMode: 'none' | 'element' | 'chunk'
showSelectedHeadings: boolean
}
const blocksOptions = [
2, 1000, 2500, 5000, 7500, 10000, 15000, 20000, 25000, 30000, 40000, 50000,
100000, 200000,
]
const chunkSizeOptions = [3, 10, 100, 1000]
const searchParams =
typeof document === 'undefined'
? null
: new URLSearchParams(document.location.search)
const parseNumber = (key: string, defaultValue: number) =>
parseInt(searchParams?.get(key) ?? '', 10) || defaultValue
const parseBoolean = (key: string, defaultValue: boolean) => {
const value = searchParams?.get(key)
if (value) return value === 'true'
return defaultValue
}
const parseEnum = <T extends string>(
key: string,
options: T[],
defaultValue: T
): T => {
const value = searchParams?.get(key) as T | null | undefined
if (value && options.includes(value)) return value
return defaultValue
}
const initialConfig: Config = {
blocks: parseNumber('blocks', 10000),
chunking: parseBoolean('chunking', true),
chunkSize: parseNumber('chunk_size', 1000),
chunkDivs: parseBoolean('chunk_divs', true),
chunkOutlines: parseBoolean('chunk_outlines', false),
contentVisibilityMode: parseEnum(
'content_visibility',
['none', 'element', 'chunk'],
'chunk'
),
showSelectedHeadings: parseBoolean('selected_headings', false),
}
const setSearchParams = (config: Config) => {
if (searchParams) {
searchParams.set('blocks', config.blocks.toString())
searchParams.set('chunking', config.chunking ? 'true' : 'false')
searchParams.set('chunk_size', config.chunkSize.toString())
searchParams.set('chunk_divs', config.chunkDivs ? 'true' : 'false')
searchParams.set('chunk_outlines', config.chunkOutlines ? 'true' : 'false')
searchParams.set('content_visibility', config.contentVisibilityMode)
searchParams.set(
'selected_headings',
config.showSelectedHeadings ? 'true' : 'false'
)
history.replaceState({}, '', `?${searchParams.toString()}`)
}
initialValue.push(heading)
}
for (let p = 0; p < PARAGRAPHS; p++) {
const paragraph: ParagraphElement = {
type: 'paragraph',
children: [{ text: faker.lorem.paragraph() }],
const cachedInitialValue: Descendant[] = []
const getInitialValue = (blocks: number) => {
if (cachedInitialValue.length >= blocks) {
return cachedInitialValue.slice(0, blocks)
}
faker.seed(1)
for (let i = cachedInitialValue.length; i < blocks; i++) {
if (i % 100 === 0) {
const heading: HeadingElement = {
type: 'heading-one',
children: [{ text: faker.lorem.sentence() }],
}
cachedInitialValue.push(heading)
} else {
const paragraph: ParagraphElement = {
type: 'paragraph',
children: [{ text: faker.lorem.paragraph() }],
}
cachedInitialValue.push(paragraph)
}
initialValue.push(paragraph)
}
return cachedInitialValue.slice()
}
const initialInitialValue =
typeof window === 'undefined' ? [] : getInitialValue(initialConfig.blocks)
const createEditor = (config: Config) => {
const editor = withReact(slateCreateEditor())
editor.getChunkSize = node =>
config.chunking && Editor.isEditor(node) ? config.chunkSize : null
return editor
}
const HugeDocumentExample = () => {
const renderElement = useCallback(
(props: RenderElementProps) => <Element {...props} />,
[]
const [rendering, setRendering] = useState(false)
const [config, baseSetConfig] = useState<Config>(initialConfig)
const [initialValue, setInitialValue] = useState(initialInitialValue)
const [editor, setEditor] = useState(() => createEditor(config))
const [editorVersion, setEditorVersion] = useState(0)
const setConfig = useCallback(
(partialConfig: Partial<Config>) => {
const newConfig = { ...config, ...partialConfig }
setRendering(true)
baseSetConfig(newConfig)
setSearchParams(newConfig)
setTimeout(() => {
setRendering(false)
setInitialValue(getInitialValue(newConfig.blocks))
setEditor(createEditor(newConfig))
setEditorVersion(n => n + 1)
})
},
[config]
)
const editor = useMemo(() => withReact(createEditor()) as CustomEditor, [])
const renderElement = useCallback(
(props: RenderElementProps) => (
<Element
{...props}
contentVisibility={config.contentVisibilityMode === 'element'}
showSelectedHeadings={config.showSelectedHeadings}
/>
),
[config.contentVisibilityMode, config.showSelectedHeadings]
)
const renderChunk = useCallback(
(props: RenderChunkProps) => (
<Chunk
{...props}
contentVisibilityLowest={config.contentVisibilityMode === 'chunk'}
outline={config.chunkOutlines}
/>
),
[config.contentVisibilityMode, config.chunkOutlines]
)
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable renderElement={renderElement} spellCheck autoFocus />
</Slate>
<>
<PerformanceControls
editor={editor}
config={config}
setConfig={setConfig}
/>
{rendering ? (
<div>Rendering&hellip;</div>
) : (
<Slate key={editorVersion} editor={editor} initialValue={initialValue}>
<Editable
placeholder="Enter some text…"
renderElement={renderElement}
renderChunk={config.chunkDivs ? renderChunk : undefined}
spellCheck
autoFocus
/>
</Slate>
)}
</>
)
}
const Element = ({ attributes, children, element }: RenderElementProps) => {
const Chunk = ({
attributes,
children,
lowest,
contentVisibilityLowest,
outline,
}: RenderChunkProps & {
contentVisibilityLowest: boolean
outline: boolean
}) => {
const style: CSSProperties = {
contentVisibility: contentVisibilityLowest && lowest ? 'auto' : undefined,
border: outline ? '1px solid red' : undefined,
padding: outline ? 20 : undefined,
marginBottom: outline ? 20 : undefined,
}
return (
<div {...attributes} style={style}>
{children}
</div>
)
}
const Heading = React.forwardRef<
HTMLHeadingElement,
React.ComponentProps<'h1'> & { showSelectedHeadings: boolean }
>(({ style: styleProp, showSelectedHeadings = false, ...props }, ref) => {
// Fine since the editor is remounted if the config changes
// eslint-disable-next-line react-hooks/rules-of-hooks
const selected = showSelectedHeadings ? useSelected() : false
const style = { ...styleProp, color: selected ? 'green' : undefined }
return <h1 ref={ref} {...props} aria-selected={selected} style={style} />
})
const Paragraph = 'p'
const Element = ({
attributes,
children,
element,
contentVisibility,
showSelectedHeadings,
}: RenderElementProps & {
contentVisibility: boolean
showSelectedHeadings: boolean
}) => {
const style: CSSProperties = {
contentVisibility: contentVisibility ? 'auto' : undefined,
}
switch (element.type) {
case 'heading-one':
return <h1 {...attributes}>{children}</h1>
return (
<Heading
{...attributes}
style={style}
showSelectedHeadings={showSelectedHeadings}
>
{children}
</Heading>
)
default:
return <p {...attributes}>{children}</p>
return (
<Paragraph {...attributes} style={style}>
{children}
</Paragraph>
)
}
}
const PerformanceControls = ({
editor,
config,
setConfig,
}: {
editor: Editor
config: Config
setConfig: Dispatch<Partial<Config>>
}) => {
const [configurationOpen, setConfigurationOpen] = useState(true)
const [keyPressDurations, setKeyPressDurations] = useState<number[]>([])
const [lastLongAnimationFrameDuration, setLastLongAnimationFrameDuration] =
useState<number | null>(null)
const lastKeyPressDuration: number | null = keyPressDurations[0] ?? null
const averageKeyPressDuration =
keyPressDurations.length === 10
? Math.round(keyPressDurations.reduce((total, d) => total + d) / 10)
: null
useEffect(() => {
if (!SUPPORTS_EVENT_TIMING) return
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (entry.name === 'keypress') {
const duration = Math.round(
// @ts-ignore Entry type is missing processingStart and processingEnd
entry.processingEnd - entry.processingStart
)
setKeyPressDurations(durations => [
duration,
...durations.slice(0, 9),
])
}
})
})
// @ts-ignore Options type is missing durationThreshold
observer.observe({ type: 'event', durationThreshold: 16 })
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!SUPPORTS_LOAF_TIMING) return
const { apply } = editor
let afterOperation = false
editor.apply = operation => {
apply(operation)
afterOperation = true
}
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (afterOperation) {
setLastLongAnimationFrameDuration(Math.round(entry.duration))
afterOperation = false
}
})
})
// Register the observer for events
observer.observe({ type: 'long-animation-frame' })
return () => observer.disconnect()
}, [editor])
return (
<div className="performance-controls">
<p>
<label>
Blocks:{' '}
<select
value={config.blocks}
onChange={event =>
setConfig({
blocks: parseInt(event.target.value, 10),
})
}
>
{blocksOptions.map(blocks => (
<option key={blocks} value={blocks}>
{blocks.toString().replace(/(\d{3})$/, ',$1')}
</option>
))}
</select>
</label>
</p>
<details
open={configurationOpen}
onToggle={event => setConfigurationOpen(event.currentTarget.open)}
>
<summary>Configuration</summary>
<p>
<label>
<input
type="checkbox"
checked={config.chunking}
onChange={event =>
setConfig({
chunking: event.target.checked,
})
}
/>{' '}
Chunking enabled
</label>
</p>
{config.chunking && (
<>
<p>
<label>
<input
type="checkbox"
checked={config.chunkDivs}
onChange={event =>
setConfig({
chunkDivs: event.target.checked,
})
}
/>{' '}
Render each chunk as a separate <code>&lt;div&gt;</code>
</label>
</p>
{config.chunkDivs && (
<p>
<label>
<input
type="checkbox"
checked={config.chunkOutlines}
onChange={event =>
setConfig({
chunkOutlines: event.target.checked,
})
}
/>{' '}
Outline each chunk
</label>
</p>
)}
<p>
<label>
Chunk size:{' '}
<select
value={config.chunkSize}
onChange={event =>
setConfig({
chunkSize: parseInt(event.target.value, 10),
})
}
>
{chunkSizeOptions.map(chunkSize => (
<option key={chunkSize} value={chunkSize}>
{chunkSize}
</option>
))}
</select>
</label>
</p>
</>
)}
<p>
<label>
Set <code>content-visibility: auto</code> on:{' '}
<select
value={config.contentVisibilityMode}
onChange={event =>
setConfig({
contentVisibilityMode: event.target.value as any,
})
}
>
<option value="none">None</option>
<option value="element">Elements</option>
{config.chunking && config.chunkDivs && (
<option value="chunk">Lowest chunks</option>
)}
</select>
</label>
</p>
<p>
<label>
<input
type="checkbox"
checked={config.showSelectedHeadings}
onChange={event =>
setConfig({
showSelectedHeadings: event.target.checked,
})
}
/>{' '}
Call <code>useSelected</code> in each heading
</label>
</p>
</details>
<details>
<summary>Statistics</summary>
<p>
Last keypress (ms):{' '}
{SUPPORTS_EVENT_TIMING
? lastKeyPressDuration ?? '-'
: 'Not supported'}
</p>
<p>
Average of last 10 keypresses (ms):{' '}
{SUPPORTS_EVENT_TIMING
? averageKeyPressDuration ?? '-'
: 'Not supported'}
</p>
<p>
Last long animation frame (ms):{' '}
{SUPPORTS_LOAF_TIMING
? lastLongAnimationFrameDuration ?? '-'
: 'Not supported'}
</p>
{SUPPORTS_EVENT_TIMING && lastKeyPressDuration === null && (
<p>Events shorter than 16ms may not be detected.</p>
)}
</details>
</div>
)
}
export default HugeDocumentExample

View File

@@ -97,7 +97,7 @@ const SearchHighlightingExample = () => {
placeholder="Search the text..."
onChange={e => setSearch(e.target.value)}
className={css`
padding-left: 2.5em;
padding-left: 2.5em !important;
width: 100%;
`}
/>