Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transform.insertNodes & Transform.insertFragment performance optimize #5543

Merged
merged 6 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/witty-apples-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate': minor
---

Transform.insertNodes & Transform.insertFragment performance optimize
43 changes: 9 additions & 34 deletions packages/slate/src/core/apply.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { PathRef } from '../interfaces/path-ref'
import { PointRef } from '../interfaces/point-ref'
import { RangeRef } from '../interfaces/range-ref'
import { DIRTY_PATH_KEYS, DIRTY_PATHS, FLUSHING } from '../utils/weak-maps'
import { FLUSHING } from '../utils/weak-maps'
import { Path } from '../interfaces/path'
import { Transforms } from '../interfaces/transforms'
import { WithEditorFirstArg } from '../utils/types'
import { Editor } from '../interfaces/editor'
import { isBatchingDirtyPaths } from './batch-dirty-paths'
import { updateDirtyPaths } from './update-dirty-paths'

export const apply: WithEditorFirstArg<Editor['apply']> = (editor, op) => {
for (const ref of Editor.pathRefs(editor)) {
Expand All @@ -20,41 +22,14 @@ export const apply: WithEditorFirstArg<Editor['apply']> = (editor, op) => {
RangeRef.transform(ref, op)
}

const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const oldDirtyPathKeys = DIRTY_PATH_KEYS.get(editor) || new Set()
let dirtyPaths: Path[]
let dirtyPathKeys: Set<string>

const add = (path: Path | null) => {
if (path) {
const key = path.join(',')

if (!dirtyPathKeys.has(key)) {
dirtyPathKeys.add(key)
dirtyPaths.push(path)
}
}
}

if (Path.operationCanTransformPath(op)) {
dirtyPaths = []
dirtyPathKeys = new Set()
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
} else {
dirtyPaths = oldDirtyPaths
dirtyPathKeys = oldDirtyPathKeys
}

const newDirtyPaths = editor.getDirtyPaths(op)
for (const path of newDirtyPaths) {
add(path)
// update dirty paths
if (!isBatchingDirtyPaths(editor)) {
const transform = Path.operationCanTransformPath(op)
? (p: Path) => Path.transform(p, op)
: undefined
updateDirtyPaths(editor, editor.getDirtyPaths(op), transform)
}

DIRTY_PATHS.set(editor, dirtyPaths)
DIRTY_PATH_KEYS.set(editor, dirtyPathKeys)
Transforms.transform(editor, op)
editor.operations.push(op)
Editor.normalize(editor, {
Expand Down
24 changes: 24 additions & 0 deletions packages/slate/src/core/batch-dirty-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// perf

import { Editor } from '../interfaces/editor'

const BATCHING_DIRTY_PATHS: WeakMap<Editor, boolean> = new WeakMap()

export const isBatchingDirtyPaths = (editor: Editor) => {
return BATCHING_DIRTY_PATHS.get(editor) || false
}

export const batchDirtyPaths = (
editor: Editor,
fn: () => void,
update: () => void
) => {
const value = BATCHING_DIRTY_PATHS.get(editor) || false
BATCHING_DIRTY_PATHS.set(editor, true)
try {
fn()
update()
} finally {
BATCHING_DIRTY_PATHS.set(editor, value)
}
}
50 changes: 50 additions & 0 deletions packages/slate/src/core/update-dirty-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { DIRTY_PATH_KEYS, DIRTY_PATHS } from '../utils/weak-maps'
import { Path } from '../interfaces/path'
import { Editor } from '../interfaces/editor'

/**
* update editor dirty paths
*
* @param newDirtyPaths: Path[]; new dirty paths
* @param transform: (p: Path) => Path | null; how to transform existing dirty paths
*/
export function updateDirtyPaths(
editor: Editor,
newDirtyPaths: Path[],
transform?: (p: Path) => Path | null
) {
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const oldDirtyPathKeys = DIRTY_PATH_KEYS.get(editor) || new Set()
let dirtyPaths: Path[]
let dirtyPathKeys: Set<string>

const add = (path: Path | null) => {
if (path) {
const key = path.join(',')

if (!dirtyPathKeys.has(key)) {
dirtyPathKeys.add(key)
dirtyPaths.push(path)
}
}
}

if (transform) {
dirtyPaths = []
dirtyPathKeys = new Set()
for (const path of oldDirtyPaths) {
const newPath = transform(path)
add(newPath)
}
} else {
dirtyPaths = oldDirtyPaths
dirtyPathKeys = oldDirtyPathKeys
}

for (const path of newDirtyPaths) {
add(path)
}

DIRTY_PATHS.set(editor, dirtyPaths)
DIRTY_PATH_KEYS.set(editor, dirtyPathKeys)
}
1 change: 1 addition & 0 deletions packages/slate/src/interfaces/transforms/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface NodeInsertNodesOptions<T extends Node> {
hanging?: boolean
select?: boolean
voids?: boolean
batchDirty?: boolean
}

export interface NodeTransforms {
Expand Down
1 change: 1 addition & 0 deletions packages/slate/src/interfaces/transforms/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface TextInsertFragmentOptions {
at?: Location
hanging?: boolean
voids?: boolean
batchDirty?: boolean
}

export interface TextInsertTextOptions {
Expand Down
69 changes: 63 additions & 6 deletions packages/slate/src/transforms-node/insert-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ import { Text } from '../interfaces/text'
import { Element } from '../interfaces/element'
import { Path } from '../interfaces/path'
import { getDefaultInsertLocation } from '../utils'
import { batchDirtyPaths } from '../core/batch-dirty-paths'
import { BaseInsertNodeOperation } from '../interfaces'
import { updateDirtyPaths } from '../core/update-dirty-paths'

export const insertNodes: NodeTransforms['insertNodes'] = (
editor,
nodes,
options = {}
) => {
Editor.withoutNormalizing(editor, () => {
const { hanging = false, voids = false, mode = 'lowest' } = options
const {
hanging = false,
voids = false,
mode = 'lowest',
batchDirty = true,
} = options
let { at, match, select } = options

if (Node.isNode(nodes)) {
Expand Down Expand Up @@ -91,12 +99,61 @@ export const insertNodes: NodeTransforms['insertNodes'] = (
return
}

for (const node of nodes) {
const path = parentPath.concat(index)
index++
editor.apply({ type: 'insert_node', path, node })
at = Path.next(at)
if (batchDirty) {
// PERF: batch update dirty paths
// batched ops used to transform existing dirty paths
const batchedOps: BaseInsertNodeOperation[] = []
const newDirtyPaths: Path[] = Path.levels(parentPath)
batchDirtyPaths(
editor,
() => {
for (const node of nodes as Node[]) {
const path = parentPath.concat(index)
index++

const op: BaseInsertNodeOperation = {
type: 'insert_node',
path,
node,
}
editor.apply(op)
at = Path.next(at as Path)

batchedOps.push(op)
if (!Text.isText) {
newDirtyPaths.push(path)
} else {
newDirtyPaths.push(
...Array.from(Node.nodes(node), ([, p]) => path.concat(p))
)
}
}
},
() => {
updateDirtyPaths(editor, newDirtyPaths, p => {
let newPath: Path | null = p
for (const op of batchedOps) {
if (Path.operationCanTransformPath(op)) {
newPath = Path.transform(newPath, op)
if (!newPath) {
return null
}
}
}
return newPath
})
}
)
} else {
for (const node of nodes as Node[]) {
const path = parentPath.concat(index)
index++

editor.apply({ type: 'insert_node', path, node })
at = Path.next(at as Path)
}
}

at = Path.previous(at)

if (select) {
Expand Down
5 changes: 4 additions & 1 deletion packages/slate/src/transforms-text/insert-fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const insertFragment: TextTransforms['insertFragment'] = (
) => {
Editor.withoutNormalizing(editor, () => {
const { hanging = false, voids = false } = options
let { at = getDefaultInsertLocation(editor) } = options
let { at = getDefaultInsertLocation(editor), batchDirty = true } = options

if (!fragment.length) {
return
Expand Down Expand Up @@ -187,6 +187,7 @@ export const insertFragment: TextTransforms['insertFragment'] = (
match: n => Text.isText(n) || Editor.isInline(editor, n),
mode: 'highest',
voids,
batchDirty,
})

if (isBlockEmpty && !starts.length && middles.length && !ends.length) {
Expand All @@ -198,13 +199,15 @@ export const insertFragment: TextTransforms['insertFragment'] = (
match: n => Element.isElement(n) && Editor.isBlock(editor, n),
mode: 'lowest',
voids,
batchDirty,
})

Transforms.insertNodes(editor, ends, {
at: endRef.current!,
match: n => Text.isText(n) || Editor.isInline(editor, n),
mode: 'highest',
voids,
batchDirty,
})

if (!options.at) {
Expand Down
36 changes: 35 additions & 1 deletion packages/slate/test/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'assert'
import { cloneDeep } from 'lodash'
import { fixtures } from '../../../support/fixtures'
import { Editor } from 'slate'
import { Editor, createEditor } from 'slate'
import { createHyperscript } from 'slate-hyperscript'

describe('slate', () => {
Expand Down Expand Up @@ -45,6 +46,31 @@ describe('slate', () => {
const result = test(input)
assert.deepEqual(result, output)
})
// make sure with or without batchDirty, the normalize result is the same
const testBatchDirty = ({ module }) => {
mainhanu marked this conversation as resolved.
Show resolved Hide resolved
const { input, run } = module

const input2 = createEditor()
input2.children = cloneDeep(input.children)
input2.selection = cloneDeep(input.selection)

const dirties1 = []
const dirties2 = []

const editor1 = withBatchTest(withTest(input), dirties1)
const editor2 = withBatchTest(withTest(input2), dirties2)

run(editor1, { batchDirty: true })
run(editor2, { batchDirty: false })

assert.equal(dirties1.join(' '), dirties2.join(' '))
}
fixtures(__dirname, 'transforms/insertNodes', ({ module }) => {
testBatchDirty({ module })
})
fixtures(__dirname, 'transforms/insertFragment', ({ module }) => {
testBatchDirty({ module })
})
})
const withTest = editor => {
const { isInline, isVoid, isElementReadOnly, isSelectable } = editor
Expand All @@ -62,6 +88,14 @@ const withTest = editor => {
}
return editor
}
const withBatchTest = (editor, dirties) => {
const { normalizeNode } = editor
editor.normalizeNode = ([node, path]) => {
dirties.push(JSON.stringify(path))
normalizeNode([node, path])
}
return editor
}
export const jsx = createHyperscript({
elements: {
block: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Transforms } from 'slate'
import { jsx } from '../../..'

export const run = editor => {
export const run = (editor, options = {}) => {
Transforms.insertFragment(
editor,
<fragment>
Expand All @@ -11,7 +11,8 @@ export const run = editor => {
</block>
<block>two</block>
<block>three</block>
</fragment>
</fragment>,
options
)
}
export const input = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
import { Transforms } from 'slate'
import { jsx } from '../../..'

export const run = editor => {
export const run = (editor, options = {}) => {
Transforms.insertFragment(
editor,
<fragment>
<block>one</block>
<block>two</block>
<block>three</block>
</fragment>
</fragment>,
options
)
}
export const input = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ const fragment = (
<block>two</block>
</fragment>
)
export const run = editor => {
Transforms.insertFragment(editor, fragment)
export const run = (editor, options = {}) => {
Transforms.insertFragment(editor, fragment, options)
}
export const input = (
<editor>
Expand Down
Loading
Loading