diff --git a/.changeset/short-lamps-listen.md b/.changeset/short-lamps-listen.md new file mode 100644 index 000000000..1baed7a7f --- /dev/null +++ b/.changeset/short-lamps-listen.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: make event Behavior matching faster diff --git a/packages/editor/src/behaviors/behavior.index.ts b/packages/editor/src/behaviors/behavior.index.ts new file mode 100644 index 000000000..b7f1a220b --- /dev/null +++ b/packages/editor/src/behaviors/behavior.index.ts @@ -0,0 +1,77 @@ +import type {Behavior} from './behavior.types.behavior' +import type {BehaviorEvent} from './behavior.types.event' + +export type BehaviorIndex = { + global: Array + namespaced: Map> + exact: Map> +} + +type SortedBehavior = { + behavior: Behavior + sortOrder: number +} + +/** + * Given an array of Behaviors, build a `BehaviorIndex` where the `sortOrder` + * of each Behavior is preserved. + */ +export function buildBehaviorIndex(behaviors: Array): BehaviorIndex { + const global: Array = [] + const namespaced = new Map>() + const exact = new Map>() + + let sortOrder = -1 + + for (const behavior of behaviors) { + sortOrder++ + const sortedBehavior = {behavior, sortOrder} + + if (behavior.on === '*') { + global.push(sortedBehavior) + continue + } + + if (behavior.on.endsWith('.*')) { + const namespace = behavior.on.slice(0, -2) + const indexedBehaviors = namespaced.get(namespace) ?? [] + + indexedBehaviors.push(sortedBehavior) + + namespaced.set(namespace, indexedBehaviors) + + continue + } + + const indexedBehaviors = exact.get(behavior.on) ?? [] + + indexedBehaviors.push(sortedBehavior) + + exact.set(behavior.on, indexedBehaviors) + } + + return {exact, global, namespaced} +} + +export function getEventBehaviors( + behaviorIndex: BehaviorIndex, + type: BehaviorEvent['type'], +): Array { + // Catches all events + const global = behaviorIndex.global + + // Handles scenarios like a Behavior listening for `select.*` and the event + // `select.block` is fired. + // OR a Behavior listening for `select.*` and the event `select` is fired. + const namespace = type.includes('.') ? type.split('.').at(0) : type + const namespaced = namespace + ? (behaviorIndex.namespaced.get(namespace) ?? []) + : [] + const exact = behaviorIndex.exact.get(type) ?? [] + + const sorted = [...global, ...namespaced, ...exact].sort( + (a, b) => a.sortOrder - b.sortOrder, + ) + + return sorted.map((sortedBehavior) => sortedBehavior.behavior) +} diff --git a/packages/editor/src/behaviors/behavior.perform-event.ts b/packages/editor/src/behaviors/behavior.perform-event.ts index cf3095fbb..c7e319fa2 100644 --- a/packages/editor/src/behaviors/behavior.perform-event.ts +++ b/packages/editor/src/behaviors/behavior.perform-event.ts @@ -7,7 +7,7 @@ import {debugWithName} from '../internal-utils/debug' import {performOperation} from '../operations/operation.perform' import type {PortableTextSlateEditor} from '../types/slate-editor' import {defaultKeyGenerator} from '../utils/key-generator' -import {abstractBehaviors} from './behavior.abstract' +import {getEventBehaviors, type BehaviorIndex} from './behavior.index' import type {BehaviorAction} from './behavior.types.action' import type {Behavior} from './behavior.types.behavior' import { @@ -34,8 +34,9 @@ function eventCategory(event: BehaviorEvent) { export function performEvent({ mode, - behaviors, - remainingEventBehaviors, + behaviorIndex, + abstractBehaviorIndex, + forwardFromBehaviors, event, editor, keyGenerator, @@ -45,8 +46,9 @@ export function performEvent({ sendBack, }: { mode: 'send' | 'raise' | 'execute' | 'forward' - behaviors: Array - remainingEventBehaviors: Array + behaviorIndex: BehaviorIndex + abstractBehaviorIndex: BehaviorIndex + forwardFromBehaviors?: Array event: BehaviorEvent editor: PortableTextSlateEditor keyGenerator: () => string @@ -70,45 +72,13 @@ export function performEvent({ JSON.stringify(event, null, 2), ) - const eventBehaviors = [ - ...remainingEventBehaviors, - ...abstractBehaviors, - ].filter((behavior) => { - // Catches all events - if (behavior.on === '*') { - return true - } - - const [listenedNamespace] = - behavior.on.includes('*') && behavior.on.includes('.') - ? behavior.on.split('.') - : [undefined] - const [eventNamespace] = event.type.includes('.') - ? event.type.split('.') - : [undefined] - - // Handles scenarios like a Behavior listening for `select.*` and the event - // `select.block` is fired. - if ( - listenedNamespace !== undefined && - eventNamespace !== undefined && - listenedNamespace === eventNamespace - ) { - return true - } - - // Handles scenarios like a Behavior listening for `select.*` and the event - // `select` is fired. - if ( - listenedNamespace !== undefined && - eventNamespace === undefined && - listenedNamespace === event.type - ) { - return true - } - - return behavior.on === event.type - }) + const eventBehaviors = + mode === 'forward' && forwardFromBehaviors + ? forwardFromBehaviors + : getEventBehaviors( + mode === 'execute' ? abstractBehaviorIndex : behaviorIndex, + event.type, + ) if (eventBehaviors.length === 0 && isSyntheticBehaviorEvent(event)) { nativeEvent?.preventDefault() @@ -270,14 +240,15 @@ export function performEvent({ } if (action.type === 'forward') { - const remainingEventBehaviors = eventBehaviors.slice( + const remainingBehaviors = eventBehaviors.slice( eventBehaviorIndex + 1, ) performEvent({ mode: mode === 'execute' ? 'execute' : 'forward', - behaviors, - remainingEventBehaviors: remainingEventBehaviors, + behaviorIndex, + abstractBehaviorIndex, + forwardFromBehaviors: remainingBehaviors, event: action.event, editor, keyGenerator, @@ -293,9 +264,8 @@ export function performEvent({ if (action.type === 'raise') { performEvent({ mode: mode === 'execute' ? 'execute' : 'raise', - behaviors, - remainingEventBehaviors: - mode === 'execute' ? remainingEventBehaviors : behaviors, + behaviorIndex, + abstractBehaviorIndex, event: action.event, editor, keyGenerator, @@ -310,8 +280,8 @@ export function performEvent({ performEvent({ mode: 'execute', - behaviors, - remainingEventBehaviors: [], + behaviorIndex, + abstractBehaviorIndex, event: action.event, editor, keyGenerator, diff --git a/packages/editor/src/editor/editor-machine.ts b/packages/editor/src/editor/editor-machine.ts index 6dca8fdbf..e1f2b01a3 100644 --- a/packages/editor/src/editor/editor-machine.ts +++ b/packages/editor/src/editor/editor-machine.ts @@ -11,8 +11,13 @@ import { setup, type ActorRefFrom, } from 'xstate' +import {abstractBehaviors} from '../behaviors/behavior.abstract' import type {BehaviorConfig} from '../behaviors/behavior.config' import {coreBehaviorsConfig} from '../behaviors/behavior.core' +import { + buildBehaviorIndex, + type BehaviorIndex, +} from '../behaviors/behavior.index' import {performEvent} from '../behaviors/behavior.perform-event' import type { BehaviorEvent, @@ -179,7 +184,9 @@ export const editorMachine = setup({ types: { context: {} as { behaviors: Set - behaviorsSorted: boolean + behaviorIndex: BehaviorIndex + abstractBehaviorIndex: BehaviorIndex + behaviorsIndexed: boolean converters: Set getLegacySchema: () => PortableTextMemberSchemaTypes keyGenerator: () => string @@ -214,7 +221,7 @@ export const editorMachine = setup({ return new Set([...context.behaviors, event.behaviorConfig]) }, - behaviorsSorted: false, + behaviorsIndexed: false, }), 'remove behavior from context': assign({ behaviors: ({context, event}) => { @@ -224,6 +231,7 @@ export const editorMachine = setup({ return new Set([...context.behaviors]) }, + behaviorsIndexed: false, }), 'add slate editor to context': assign({ slateEditor: ({context, event}) => { @@ -311,14 +319,10 @@ export const editorMachine = setup({ assertEvent(event, ['behavior event']) try { - const behaviors = [...context.behaviors.values()].map( - (config) => config.behavior, - ) - performEvent({ mode: 'send', - behaviors, - remainingEventBehaviors: behaviors, + behaviorIndex: context.behaviorIndex, + abstractBehaviorIndex: context.abstractBehaviorIndex, event: event.behaviorEvent, editor: event.editor, keyGenerator: context.keyGenerator, @@ -354,12 +358,22 @@ export const editorMachine = setup({ ) } }, - 'sort behaviors': assign({ - behaviors: ({context}) => - !context.behaviorsSorted - ? new Set(sortByPriority([...context.behaviors.values()])) - : context.behaviors, - behaviorsSorted: true, + 'index behaviors': assign(({context}) => { + if (context.behaviorsIndexed) { + return {} + } + + const sortedConfigs = sortByPriority([...context.behaviors.values()]) + const allBehaviors = [ + ...sortedConfigs.map((config) => config.behavior), + ...abstractBehaviors, + ] + + return { + behaviorIndex: buildBehaviorIndex(allBehaviors), + abstractBehaviorIndex: buildBehaviorIndex(abstractBehaviors), + behaviorsIndexed: true, + } }), }, guards: { @@ -382,7 +396,12 @@ export const editorMachine = setup({ id: 'editor', context: ({input}) => ({ behaviors: new Set(coreBehaviorsConfig), - behaviorsSorted: false, + behaviorIndex: buildBehaviorIndex([ + ...coreBehaviorsConfig.map((config) => config.behavior), + ...abstractBehaviors, + ]), + abstractBehaviorIndex: buildBehaviorIndex(abstractBehaviors), + behaviorsIndexed: false, converters: new Set(input.converters ?? []), getLegacySchema: input.getLegacySchema, keyGenerator: input.keyGenerator, @@ -416,7 +435,7 @@ export const editorMachine = setup({ initial: 'determine initial edit mode', on: { 'behavior event': { - actions: ['sort behaviors', 'handle behavior event'], + actions: ['index behaviors', 'handle behavior event'], guard: ({event}) => event.behaviorEvent.type === 'clipboard.copy' || event.behaviorEvent.type === 'mouse.click' || @@ -483,7 +502,7 @@ export const editorMachine = setup({ actions: ['emit read only'], }, 'behavior event': { - actions: ['sort behaviors', 'handle behavior event'], + actions: ['index behaviors', 'handle behavior event'], }, 'blur': { actions: 'handle blur', diff --git a/packages/editor/tests/behavior-indexing.test.tsx b/packages/editor/tests/behavior-indexing.test.tsx new file mode 100644 index 000000000..fda04c854 --- /dev/null +++ b/packages/editor/tests/behavior-indexing.test.tsx @@ -0,0 +1,134 @@ +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {effect} from '../src/behaviors' +import {defineBehavior} from '../src/behaviors/behavior.types.behavior' +import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {createTestEditor} from '../src/test/vitest' + +describe('Behavior Indexing', () => { + test('Scenario: Behaviors are indexed and found for matching events', async () => { + const sideEffect = vi.fn() + const customBehavior = defineBehavior({ + on: 'custom.test', + actions: [() => [effect(sideEffect)]], + }) + + const {editor, locator} = await createTestEditor({ + children: , + }) + + await userEvent.click(locator) + + editor.send({type: 'custom.test'}) + + await vi.waitFor(() => { + expect(sideEffect).toHaveBeenCalled() + }) + }) + + test('Scenario: Index is updated when a behavior is added', async () => { + const sideEffectA = vi.fn() + const sideEffectB = vi.fn() + + const behaviorA = defineBehavior({ + on: 'custom.a', + actions: [() => [effect(sideEffectA)]], + }) + + const behaviorB = defineBehavior({ + on: 'custom.b', + actions: [() => [effect(sideEffectB)]], + }) + + const {editor} = await createTestEditor({ + children: , + }) + + editor.send({type: 'custom.a'}) + + await vi.waitFor(() => { + expect(sideEffectA).toHaveBeenCalledTimes(1) + }) + + editor.send({type: 'custom.b'}) + + await vi.waitFor(() => { + expect(sideEffectB).not.toHaveBeenCalled() + }) + + const unregister = editor.registerBehavior({behavior: behaviorB}) + + editor.send({type: 'custom.b'}) + + await vi.waitFor(() => { + expect(sideEffectB).toHaveBeenCalledTimes(1) + }) + + unregister() + }) + + test('Scenario: Index is updated when a behavior is removed', async () => { + const sideEffect = vi.fn() + const behavior = defineBehavior({ + on: 'custom.removable', + actions: [() => [effect(sideEffect)]], + }) + + const {editor} = await createTestEditor({}) + + const unregister = editor.registerBehavior({behavior}) + + editor.send({type: 'custom.removable'}) + + await vi.waitFor(() => { + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + + unregister() + + editor.send({type: 'custom.removable'}) + + await vi.waitFor(() => { + expect(sideEffect).toHaveBeenCalledTimes(1) + }) + }) + + test('Scenario: Wildcard behaviors match custom events', async () => { + const sideEffect = vi.fn() + const behavior = defineBehavior({ + on: '*', + guard: ({event}) => event.type.startsWith('custom.'), + actions: [() => [effect(sideEffect)]], + }) + + const {editor} = await createTestEditor({ + children: , + }) + + editor.send({type: 'custom.any'}) + editor.send({type: 'custom.other'}) + + await vi.waitFor(() => { + expect(sideEffect).toHaveBeenCalledTimes(2) + }) + }) + + test('Scenario: Namespaced behaviors match events in namespace', async () => { + const sideEffect = vi.fn() + const behavior = defineBehavior({ + on: 'custom.*', + actions: [() => [effect(sideEffect)]], + }) + + const {editor} = await createTestEditor({ + children: , + }) + + editor.send({type: 'custom.one'}) + editor.send({type: 'custom.two'}) + + await vi.waitFor(() => { + expect(sideEffect).toHaveBeenCalledTimes(2) + }) + }) +})