Experimental chunking optimisation and other performance improvements#5871
Experimental chunking optimisation and other performance improvements#5871dylans merged 29 commits intoianstormtaylor:mainfrom
Conversation
🦋 Changeset detectedLatest commit: d26c47f The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
|
@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. |
| - 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 |
There was a problem hiding this comment.
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.
| - [Check hooks](hooks.md#check-hooks) | ||
| - [Editor hooks](hooks.md#editor-hooks) | ||
| - [Selection hooks](hooks.md#selection-hooks) |
There was a problem hiding this comment.
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.
| collectCoverage: true, | ||
| collectCoverageFrom: ['./packages/slate-react/src/chunking/*'], | ||
| coverageThreshold: { | ||
| './packages/slate-react/src/chunking/*': { | ||
| branches: 100, | ||
| functions: 100, | ||
| lines: 100, | ||
| statements: 100, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
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.
| /* Collect trace if the first attempt fails. See https://playwright.dev/docs/trace-viewer */ | ||
| trace: 'retain-on-first-failure', |
There was a problem hiding this comment.
Explanation: This lets us get proper traces for flaky tests, not just tests that fail consistently.
| await page.locator('[data-slate-editor]').fill('') // clear editor | ||
| await page.locator('[data-slate-editor]').selectText() | ||
| await page.keyboard.press('Backspace') // clear editor |
There was a problem hiding this comment.
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 = () => { | |||
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 */ |
There was a problem hiding this comment.
Explanation: z-index:1 is now occupied by the sticky performance controls in the huge document example. z-index: 2 is the underlay.
| input { | ||
| input[type='text'], | ||
| input[type='search'] { |
There was a problem hiding this comment.
Explanation: This avoids adding unwanted styles to input[type='checkbox'].
dylans
left a comment
There was a problem hiding this comment.
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.
dylans
left a comment
There was a problem hiding this comment.
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?
|
@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.
Yep, I've updated it in |
Note
Try it here: https://deploy-preview-5871--slatejs.netlify.app/examples/huge-document
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:
editor.getChunkSize. If enabled, chunking is also used to optimise setting theNODE_TO_PARENTandNODE_TO_INDEXweak maps for each child, since weak map operations (both getting and setting) can be extremely slow in large numbers./packages/slate-react/src/chunking/packages/slate-react/src/hooks/use-children.tsxslate. 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./packages/slate/src/interfaces/transforms/general.tsuseSlateno longer uses a context provider in theSlatecomponent whose value is modified on each change, which was causing minor performance issues for unknown reasons. Instead,useSlateworks in the same way asuseSlateSelector, subscribing toonChangeto re-render the component when Slate's value changes./packages/slate-react/src/components/slate.tsx/packages/slate-react/src/hooks/use-slate.tsxuseSelectedno longer uses a context. Instead, it usesuseSlateSelectorto compareeditor.selectionto 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 inuseChildren./packages/slate-react/src/hooks/use-selected.tsuseElementhas been added to get the current element. This is mainly to support the newuseSelectedsolution./packages/slate-react/src/hooks/use-element.tsdecoratefunction fromEditableis now provided to nodes using a selector-like solution, so that ifdecorateis replaced, only nodes whose decorations are affected by the change will be re-rendered.decoratefunction is changed. This is a breaking change.useSelected, the main reason for this change is to work better with chunking./packages/slate-react/src/hooks/use-decorations.ts/packages/slate-dom/src/utils/range-list.ts/packages/slate-react/src/hooks/use-children.tsxcontent-visibility: autoon each chunk or each element.Remaining tasks
placeholderprop on performancePlate bug:Error on Plate's endCannot update a component while rendering a different componentwhen creating a new paragraph above a suggestion and clicking the suggestion.Plate bug: Code highlighting does not update when language is changedCovered by the breaking decorations changeuseSlate(currently hacked together usinguseSlateSelector)useSlateWithV- Thevdoesn't exist anymore, but we might want to polyfill it for compatibility?content-visibility: autoPerformance 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.
(initial render took several minutes)
(initial render took several minutes)
content-visibility: autoon each element(relatively fast initial render)
content-visibility: autoon each chunk(relatively fast initial render)
This data and my own observations indicate that:
content-visibility: auto).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.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.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
useChildrenhook and uses a complex algorithm to keep a mutable "chunk tree" object in sync with thechildrenarray (analogous to React's virtual DOM). Theeditor.childrenvalue 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
renderChunkprop toEditablethat renders a DOM node for each chunk, on which styles such ascontent-visibility: autocan 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.