From ca1c313dda6964402ba0e038e626effaf08d9902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 22 Dec 2025 09:54:57 +0100 Subject: [PATCH] fix: make Behavior Event matching faster Previously, every Behavior Event required filtering all registered behaviors to find matches. Now, we have a Behavior Index that pre-categorizes Behaviors by their `on` pattern: - `global`: Behaviors with `on: '*'` - `namespaced`: Behaviors with patterns like `on: 'select.*'` - `exact`: Behaviors with patterns like `on: 'insert.text'` Performing a Behavior Event now does O(1) map lookups instead of filtering, then merges and sorts the results to preserve priority order. The merging and sorting still allocates and iterates arrays, albeit a lot smaller. In the case of Behavior addition and removal, the index is marked as stale and then it is lazily rebuilt when the next Behavior Event is triggered. This mimics the previous caching Behavior sort order. --- .changeset/short-lamps-listen.md | 5 + .../editor/src/behaviors/behavior.index.ts | 77 ++++++++++ .../src/behaviors/behavior.perform-event.ts | 74 +++------- packages/editor/src/editor/editor-machine.ts | 53 ++++--- .../editor/tests/behavior-indexing.test.tsx | 134 ++++++++++++++++++ 5 files changed, 274 insertions(+), 69 deletions(-) create mode 100644 .changeset/short-lamps-listen.md create mode 100644 packages/editor/src/behaviors/behavior.index.ts create mode 100644 packages/editor/tests/behavior-indexing.test.tsx 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) + }) + }) +})