Skip to content

Experimental chunking optimisation and other performance improvements#5871

Merged
dylans merged 29 commits intoianstormtaylor:mainfrom
12joan:performance/chunking
Jun 6, 2025
Merged

Experimental chunking optimisation and other performance improvements#5871
dylans merged 29 commits intoianstormtaylor:mainfrom
12joan:performance/chunking

Conversation

@12joan
Copy link
Contributor

@12joan 12joan commented May 15, 2025

Summary

This PR makes various improvements to the performance of slate-react, reducing the latency when typing in very large documents (between 30,000 and ~100,000 blocks) by around 90% in Firefox and around 99.7% in Chrome.

It includes the following major changes:

  • Chunking is the most significant and complex optimisation in this PR. Large children arrays are split into nested "chunks", each of which is rendered as a separate memoised React component and (optionally) a separate DOM element. This optimisation is disabled by default and can be enabled by overriding editor.getChunkSize. If enabled, chunking is also used to optimise setting the NODE_TO_PARENT and NODE_TO_INDEX weak maps for each child, since weak map operations (both getting and setting) can be extremely slow in large numbers.
  • Immer has been removed from most of slate. When applying operations, vanilla JS is used instead to replace modified nodes and their ancestors with new versions while keeping unmodified descendants and siblings unchanged.
  • useSlate no longer uses a context provider in the Slate component whose value is modified on each change, which was causing minor performance issues for unknown reasons. Instead, useSlate works in the same way as useSlateSelector, subscribing to onChange to re-render the component when Slate's value changes.
  • useSelected no longer uses a context. Instead, it uses useSlateSelector to compare editor.selection to the current element's range. This change was mainly because the old context-based approach is difficult to do efficiently alongside chunking due to the added complexity in useChildren.
  • useElement has been added to get the current element. This is mainly to support the new useSelected solution.
  • Decorations now work slightly differently:
  • The Huge Document example is now an interactive playground to explore the effects of various factors on Slate's performance, including changing the number of blocks, enabling/disabling chunking, changing the chunk size, and setting content-visibility: auto on each chunk or each element.

Remaining tasks

  • Examine the effect of the placeholder prop on performance
  • Investigate Playwright test failures
  • Investigate flaky code highlighting Playwright test on Chromium
  • Check for IME regressions
  • Check for regressions on Android
  • Check for regressions on iOS
  • Check for regressions on Windows
  • Test the compatibility of these changes with existing Slate apps, such as Plate
    • Plate bug: Cannot update a component while rendering a different component when creating a new paragraph above a suggestion and clicking the suggestion. Error on Plate's end
    • Plate bug: Code highlighting does not update when language is changed Covered by the breaking decorations change
  • Clean up the implementation of useSlate (currently hacked together using useSlateSelector)
  • Do something about useSlateWithV - The v doesn't exist anymore, but we might want to polyfill it for compatibility?
  • Add changesets
  • Write a documentation page about performance, including instructions to enable chunking and other optimisations that must be enabled manually, such as content-visibility: auto
  • Update JS examples to match TS examples

Performance comparison

comparison.mp4

The following table shows the average delay per keystroke when typing in a 50,000 block document. In Chrome and Safari, the durations include the time taken to paint the DOM after each keystroke. The durations in Firefox do not include painting time, but this is generally negligible in Firefox.

Configuration Firefox (ms) Chrome (ms) Safari (ms)
https://slatejs.org 376 25,176 344
(initial render took several minutes)
This branch with no optional features enabled 284 22,488 354
(initial render took several minutes)
content-visibility: auto on each element 219 485 (initial render took about 30 minutes, too slow to test)
Chunking enabled 29 26,840 166
(relatively fast initial render)
Chunking enabled and content-visibility: auto on each chunk 27 75 60
(relatively fast initial render)

This data and my own observations indicate that:

  • The non-optional optimisations on this branch provide negligible performance improvements on their own (although they are much more significant when combined with chunking and content-visibility: auto).
  • The biggest optimisation in Chrome comes from enabling content-visibility: auto, especially if it is applied on each chunk instead of each element. This CSS rule prevents the browser from painting off-screen DOM nodes. However, enabling it on too many nodes causes additional overhead as the browser must determine whether each one is visible individually; this is why enabling it on each chunk (each of which contains 1000 Slate nodes) is more effective.
  • In Safari, the editor is very slow to render without content-visibility: auto, but enabling it on all 50,000 nodes makes it completely unusable due to the overhead. Enabling it on each chunk is the only viable solution.
  • In browsers that do not have painting issues, such as Firefox, enabling chunking reduces the delay per keystroke by approximately 90%.

Chunking

Chunking is the technique of splitting a node's children into nested chunks. The size of each chunk does not matter much; I get almost identical results regardless of whether the chunk size is 10 or 1000. Chunking happens inside the useChildren hook and uses a complex algorithm to keep a mutable "chunk tree" object in sync with the children array (analogous to React's virtual DOM). The editor.children value is not affected.

Once assigned to a chunk, a Slate node is never reassigned to a different chunk, since this would result in remounting its React component. When blocks are added to the editor one by one, a greedy algorithm is used to assign them to chunks. Blocks that are already present in the editor (and large groups of blocks inserted all at once) are chunked using a more optimal algorithm since the total number of nodes is known in advance.

Each chunk is rendered as a separate memoised React component. This significantly reduces the amount of JSX that React must process on each change, which I believe is the main reason why the chunking optimisation is effective.

By default, chunking does not affect the DOM so that users can enable chunking for their editors without breaking their HTML structure. Optionally, users can pass a renderChunk prop to Editable that renders a DOM node for each chunk, on which styles such as content-visibility: auto can be applied. Most users will need to customise their editor's CSS to account for the nested DOM structure; I recommend setting the chunk size to a small number such as 3 during development so that any issues can be identified and resolved quickly.

Feedback welcome

If you have any questions or feedback about the design or implementation of these changes, please let me know.

@changeset-bot
Copy link

changeset-bot bot commented May 15, 2025

🦋 Changeset detected

Latest commit: d26c47f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
slate-dom Minor
slate Minor
slate-react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@lagupa
Copy link
Contributor

lagupa commented May 15, 2025

@12joan I haven’t had the time to dive deeper yet, but I just wanted to quickly thank you and commend your effort. The video is excellent — it explains the performance improvement very clearly and effectively. Much appreciated!

This PR is a game changer for long document in Slate.

Comment on lines +38 to +44
- name: Upload Playwright test results
if: ${{ !cancelled() && matrix.command == 'test:integration' }}
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results
retention-days: 30
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: This uploads the test-results/ directory produced by Playwright, which includes trace.zip files for the first failure of each test. These can be uploaded to https://trace.playwright.dev/ to see exactly what failed.

Comment on lines -3 to -5
- [Check hooks](hooks.md#check-hooks)
- [Editor hooks](hooks.md#editor-hooks)
- [Selection hooks](hooks.md#selection-hooks)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: I'm not sure these hook categories are useful. useElement doesn't really fit into any of them, and useSlateSelector was miscategorised as a selection hook.

Comment on lines +13 to +22
collectCoverage: true,
collectCoverageFrom: ['./packages/slate-react/src/chunking/*'],
coverageThreshold: {
'./packages/slate-react/src/chunking/*': {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: This enables Jest's code coverage checking for all files in packages/slate-react/src/chunking/ (and no other files).

It also requires that these files have 100% coverage, although individual branches can be excluded by annotating them with a magic // istanbul ignore next comment.

Since the chunking logic is very complex, I enabled this to strongly encourage future contributors to add tests when changing its logic.

Out of scope: It would also be a good idea to track code coverage for the whole of Slate, although requiring 100% coverage would be overkill.

"@emotion/css": "^11.11.2",
"@faker-js/faker": "^8.2.0",
"@playwright/test": "^1.39.0",
"@playwright/test": "^1.52.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Playwright itself was crashing when I tried to run the tests locally using --debug until I upgraded its version.

},
"devDependencies": {
"@babel/runtime": "^7.23.2",
"@testing-library/jest-dom": "^6.6.3",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Importing @testing-library/jest-dom into a test file enables expectations like toBeInTheDocument() within that file, which I've used in use-slate.spec.tsx.

}, [scheduleOnDOMSelectionChange, state])

const decorations = decorate([editor, []])
const decorateContext = useDecorateContext(decorate)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: The decorate function is now passed to descendant components using a subscribable pattern, which lets them recompute their decorations if decorate changes without necessarily re-rendering.

})
})

useFlushDeferredSelectorsOnRender()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Selectors created using useSlateSelector may now be "deferred", which means they do not run until after React has re-rendered the Editable. This is used by useSelected to get the element's updated path.

The useFlushDeferredSelectorsOnRender hook is what tells these deferred selectors when to run.

renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
renderText={renderText}
selection={editor.selection}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Slate nodes no longer re-render when the selection changes. The only thing this was used for was SelectedContext, which I've removed. useSelected now uses a subscribable pattern to re-render elements when they become selected only if the component cares whether it is selected.

@@ -1,4 +1,3 @@
import { produce } from 'immer'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: immer produces a lot of overhead, and doesn't provide any benefit in most places where it's used.

Most of the noise in the below diff is just because of the indentation change and replacing p with point. The change itself is fairly trivial.

@@ -1,4 +1,3 @@
import { produce } from 'immer'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Same situation as point.ts. Aside from the indentation change, the only real difference is:

-    r.anchor = anchor
-    r.focus = focus
+    return { anchor, focus }

@@ -1,4 +1,3 @@
import { createDraft, finishDraft, isDraft } from 'immer'
Copy link
Contributor Author

@12joan 12joan May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: Removing immer here requires substantial changes to the code, but it isn't much more complex overall. I've written a set of utility functions like replaceChildren that mimick immer's behaviour without the overhead.

The resulting behaviour is identical, except for the error messages in some cases.

Comment on lines +79 to +80
/* Collect trace if the first attempt fails. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-first-failure',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: This lets us get proper traces for flaky tests, not just tests that fail consistently.

Comment on lines -33 to +34
await page.locator('[data-slate-editor]').fill('') // clear editor
await page.locator('[data-slate-editor]').selectText()
await page.keyboard.press('Backspace') // clear editor
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: For some reason, Playwright's fill has become very flaky when applied to a Slate editor. I'm not sure if this is due to the upgraded Playwright version or some change I made in Editable.

Selecting all and pressing backspace works reliably.

@@ -29,12 +28,11 @@ const CodeBlockType = 'code-block'
const CodeLineType = 'code-line'
const CodeHighlightingExample = () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: The code highlighting example previously depended on a somewhat unintuitive behaviour of decorations: child (but not grandchild) nodes being redecorated when their parent element is modified.

As a side-effect of the changes in useChildren, this behaviour no longer happens. Instead, nodes are only redecorated when their values (not their parents' values) change, or when the decorate function changes.

I've rewritten this example to be compatible with this breaking change.

onChange={e => setSearch(e.target.value)}
className={css`
padding-left: 2.5em;
padding-left: 2.5em !important;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: This is necessary due to a change in the specificity of the input { /* ... */ } CSS rule, which also sets padding.

height: 42px;
position: relative;
z-index: 1; /* To appear above the underlay */
z-index: 3; /* To appear above the underlay */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: z-index:1 is now occupied by the sticky performance controls in the huge document example. z-index: 2 is the underlay.

Comment on lines -59 to +64
input {
input[type='text'],
input[type='search'] {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation: This avoids adding unwanted styles to input[type='checkbox'].

@12joan 12joan changed the title Improve performance Experimental chunking optimisation and other performance improvements May 28, 2025
@12joan 12joan marked this pull request as ready for review May 28, 2025 14:54
Copy link
Collaborator

@dylans dylans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really nice and thanks for the detailed explanations. I need at least a few days to digest it and read through it, and to give anyone else interested time to give feedback, but it looks like a huge win and a good way to simplify some things. We'll need to socialize the breaking changes.

Copy link
Collaborator

@dylans dylans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I've spent a lot of time reading through it... and I think now that I landed 0.115.0, we just need to land it and see what happens.

Do we need to increase the minimum in the docs to 0.116.0 since that will be the version number?

@12joan
Copy link
Contributor Author

12joan commented Jun 3, 2025

@dylans Thanks for the review! Now seems like a good time to merge it. If there are any problems, people can downgrade to 0.115.x, and I can spend some time this week getting any problems resolved.

Do we need to increase the minimum in the docs to 0.116.0 since that will be the version number?

Yep, I've updated it in slate-react's changeset and package.json file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments