@tldraw/store 5.4.2 → 5.5.0-canary.003bf5951274
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +8 -5
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/Store.js +21 -12
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreQueries.js +10 -7
- package/dist-cjs/lib/StoreQueries.js.map +2 -2
- package/dist-cjs/lib/StoreSideEffects.js +22 -30
- package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
- package/dist-esm/index.d.mts +8 -5
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/Store.mjs +21 -12
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreQueries.mjs +10 -7
- package/dist-esm/lib/StoreQueries.mjs.map +2 -2
- package/dist-esm/lib/StoreSideEffects.mjs +22 -30
- package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
- package/package.json +4 -4
- package/src/lib/Store.ts +25 -13
- package/src/lib/StoreListeners.test.ts +32 -0
- package/src/lib/StoreQueries.test.ts +75 -0
- package/src/lib/StoreQueries.ts +15 -7
- package/src/lib/StoreSideEffects.test.ts +66 -0
- package/src/lib/StoreSideEffects.ts +24 -30
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/StoreSideEffects.ts"],
|
|
4
|
-
"sourcesContent": ["import { UnknownRecord } from './BaseRecord'\nimport { Store } from './Store'\n\n/**\n * Handler function called before a record is created in the store.\n * The handler receives the record to be created and can return a modified version.\n * Use this to validate, transform, or modify records before they are added to the store.\n *\n * @param record - The record about to be created\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns The record to actually create (may be modified)\n *\n * @example\n * ```ts\n * const handler: StoreBeforeCreateHandler<MyRecord> = (record, source) => {\n * // Ensure all user-created records have a timestamp\n * if (source === 'user' && !record.createdAt) {\n * return { ...record, createdAt: Date.now() }\n * }\n * return record\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeCreateHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => R\n/**\n * Handler function called after a record has been successfully created in the store.\n * Use this for side effects that should happen after record creation, such as updating\n * related records or triggering notifications.\n *\n * @param record - The record that was created\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterCreateHandler<BookRecord> = (book, source) => {\n * if (source === 'user') {\n * console.log(`New book added: ${book.title}`)\n * updateAuthorBookCount(book.authorId)\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterCreateHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void\n/**\n * Handler function called before a record is updated in the store.\n * The handler receives the current and new versions of the record and can return\n * a modified version or the original to prevent the change.\n *\n * @param prev - The current version of the record in the store\n * @param next - The proposed new version of the record\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns The record version to actually store (may be modified or the original to block change)\n *\n * @example\n * ```ts\n * const handler: StoreBeforeChangeHandler<ShapeRecord> = (prev, next, source) => {\n * // Prevent shapes from being moved outside the canvas bounds\n * if (next.x < 0 || next.y < 0) {\n * return prev // Block the change\n * }\n * return next\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeChangeHandler<R extends UnknownRecord> = (\n\tprev: R,\n\tnext: R,\n\tsource: 'remote' | 'user'\n) => R\n/**\n * Handler function called after a record has been successfully updated in the store.\n * Use this for side effects that should happen after record changes, such as\n * updating related records or maintaining consistency constraints.\n *\n * @param prev - The previous version of the record\n * @param next - The new version of the record that was stored\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterChangeHandler<ShapeRecord> = (prev, next, source) => {\n * // Update connected arrows when a shape moves\n * if (prev.x !== next.x || prev.y !== next.y) {\n * updateConnectedArrows(next.id)\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterChangeHandler<R extends UnknownRecord> = (\n\tprev: R,\n\tnext: R,\n\tsource: 'remote' | 'user'\n) => void\n/**\n * Handler function called before a record is deleted from the store.\n * The handler can return `false` to prevent the deletion from occurring.\n *\n * @param record - The record about to be deleted\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns `false` to prevent deletion, `void` or any other value to allow it\n *\n * @example\n * ```ts\n * const handler: StoreBeforeDeleteHandler<BookRecord> = (book, source) => {\n * // Prevent deletion of books that are currently checked out\n * if (book.isCheckedOut) {\n * console.warn('Cannot delete checked out book')\n * return false\n * }\n * // Allow deletion for other books\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeDeleteHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void | false\n/**\n * Handler function called after a record has been successfully deleted from the store.\n * Use this for cleanup operations and maintaining referential integrity.\n *\n * @param record - The record that was deleted\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterDeleteHandler<ShapeRecord> = (shape, source) => {\n * // Clean up arrows that were connected to this shape\n * const connectedArrows = findArrowsConnectedTo(shape.id)\n * store.remove(connectedArrows.map(arrow => arrow.id))\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterDeleteHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void\n\n/**\n * Handler function called when a store operation (atomic transaction) completes.\n * This is useful for performing actions after a batch of changes has been applied,\n * such as triggering saves or sending notifications.\n *\n * @param source - Whether the operation originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreOperationCompleteHandler = (source) => {\n * if (source === 'user') {\n * // Auto-save after user operations complete\n * saveStoreSnapshot()\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void\n\n/**\n * The side effect manager (aka a \"correct state enforcer\") is responsible\n * for making sure that the store's state is always correct and consistent. This includes\n * things like: deleting a shape if its parent is deleted; unbinding\n * arrows when their binding target is deleted; maintaining referential integrity; etc.\n *\n * Side effects are organized into lifecycle hooks that run before and after\n * record operations (create, change, delete), allowing you to validate data,\n * transform records, and maintain business rules.\n *\n * @example\n * ```ts\n * const sideEffects = new StoreSideEffects(store)\n *\n * // Ensure arrows are deleted when their target shape is deleted\n * sideEffects.registerAfterDeleteHandler('shape', (shape) => {\n * const arrows = store.query.records('arrow', () => ({\n * toId: { eq: shape.id }\n * })).get()\n * store.remove(arrows.map(arrow => arrow.id))\n * })\n * ```\n *\n * @public\n */\nexport class StoreSideEffects<R extends UnknownRecord> {\n\t/**\n\t * Creates a new side effects manager for the given store.\n\t *\n\t * store - The store instance to manage side effects for\n\t */\n\tconstructor(private readonly store: Store<R>) {}\n\n\tprivate _beforeCreateHandlers: { [K in string]?: StoreBeforeCreateHandler<any>[] } = {}\n\tprivate _afterCreateHandlers: { [K in string]?: StoreAfterCreateHandler<any>[] } = {}\n\tprivate _beforeChangeHandlers: { [K in string]?: StoreBeforeChangeHandler<any>[] } = {}\n\tprivate _afterChangeHandlers: { [K in string]?: StoreAfterChangeHandler<any>[] } = {}\n\tprivate _beforeDeleteHandlers: { [K in string]?: StoreBeforeDeleteHandler<any>[] } = {}\n\tprivate _afterDeleteHandlers: { [K in string]?: StoreAfterDeleteHandler<any>[] } = {}\n\tprivate _operationCompleteHandlers: StoreOperationCompleteHandler[] = []\n\n\tprivate _isEnabled = true\n\t/**\n\t * Checks whether side effects are currently enabled.\n\t * When disabled, all side effect handlers are bypassed.\n\t *\n\t * @returns `true` if side effects are enabled, `false` otherwise\n\t * @internal\n\t */\n\tisEnabled() {\n\t\treturn this._isEnabled\n\t}\n\t/**\n\t * Enables or disables side effects processing.\n\t * When disabled, no side effect handlers will be called.\n\t *\n\t * @param enabled - Whether to enable or disable side effects\n\t * @internal\n\t */\n\tsetIsEnabled(enabled: boolean) {\n\t\tthis._isEnabled = enabled\n\t}\n\n\t/**\n\t * Processes all registered 'before create' handlers for a record.\n\t * Handlers are called in registration order and can transform the record.\n\t *\n\t * @param record - The record about to be created\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns The potentially modified record to actually create\n\t * @internal\n\t */\n\thandleBeforeCreate(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return record\n\n\t\tconst handlers = this._beforeCreateHandlers[record.typeName] as StoreBeforeCreateHandler<R>[]\n\t\tif (handlers) {\n\t\t\tlet r = record\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tr = handler(r, source)\n\t\t\t}\n\t\t\treturn r\n\t\t}\n\n\t\treturn record\n\t}\n\n\t/**\n\t * Processes all registered 'after create' handlers for a record.\n\t * Handlers are called in registration order after the record is created.\n\t *\n\t * @param record - The record that was created\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterCreate(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterCreateHandlers[record.typeName] as StoreAfterCreateHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(record, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered 'before change' handlers for a record.\n\t * Handlers are called in registration order and can modify or block the change.\n\t *\n\t * @param prev - The current version of the record\n\t * @param next - The proposed new version of the record\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns The potentially modified record to actually store\n\t * @internal\n\t */\n\thandleBeforeChange(prev: R, next: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return next\n\n\t\tconst handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[]\n\t\tif (handlers) {\n\t\t\tlet r = next\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tr = handler(prev, r, source)\n\t\t\t}\n\t\t\treturn r\n\t\t}\n\n\t\treturn next\n\t}\n\n\t/**\n\t * Processes all registered 'after change' handlers for a record.\n\t * Handlers are called in registration order after the record is updated.\n\t *\n\t * @param prev - The previous version of the record\n\t * @param next - The new version of the record that was stored\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterChange(prev: R, next: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterChangeHandlers[next.typeName] as StoreAfterChangeHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(prev, next, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered 'before delete' handlers for a record.\n\t * If any handler returns `false`, the deletion is prevented.\n\t *\n\t * @param record - The record about to be deleted\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns `true` to allow deletion, `false` to prevent it\n\t * @internal\n\t */\n\thandleBeforeDelete(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return true\n\n\t\tconst handlers = this._beforeDeleteHandlers[record.typeName] as StoreBeforeDeleteHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tif (handler(record, source) === false) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\t/**\n\t * Processes all registered 'after delete' handlers for a record.\n\t * Handlers are called in registration order after the record is deleted.\n\t *\n\t * @param record - The record that was deleted\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterDelete(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterDeleteHandlers[record.typeName] as StoreAfterDeleteHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(record, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered operation complete handlers.\n\t * Called after an atomic store operation finishes.\n\t *\n\t * @param source - Whether the operation originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleOperationComplete(source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tfor (const handler of this._operationCompleteHandlers) {\n\t\t\thandler(source)\n\t\t}\n\t}\n\n\t/**\n\t * Internal helper for registering multiple side effect handlers at once and keeping them organized.\n\t * This provides a convenient way to register handlers for multiple record types and lifecycle events\n\t * in a single call, returning a single cleanup function.\n\t *\n\t * @param handlersByType - An object mapping record type names to their respective handlers\n\t * @returns A function that removes all registered handlers when called\n\t *\n\t * @example\n\t * ```ts\n\t * const cleanup = sideEffects.register({\n\t * shape: {\n\t * afterDelete: (shape) => console.log('Shape deleted:', shape.id),\n\t * beforeChange: (prev, next) => ({ ...next, lastModified: Date.now() })\n\t * },\n\t * arrow: {\n\t * afterCreate: (arrow) => updateConnectedShapes(arrow)\n\t * }\n\t * })\n\t *\n\t * // Later, remove all handlers\n\t * cleanup()\n\t * ```\n\t *\n\t * @internal\n\t */\n\tregister(handlersByType: {\n\t\t[T in R as T['typeName']]?: {\n\t\t\tbeforeCreate?: StoreBeforeCreateHandler<T>\n\t\t\tafterCreate?: StoreAfterCreateHandler<T>\n\t\t\tbeforeChange?: StoreBeforeChangeHandler<T>\n\t\t\tafterChange?: StoreAfterChangeHandler<T>\n\t\t\tbeforeDelete?: StoreBeforeDeleteHandler<T>\n\t\t\tafterDelete?: StoreAfterDeleteHandler<T>\n\t\t}\n\t}) {\n\t\tconst disposes: (() => void)[] = []\n\t\tfor (const [type, handlers] of Object.entries(handlersByType) as any) {\n\t\t\tif (handlers?.beforeCreate) {\n\t\t\t\tdisposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate))\n\t\t\t}\n\t\t\tif (handlers?.afterCreate) {\n\t\t\t\tdisposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate))\n\t\t\t}\n\t\t\tif (handlers?.beforeChange) {\n\t\t\t\tdisposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange))\n\t\t\t}\n\t\t\tif (handlers?.afterChange) {\n\t\t\t\tdisposes.push(this.registerAfterChangeHandler(type, handlers.afterChange))\n\t\t\t}\n\t\t\tif (handlers?.beforeDelete) {\n\t\t\t\tdisposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete))\n\t\t\t}\n\t\t\tif (handlers?.afterDelete) {\n\t\t\t\tdisposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete))\n\t\t\t}\n\t\t}\n\t\treturn () => {\n\t\t\tfor (const dispose of disposes) dispose()\n\t\t}\n\t}\n\n\t/**\n\t * Register a handler to be called before a record of a certain type is created. Return a\n\t * modified record from the handler to change the record that will be created.\n\t *\n\t * Use this handle only to modify the creation of the record itself. If you want to trigger a\n\t * side-effect on a different record (for example, moving one shape when another is created),\n\t * use {@link StoreSideEffects.registerAfterCreateHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {\n\t * // only modify shapes created by the user\n\t * if (source !== 'user') return shape\n\t *\n\t * //by default, arrow shapes have no label. Let's make sure they always have a label.\n\t * if (shape.type === 'arrow') {\n\t * return {...shape, props: {...shape.props, text: 'an arrow'}}\n\t * }\n\t *\n\t * // other shapes get returned unmodified\n\t * return shape\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeCreateHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeCreateHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._beforeCreateHandlers[typeName] as StoreBeforeCreateHandler<any>[]\n\t\tif (!handlers) this._beforeCreateHandlers[typeName] = []\n\t\tthis._beforeCreateHandlers[typeName]!.push(handler)\n\t\treturn () => remove(this._beforeCreateHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is created. This is useful for side-effects\n\t * that would update _other_ records. If you want to modify the record being created use\n\t * {@link StoreSideEffects.registerBeforeCreateHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterCreateHandler('page', (page, source) => {\n\t * // Automatically create a shape when a page is created\n\t * editor.createShape({\n\t * id: createShapeId(),\n\t * type: 'text',\n\t * props: { richText: toRichText(page.name) },\n\t * })\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterCreateHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterCreateHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._afterCreateHandlers[typeName] as StoreAfterCreateHandler<any>[]\n\t\tif (!handlers) this._afterCreateHandlers[typeName] = []\n\t\tthis._afterCreateHandlers[typeName]!.push(handler)\n\t\treturn () => remove(this._afterCreateHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called before a record is changed. The handler is given the old and\n\t * new record - you can return a modified record to apply a different update, or the old record\n\t * to block the update entirely.\n\t *\n\t * Use this handler only for intercepting updates to the record itself. If you want to update\n\t * other records in response to a change, use\n\t * {@link StoreSideEffects.registerAfterChangeHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {\n\t * if (next.isLocked && !prev.isLocked) {\n\t * // prevent shapes from ever being locked:\n\t * return prev\n\t * }\n\t * // other types of change are allowed\n\t * return next\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeChangeHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeChangeHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._beforeChangeHandlers[typeName] as StoreBeforeChangeHandler<any>[]\n\t\tif (!handlers) this._beforeChangeHandlers[typeName] = []\n\t\tthis._beforeChangeHandlers[typeName]!.push(handler)\n\t\treturn () => remove(this._beforeChangeHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is changed. This is useful for side-effects\n\t * that would update _other_ records - if you want to modify the record being changed, use\n\t * {@link StoreSideEffects.registerBeforeChangeHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {\n\t * if (next.props.color === 'red') {\n\t * // there can only be one red shape at a time:\n\t * const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id)\n\t * editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}})))\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterChangeHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterChangeHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._afterChangeHandlers[typeName] as StoreAfterChangeHandler<any>[]\n\t\tif (!handlers) this._afterChangeHandlers[typeName] = []\n\t\tthis._afterChangeHandlers[typeName]!.push(handler as StoreAfterChangeHandler<any>)\n\t\treturn () => remove(this._afterChangeHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called before a record is deleted. The handler can return `false` to\n\t * prevent the deletion.\n\t *\n\t * Use this handler only for intercepting deletions of the record itself. If you want to do\n\t * something to other records in response to a deletion, use\n\t * {@link StoreSideEffects.registerAfterDeleteHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {\n\t * if (shape.props.color === 'red') {\n\t * // prevent red shapes from being deleted\n\t * \t return false\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeDeleteHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeDeleteHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._beforeDeleteHandlers[typeName] as StoreBeforeDeleteHandler<any>[]\n\t\tif (!handlers) this._beforeDeleteHandlers[typeName] = []\n\t\tthis._beforeDeleteHandlers[typeName]!.push(handler as StoreBeforeDeleteHandler<any>)\n\t\treturn () => remove(this._beforeDeleteHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is deleted. This is useful for side-effects\n\t * that would update _other_ records - if you want to block the deletion of the record itself,\n\t * use {@link StoreSideEffects.registerBeforeDeleteHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {\n\t * // if the last shape in a frame is deleted, delete the frame too:\n\t * const parentFrame = editor.getShape(shape.parentId)\n\t * if (!parentFrame || parentFrame.type !== 'frame') return\n\t *\n\t * const siblings = editor.getSortedChildIdsForParent(parentFrame)\n\t * if (siblings.length === 0) {\n\t * editor.deleteShape(parentFrame.id)\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterDeleteHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterDeleteHandler<R & { typeName: T }>\n\t) {\n\t\tconst handlers = this._afterDeleteHandlers[typeName] as StoreAfterDeleteHandler<any>[]\n\t\tif (!handlers) this._afterDeleteHandlers[typeName] = []\n\t\tthis._afterDeleteHandlers[typeName]!.push(handler as StoreAfterDeleteHandler<any>)\n\t\treturn () => remove(this._afterDeleteHandlers[typeName]!, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called when a store completes an atomic operation.\n\t *\n\t * @example\n\t * ```ts\n\t * let count = 0\n\t *\n\t * editor.sideEffects.registerOperationCompleteHandler(() => count++)\n\t *\n\t * editor.selectAll()\n\t * expect(count).toBe(1)\n\t *\n\t * editor.store.atomic(() => {\n\t *\teditor.selectNone()\n\t * \teditor.selectAll()\n\t * })\n\t *\n\t * expect(count).toBe(2)\n\t * ```\n\t *\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t *\n\t * @public\n\t */\n\tregisterOperationCompleteHandler(handler: StoreOperationCompleteHandler) {\n\t\tthis._operationCompleteHandlers.push(handler)\n\t\treturn () => remove(this._operationCompleteHandlers, handler)\n\t}\n}\n\nfunction remove(array: any[], item: any) {\n\tconst index = array.indexOf(item)\n\tif (index >= 0) {\n\t\tarray.splice(index, 1)\n\t}\n}\n"],
|
|
5
|
-
"mappings": "AA0MO,MAAM,iBAA0C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtD,YAA6B,OAAiB;AAAjB;AAAA,EAAkB;AAAA,EAAlB;AAAA,EAErB,wBAA6E,CAAC;AAAA,EAC9E,uBAA2E,CAAC;AAAA,EAC5E,wBAA6E,CAAC;AAAA,EAC9E,uBAA2E,CAAC;AAAA,EAC5E,wBAA6E,CAAC;AAAA,EAC9E,uBAA2E,CAAC;AAAA,EAC5E,6BAA8D,CAAC;AAAA,EAE/D,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import { UnknownRecord } from './BaseRecord'\nimport { Store } from './Store'\n\n/**\n * Handler function called before a record is created in the store.\n * The handler receives the record to be created and can return a modified version.\n * Use this to validate, transform, or modify records before they are added to the store.\n *\n * @param record - The record about to be created\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns The record to actually create (may be modified)\n *\n * @example\n * ```ts\n * const handler: StoreBeforeCreateHandler<MyRecord> = (record, source) => {\n * // Ensure all user-created records have a timestamp\n * if (source === 'user' && !record.createdAt) {\n * return { ...record, createdAt: Date.now() }\n * }\n * return record\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeCreateHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => R\n/**\n * Handler function called after a record has been successfully created in the store.\n * Use this for side effects that should happen after record creation, such as updating\n * related records or triggering notifications.\n *\n * @param record - The record that was created\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterCreateHandler<BookRecord> = (book, source) => {\n * if (source === 'user') {\n * console.log(`New book added: ${book.title}`)\n * updateAuthorBookCount(book.authorId)\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterCreateHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void\n/**\n * Handler function called before a record is updated in the store.\n * The handler receives the current and new versions of the record and can return\n * a modified version or the original to prevent the change.\n *\n * @param prev - The current version of the record in the store\n * @param next - The proposed new version of the record\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns The record version to actually store (may be modified or the original to block change)\n *\n * @example\n * ```ts\n * const handler: StoreBeforeChangeHandler<ShapeRecord> = (prev, next, source) => {\n * // Prevent shapes from being moved outside the canvas bounds\n * if (next.x < 0 || next.y < 0) {\n * return prev // Block the change\n * }\n * return next\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeChangeHandler<R extends UnknownRecord> = (\n\tprev: R,\n\tnext: R,\n\tsource: 'remote' | 'user'\n) => R\n/**\n * Handler function called after a record has been successfully updated in the store.\n * Use this for side effects that should happen after record changes, such as\n * updating related records or maintaining consistency constraints.\n *\n * @param prev - The previous version of the record\n * @param next - The new version of the record that was stored\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterChangeHandler<ShapeRecord> = (prev, next, source) => {\n * // Update connected arrows when a shape moves\n * if (prev.x !== next.x || prev.y !== next.y) {\n * updateConnectedArrows(next.id)\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterChangeHandler<R extends UnknownRecord> = (\n\tprev: R,\n\tnext: R,\n\tsource: 'remote' | 'user'\n) => void\n/**\n * Handler function called before a record is deleted from the store.\n * The handler can return `false` to prevent the deletion from occurring.\n *\n * @param record - The record about to be deleted\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n * @returns `false` to prevent deletion, `void` or any other value to allow it\n *\n * @example\n * ```ts\n * const handler: StoreBeforeDeleteHandler<BookRecord> = (book, source) => {\n * // Prevent deletion of books that are currently checked out\n * if (book.isCheckedOut) {\n * console.warn('Cannot delete checked out book')\n * return false\n * }\n * // Allow deletion for other books\n * }\n * ```\n *\n * @public\n */\nexport type StoreBeforeDeleteHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void | false\n/**\n * Handler function called after a record has been successfully deleted from the store.\n * Use this for cleanup operations and maintaining referential integrity.\n *\n * @param record - The record that was deleted\n * @param source - Whether the change originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreAfterDeleteHandler<ShapeRecord> = (shape, source) => {\n * // Clean up arrows that were connected to this shape\n * const connectedArrows = findArrowsConnectedTo(shape.id)\n * store.remove(connectedArrows.map(arrow => arrow.id))\n * }\n * ```\n *\n * @public\n */\nexport type StoreAfterDeleteHandler<R extends UnknownRecord> = (\n\trecord: R,\n\tsource: 'remote' | 'user'\n) => void\n\n/**\n * Handler function called when a store operation (atomic transaction) completes.\n * This is useful for performing actions after a batch of changes has been applied,\n * such as triggering saves or sending notifications.\n *\n * @param source - Whether the operation originated from 'user' interaction or 'remote' synchronization\n *\n * @example\n * ```ts\n * const handler: StoreOperationCompleteHandler = (source) => {\n * if (source === 'user') {\n * // Auto-save after user operations complete\n * saveStoreSnapshot()\n * }\n * }\n * ```\n *\n * @public\n */\nexport type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void\n\n/**\n * The side effect manager (aka a \"correct state enforcer\") is responsible\n * for making sure that the store's state is always correct and consistent. This includes\n * things like: deleting a shape if its parent is deleted; unbinding\n * arrows when their binding target is deleted; maintaining referential integrity; etc.\n *\n * Side effects are organized into lifecycle hooks that run before and after\n * record operations (create, change, delete), allowing you to validate data,\n * transform records, and maintain business rules.\n *\n * @example\n * ```ts\n * const sideEffects = new StoreSideEffects(store)\n *\n * // Ensure arrows are deleted when their target shape is deleted\n * sideEffects.registerAfterDeleteHandler('shape', (shape) => {\n * const arrows = store.query.records('arrow', () => ({\n * toId: { eq: shape.id }\n * })).get()\n * store.remove(arrows.map(arrow => arrow.id))\n * })\n * ```\n *\n * @public\n */\nexport class StoreSideEffects<R extends UnknownRecord> {\n\t/**\n\t * Creates a new side effects manager for the given store.\n\t *\n\t * store - The store instance to manage side effects for\n\t */\n\tconstructor(private readonly store: Store<R>) {}\n\n\tprivate _beforeCreateHandlers: { [K in string]?: StoreBeforeCreateHandler<any>[] } = {}\n\tprivate _afterCreateHandlers: { [K in string]?: StoreAfterCreateHandler<any>[] } = {}\n\tprivate _beforeChangeHandlers: { [K in string]?: StoreBeforeChangeHandler<any>[] } = {}\n\tprivate _afterChangeHandlers: { [K in string]?: StoreAfterChangeHandler<any>[] } = {}\n\tprivate _beforeDeleteHandlers: { [K in string]?: StoreBeforeDeleteHandler<any>[] } = {}\n\tprivate _afterDeleteHandlers: { [K in string]?: StoreAfterDeleteHandler<any>[] } = {}\n\tprivate _operationCompleteHandlers: StoreOperationCompleteHandler[] = []\n\n\tprivate _isEnabled = true\n\n\t// Handler arrays are replaced, never mutated in place: dispatch iterates the array it looked up,\n\t// so a handler that removes itself (or registers another) while running must not shift the\n\t// entries out from under that iteration.\n\tprivate addHandler<H>(handlers: { [K in string]?: H[] }, typeName: string, handler: H) {\n\t\thandlers[typeName] = [...(handlers[typeName] ?? []), handler]\n\t\treturn () => {\n\t\t\thandlers[typeName] = withoutFirst(handlers[typeName] ?? [], handler)\n\t\t}\n\t}\n\t/**\n\t * Checks whether side effects are currently enabled.\n\t * When disabled, all side effect handlers are bypassed.\n\t *\n\t * @returns `true` if side effects are enabled, `false` otherwise\n\t * @internal\n\t */\n\tisEnabled() {\n\t\treturn this._isEnabled\n\t}\n\t/**\n\t * Enables or disables side effects processing.\n\t * When disabled, no side effect handlers will be called.\n\t *\n\t * @param enabled - Whether to enable or disable side effects\n\t * @internal\n\t */\n\tsetIsEnabled(enabled: boolean) {\n\t\tthis._isEnabled = enabled\n\t}\n\n\t/**\n\t * Processes all registered 'before create' handlers for a record.\n\t * Handlers are called in registration order and can transform the record.\n\t *\n\t * @param record - The record about to be created\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns The potentially modified record to actually create\n\t * @internal\n\t */\n\thandleBeforeCreate(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return record\n\n\t\tconst handlers = this._beforeCreateHandlers[record.typeName] as StoreBeforeCreateHandler<R>[]\n\t\tif (handlers) {\n\t\t\tlet r = record\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tr = handler(r, source)\n\t\t\t}\n\t\t\treturn r\n\t\t}\n\n\t\treturn record\n\t}\n\n\t/**\n\t * Processes all registered 'after create' handlers for a record.\n\t * Handlers are called in registration order after the record is created.\n\t *\n\t * @param record - The record that was created\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterCreate(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterCreateHandlers[record.typeName] as StoreAfterCreateHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(record, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered 'before change' handlers for a record.\n\t * Handlers are called in registration order and can modify or block the change.\n\t *\n\t * @param prev - The current version of the record\n\t * @param next - The proposed new version of the record\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns The potentially modified record to actually store\n\t * @internal\n\t */\n\thandleBeforeChange(prev: R, next: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return next\n\n\t\tconst handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[]\n\t\tif (handlers) {\n\t\t\tlet r = next\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tr = handler(prev, r, source)\n\t\t\t}\n\t\t\treturn r\n\t\t}\n\n\t\treturn next\n\t}\n\n\t/**\n\t * Processes all registered 'after change' handlers for a record.\n\t * Handlers are called in registration order after the record is updated.\n\t *\n\t * @param prev - The previous version of the record\n\t * @param next - The new version of the record that was stored\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterChange(prev: R, next: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterChangeHandlers[next.typeName] as StoreAfterChangeHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(prev, next, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered 'before delete' handlers for a record.\n\t * If any handler returns `false`, the deletion is prevented.\n\t *\n\t * @param record - The record about to be deleted\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @returns `true` to allow deletion, `false` to prevent it\n\t * @internal\n\t */\n\thandleBeforeDelete(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return true\n\n\t\tconst handlers = this._beforeDeleteHandlers[record.typeName] as StoreBeforeDeleteHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\tif (handler(record, source) === false) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\t/**\n\t * Processes all registered 'after delete' handlers for a record.\n\t * Handlers are called in registration order after the record is deleted.\n\t *\n\t * @param record - The record that was deleted\n\t * @param source - Whether the change originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleAfterDelete(record: R, source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tconst handlers = this._afterDeleteHandlers[record.typeName] as StoreAfterDeleteHandler<R>[]\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\thandler(record, source)\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Processes all registered operation complete handlers.\n\t * Called after an atomic store operation finishes.\n\t *\n\t * @param source - Whether the operation originated from 'user' or 'remote'\n\t * @internal\n\t */\n\thandleOperationComplete(source: 'remote' | 'user') {\n\t\tif (!this._isEnabled) return\n\n\t\tfor (const handler of this._operationCompleteHandlers) {\n\t\t\thandler(source)\n\t\t}\n\t}\n\n\t/**\n\t * Internal helper for registering multiple side effect handlers at once and keeping them organized.\n\t * This provides a convenient way to register handlers for multiple record types and lifecycle events\n\t * in a single call, returning a single cleanup function.\n\t *\n\t * @param handlersByType - An object mapping record type names to their respective handlers\n\t * @returns A function that removes all registered handlers when called\n\t *\n\t * @example\n\t * ```ts\n\t * const cleanup = sideEffects.register({\n\t * shape: {\n\t * afterDelete: (shape) => console.log('Shape deleted:', shape.id),\n\t * beforeChange: (prev, next) => ({ ...next, lastModified: Date.now() })\n\t * },\n\t * arrow: {\n\t * afterCreate: (arrow) => updateConnectedShapes(arrow)\n\t * }\n\t * })\n\t *\n\t * // Later, remove all handlers\n\t * cleanup()\n\t * ```\n\t *\n\t * @internal\n\t */\n\tregister(handlersByType: {\n\t\t[T in R as T['typeName']]?: {\n\t\t\tbeforeCreate?: StoreBeforeCreateHandler<T>\n\t\t\tafterCreate?: StoreAfterCreateHandler<T>\n\t\t\tbeforeChange?: StoreBeforeChangeHandler<T>\n\t\t\tafterChange?: StoreAfterChangeHandler<T>\n\t\t\tbeforeDelete?: StoreBeforeDeleteHandler<T>\n\t\t\tafterDelete?: StoreAfterDeleteHandler<T>\n\t\t}\n\t}) {\n\t\tconst disposes: (() => void)[] = []\n\t\tfor (const [type, handlers] of Object.entries(handlersByType) as any) {\n\t\t\tif (handlers?.beforeCreate) {\n\t\t\t\tdisposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate))\n\t\t\t}\n\t\t\tif (handlers?.afterCreate) {\n\t\t\t\tdisposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate))\n\t\t\t}\n\t\t\tif (handlers?.beforeChange) {\n\t\t\t\tdisposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange))\n\t\t\t}\n\t\t\tif (handlers?.afterChange) {\n\t\t\t\tdisposes.push(this.registerAfterChangeHandler(type, handlers.afterChange))\n\t\t\t}\n\t\t\tif (handlers?.beforeDelete) {\n\t\t\t\tdisposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete))\n\t\t\t}\n\t\t\tif (handlers?.afterDelete) {\n\t\t\t\tdisposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete))\n\t\t\t}\n\t\t}\n\t\treturn () => {\n\t\t\tfor (const dispose of disposes) dispose()\n\t\t}\n\t}\n\n\t/**\n\t * Register a handler to be called before a record of a certain type is created. Return a\n\t * modified record from the handler to change the record that will be created.\n\t *\n\t * Use this handle only to modify the creation of the record itself. If you want to trigger a\n\t * side-effect on a different record (for example, moving one shape when another is created),\n\t * use {@link StoreSideEffects.registerAfterCreateHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {\n\t * // only modify shapes created by the user\n\t * if (source !== 'user') return shape\n\t *\n\t * //by default, arrow shapes have no label. Let's make sure they always have a label.\n\t * if (shape.type === 'arrow') {\n\t * return {...shape, props: {...shape.props, text: 'an arrow'}}\n\t * }\n\t *\n\t * // other shapes get returned unmodified\n\t * return shape\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeCreateHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeCreateHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._beforeCreateHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is created. This is useful for side-effects\n\t * that would update _other_ records. If you want to modify the record being created use\n\t * {@link StoreSideEffects.registerBeforeCreateHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterCreateHandler('page', (page, source) => {\n\t * // Automatically create a shape when a page is created\n\t * editor.createShape({\n\t * id: createShapeId(),\n\t * type: 'text',\n\t * props: { richText: toRichText(page.name) },\n\t * })\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterCreateHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterCreateHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._afterCreateHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called before a record is changed. The handler is given the old and\n\t * new record - you can return a modified record to apply a different update, or the old record\n\t * to block the update entirely.\n\t *\n\t * Use this handler only for intercepting updates to the record itself. If you want to update\n\t * other records in response to a change, use\n\t * {@link StoreSideEffects.registerAfterChangeHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {\n\t * if (next.isLocked && !prev.isLocked) {\n\t * // prevent shapes from ever being locked:\n\t * return prev\n\t * }\n\t * // other types of change are allowed\n\t * return next\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeChangeHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeChangeHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._beforeChangeHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is changed. This is useful for side-effects\n\t * that would update _other_ records - if you want to modify the record being changed, use\n\t * {@link StoreSideEffects.registerBeforeChangeHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {\n\t * if (next.props.color === 'red') {\n\t * // there can only be one red shape at a time:\n\t * const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id)\n\t * editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}})))\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterChangeHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterChangeHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._afterChangeHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called before a record is deleted. The handler can return `false` to\n\t * prevent the deletion.\n\t *\n\t * Use this handler only for intercepting deletions of the record itself. If you want to do\n\t * something to other records in response to a deletion, use\n\t * {@link StoreSideEffects.registerAfterDeleteHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {\n\t * if (shape.props.color === 'red') {\n\t * // prevent red shapes from being deleted\n\t * \t return false\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterBeforeDeleteHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreBeforeDeleteHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._beforeDeleteHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called after a record is deleted. This is useful for side-effects\n\t * that would update _other_ records - if you want to block the deletion of the record itself,\n\t * use {@link StoreSideEffects.registerBeforeDeleteHandler} instead.\n\t *\n\t * @example\n\t * ```ts\n\t * editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {\n\t * // if the last shape in a frame is deleted, delete the frame too:\n\t * const parentFrame = editor.getShape(shape.parentId)\n\t * if (!parentFrame || parentFrame.type !== 'frame') return\n\t *\n\t * const siblings = editor.getSortedChildIdsForParent(parentFrame)\n\t * if (siblings.length === 0) {\n\t * editor.deleteShape(parentFrame.id)\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @param typeName - The type of record to listen for\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t */\n\tregisterAfterDeleteHandler<T extends R['typeName']>(\n\t\ttypeName: T,\n\t\thandler: StoreAfterDeleteHandler<R & { typeName: T }>\n\t) {\n\t\treturn this.addHandler(this._afterDeleteHandlers, typeName, handler)\n\t}\n\n\t/**\n\t * Register a handler to be called when a store completes an atomic operation.\n\t *\n\t * @example\n\t * ```ts\n\t * let count = 0\n\t *\n\t * editor.sideEffects.registerOperationCompleteHandler(() => count++)\n\t *\n\t * editor.selectAll()\n\t * expect(count).toBe(1)\n\t *\n\t * editor.store.atomic(() => {\n\t *\teditor.selectNone()\n\t * \teditor.selectAll()\n\t * })\n\t *\n\t * expect(count).toBe(2)\n\t * ```\n\t *\n\t * @param handler - The handler to call\n\t *\n\t * @returns A callback that removes the handler.\n\t *\n\t * @public\n\t */\n\tregisterOperationCompleteHandler(handler: StoreOperationCompleteHandler) {\n\t\t// copy on write, see addHandler\n\t\tthis._operationCompleteHandlers = [...this._operationCompleteHandlers, handler]\n\t\treturn () => {\n\t\t\tthis._operationCompleteHandlers = withoutFirst(this._operationCompleteHandlers, handler)\n\t\t}\n\t}\n}\n\nfunction withoutFirst<T>(array: T[], item: T): T[] {\n\tconst index = array.indexOf(item)\n\tif (index < 0) return array\n\treturn [...array.slice(0, index), ...array.slice(index + 1)]\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/store",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (store).",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.5.0-canary.003bf5951274",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@tldraw/state": "5.
|
|
54
|
-
"@tldraw/utils": "5.
|
|
53
|
+
"@tldraw/state": "5.5.0-canary.003bf5951274",
|
|
54
|
+
"@tldraw/utils": "5.5.0-canary.003bf5951274"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"react": "^18.2.0 || ^19.2.1"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@peculiar/webcrypto": "^1.5.0",
|
|
61
61
|
"lazyrepo": "0.0.0-alpha.27",
|
|
62
62
|
"raf": "^3.4.1",
|
|
63
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^5.0.0"
|
|
64
64
|
},
|
|
65
65
|
"engines": {
|
|
66
66
|
"node": ">=22.12.0"
|
package/src/lib/Store.ts
CHANGED
|
@@ -405,13 +405,11 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
405
405
|
private historyReactor: Reactor
|
|
406
406
|
|
|
407
407
|
/**
|
|
408
|
-
*
|
|
408
|
+
* Cancels the history flush scheduled for the next frame, if any.
|
|
409
409
|
*
|
|
410
410
|
* @internal
|
|
411
411
|
*/
|
|
412
|
-
private cancelHistoryReactor()
|
|
413
|
-
/* noop */
|
|
414
|
-
}
|
|
412
|
+
private cancelHistoryReactor: null | (() => void) = null
|
|
415
413
|
|
|
416
414
|
/**
|
|
417
415
|
* The schema that defines the structure and validation rules for records in this store.
|
|
@@ -519,6 +517,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
519
517
|
// If we have accumulated history, flush it and update listeners
|
|
520
518
|
if (this.historyAccumulator.hasChanges()) {
|
|
521
519
|
const entries = this.historyAccumulator.flush()
|
|
520
|
+
const errors: unknown[] = []
|
|
522
521
|
for (const { changes, source } of entries) {
|
|
523
522
|
// Filtered diffs are computed at most once per scope per entry, and shared by every
|
|
524
523
|
// listener watching that scope.
|
|
@@ -527,23 +526,36 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
527
526
|
if (filters.source !== 'all' && filters.source !== source) {
|
|
528
527
|
continue
|
|
529
528
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
529
|
+
let listenerChanges = changes
|
|
530
|
+
if (filters.scope !== 'all') {
|
|
531
|
+
if (!scopedChanges.has(filters.scope)) {
|
|
532
|
+
scopedChanges.set(filters.scope, this.filterChangesByScope(changes, filters.scope))
|
|
533
|
+
}
|
|
534
|
+
const filtered = scopedChanges.get(filters.scope)
|
|
535
|
+
if (!filtered) continue
|
|
536
|
+
listenerChanges = filtered
|
|
533
537
|
}
|
|
534
|
-
|
|
535
|
-
|
|
538
|
+
// The entries are already dequeued, so a listener that throws must not stop the others
|
|
539
|
+
// (e.g. a sync client) from receiving them: deliver to all, then rethrow the first error.
|
|
540
|
+
try {
|
|
541
|
+
onHistory({ changes: listenerChanges, source })
|
|
542
|
+
} catch (error) {
|
|
543
|
+
errors.push(error)
|
|
536
544
|
}
|
|
537
|
-
const filtered = scopedChanges.get(filters.scope)
|
|
538
|
-
if (!filtered) continue
|
|
539
|
-
onHistory({ changes: filtered, source })
|
|
540
545
|
}
|
|
541
546
|
}
|
|
547
|
+
if (errors.length > 0) throw errors[0]
|
|
542
548
|
}
|
|
543
549
|
}
|
|
544
550
|
|
|
545
551
|
dispose() {
|
|
546
|
-
|
|
552
|
+
// Deliver what is still pending first: a change-set made in the same frame as the dispose
|
|
553
|
+
// would otherwise never reach the listeners (e.g. a sync client) still attached to the store.
|
|
554
|
+
try {
|
|
555
|
+
this._flushHistory()
|
|
556
|
+
} finally {
|
|
557
|
+
this.cancelHistoryReactor?.()
|
|
558
|
+
}
|
|
547
559
|
}
|
|
548
560
|
|
|
549
561
|
/**
|
|
@@ -482,3 +482,35 @@ describe('applying diffs with ignoreEphemeralKeys (H)', () => {
|
|
|
482
482
|
expect(result.lastActive).toBe(100)
|
|
483
483
|
})
|
|
484
484
|
})
|
|
485
|
+
|
|
486
|
+
describe('listeners: a throwing listener (H)', () => {
|
|
487
|
+
it('[H13] a listener that throws does not stop the others from receiving the flush', () => {
|
|
488
|
+
const received = vi.fn()
|
|
489
|
+
store.listen(() => {
|
|
490
|
+
throw new Error('listener failed')
|
|
491
|
+
})
|
|
492
|
+
store.listen(received)
|
|
493
|
+
|
|
494
|
+
expect(() => store.put([tolkein()])).toThrow('listener failed')
|
|
495
|
+
expect(received).toHaveBeenCalledTimes(1)
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
describe('listeners: dispose (H)', () => {
|
|
500
|
+
it('[H12] dispose delivers pending change-sets to listeners before cancelling the flush', () => {
|
|
501
|
+
try {
|
|
502
|
+
// @ts-expect-error - test-only escape hatch
|
|
503
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
504
|
+
const listener = vi.fn()
|
|
505
|
+
store.listen(listener)
|
|
506
|
+
store.put([tolkein()])
|
|
507
|
+
expect(listener).not.toHaveBeenCalled()
|
|
508
|
+
|
|
509
|
+
store.dispose()
|
|
510
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
511
|
+
} finally {
|
|
512
|
+
// @ts-expect-error - test-only escape hatch
|
|
513
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = false
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
})
|
|
@@ -814,4 +814,79 @@ describe('queries across rolled-back transactions (QH, QQ)', () => {
|
|
|
814
814
|
ids.get()
|
|
815
815
|
for (const call of args) expect(call).toEqual([])
|
|
816
816
|
})
|
|
817
|
+
|
|
818
|
+
// `read` stands in for a side effect that queries the store mid-operation.
|
|
819
|
+
function readDuringRolledBackPut(read: () => void) {
|
|
820
|
+
const phantom = Book.create({ title: 'Phantom', authorId: authors.tolkein.id })
|
|
821
|
+
expect(() =>
|
|
822
|
+
store.atomic(() => {
|
|
823
|
+
store.put([phantom])
|
|
824
|
+
read()
|
|
825
|
+
throw new Error('abort')
|
|
826
|
+
})
|
|
827
|
+
).toThrow('abort')
|
|
828
|
+
expect(store.get(phantom.id)).toBeUndefined()
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const initialBookIds = Object.values(books).map((book) => book.id)
|
|
832
|
+
|
|
833
|
+
it('[QH4] the filtered history value strictly increases across a rolled-back transaction', () => {
|
|
834
|
+
const bookHistory = store.query.filterHistory('book')
|
|
835
|
+
const values = [bookHistory.get()]
|
|
836
|
+
|
|
837
|
+
readDuringRolledBackPut(() => values.push(bookHistory.get()))
|
|
838
|
+
store.put([Book.create({ title: 'First', authorId: authors.bradbury.id })])
|
|
839
|
+
values.push(bookHistory.get())
|
|
840
|
+
store.put([Book.create({ title: 'Second', authorId: authors.bradbury.id })])
|
|
841
|
+
values.push(bookHistory.get())
|
|
842
|
+
|
|
843
|
+
expect(values.slice(1).map((value, i) => value > values[i])).toEqual([true, true, true])
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('[QH4] indexes and queries read during a rolled-back transaction are rebuilt afterwards', () => {
|
|
847
|
+
const bookIds = store.query.ids('book')
|
|
848
|
+
const booksByAuthor = store.query.index('book', 'authorId')
|
|
849
|
+
let counterSeenByQueries = -1
|
|
850
|
+
|
|
851
|
+
readDuringRolledBackPut(() => {
|
|
852
|
+
expect(bookIds.get().size).toBe(5)
|
|
853
|
+
booksByAuthor.get()
|
|
854
|
+
counterSeenByQueries = store.history.get()
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const real = Book.create({ title: 'Real', authorId: authors.bradbury.id })
|
|
858
|
+
store.put([real])
|
|
859
|
+
// a stale cache only goes unnoticed when the counter lands on the value the queries last saw
|
|
860
|
+
expect(store.history.get()).toBe(counterSeenByQueries)
|
|
861
|
+
|
|
862
|
+
expect(bookIds.get()).toEqual(new Set([...initialBookIds, real.id]))
|
|
863
|
+
expect(booksByAuthor.get().get(authors.tolkein.id)).toEqual(new Set([books.lotr.id]))
|
|
864
|
+
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
|
|
865
|
+
new Set([books.farenheit.id, real.id])
|
|
866
|
+
)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('[QH4] indexes and queries keep picking up changes after being rebuilt', () => {
|
|
870
|
+
const bookIds = store.query.ids('book')
|
|
871
|
+
const booksByAuthor = store.query.index('book', 'authorId')
|
|
872
|
+
|
|
873
|
+
readDuringRolledBackPut(() => {
|
|
874
|
+
bookIds.get()
|
|
875
|
+
booksByAuthor.get()
|
|
876
|
+
})
|
|
877
|
+
const first = Book.create({ title: 'First', authorId: authors.bradbury.id })
|
|
878
|
+
store.put([first])
|
|
879
|
+
bookIds.get()
|
|
880
|
+
booksByAuthor.get()
|
|
881
|
+
|
|
882
|
+
// the rebuild left the filtered history ahead of the store's counter, so this change lands
|
|
883
|
+
// on the value it last reported
|
|
884
|
+
const second = Book.create({ title: 'Second', authorId: authors.bradbury.id })
|
|
885
|
+
store.put([second])
|
|
886
|
+
|
|
887
|
+
expect(bookIds.get()).toEqual(new Set([...initialBookIds, first.id, second.id]))
|
|
888
|
+
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
|
|
889
|
+
new Set([books.farenheit.id, first.id, second.id])
|
|
890
|
+
)
|
|
891
|
+
})
|
|
817
892
|
})
|
package/src/lib/StoreQueries.ts
CHANGED
|
@@ -161,17 +161,19 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
161
161
|
* have been added, updated, or removed.
|
|
162
162
|
*
|
|
163
163
|
* @param typeName - The type name to filter the history by
|
|
164
|
-
* @returns A computed value containing
|
|
164
|
+
* @returns A computed value containing a change counter and diffs of changes for the specified type
|
|
165
165
|
*
|
|
166
166
|
* @example
|
|
167
167
|
* ```ts
|
|
168
168
|
* // Track changes to book records only
|
|
169
169
|
* const bookHistory = store.query.filterHistory('book')
|
|
170
170
|
*
|
|
171
|
-
* //
|
|
172
|
-
* react('book-changes', () => {
|
|
173
|
-
* const
|
|
174
|
-
*
|
|
171
|
+
* // Log the books added since the last run
|
|
172
|
+
* react('book-changes', (lastReactedEpoch) => {
|
|
173
|
+
* const diffs = bookHistory.getDiffSince(lastReactedEpoch)
|
|
174
|
+
* // no diffs to give, e.g. on the first run: rebuild anything derived from scratch
|
|
175
|
+
* if (diffs === RESET_VALUE) return
|
|
176
|
+
* for (const diff of diffs) console.log('Added:', Object.keys(diff.added))
|
|
175
177
|
* })
|
|
176
178
|
* ```
|
|
177
179
|
*
|
|
@@ -193,14 +195,20 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
193
195
|
return this.history.get()
|
|
194
196
|
}
|
|
195
197
|
|
|
198
|
+
// The value tracks the store's history counter, but it must strictly increase on every
|
|
199
|
+
// reset or relevant change: after a rolled-back transaction the counter can come back
|
|
200
|
+
// round to `lastValue`, and an unchanged value would hide the reset from downstream
|
|
201
|
+
// indexes and queries, leaving them stale for good.
|
|
202
|
+
const next = Math.max(this.history.get(), lastValue + 1)
|
|
203
|
+
|
|
196
204
|
const diff = this.history.getDiffSince(lastComputedEpoch)
|
|
197
|
-
if (diff === RESET_VALUE) return
|
|
205
|
+
if (diff === RESET_VALUE) return next
|
|
198
206
|
|
|
199
207
|
const res = { added: {}, removed: {}, updated: {} } as RecordsDiff<S>
|
|
200
208
|
const size = squashRecordDiffsMutableByType(res, diff, typeName)
|
|
201
209
|
|
|
202
210
|
if (size) {
|
|
203
|
-
return withDiff(
|
|
211
|
+
return withDiff(next, res)
|
|
204
212
|
} else {
|
|
205
213
|
return lastValue
|
|
206
214
|
}
|
|
@@ -657,3 +657,69 @@ describe('remote attribution (AO)', () => {
|
|
|
657
657
|
])
|
|
658
658
|
})
|
|
659
659
|
})
|
|
660
|
+
|
|
661
|
+
describe('handler removal during dispatch (SE)', () => {
|
|
662
|
+
it('[SE1] a handler that removes itself while running does not stop later handlers from running', () => {
|
|
663
|
+
const calls: string[] = []
|
|
664
|
+
const removeA = store.sideEffects.registerAfterCreateHandler('book', () => {
|
|
665
|
+
calls.push('A')
|
|
666
|
+
removeA()
|
|
667
|
+
})
|
|
668
|
+
store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
|
|
669
|
+
|
|
670
|
+
store.put([book1])
|
|
671
|
+
expect(calls).toEqual(['A', 'B'])
|
|
672
|
+
|
|
673
|
+
store.put([book2])
|
|
674
|
+
expect(calls).toEqual(['A', 'B', 'B'])
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('[SE1] an operationComplete handler that removes itself does not skip the next one', () => {
|
|
678
|
+
const calls: string[] = []
|
|
679
|
+
const removeA = store.sideEffects.registerOperationCompleteHandler(() => {
|
|
680
|
+
calls.push('A')
|
|
681
|
+
removeA()
|
|
682
|
+
})
|
|
683
|
+
store.sideEffects.registerOperationCompleteHandler(() => calls.push('B'))
|
|
684
|
+
|
|
685
|
+
store.put([book1])
|
|
686
|
+
expect(calls).toEqual(['A', 'B'])
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
// The two tests below pin the other half of SE1: a list edit made mid-dispatch is not seen by the
|
|
690
|
+
// dispatch it happens in. Removal is deliberately not a way to cancel a handler for the event in
|
|
691
|
+
// flight, so don't "fix" these to match the pre-copy-on-write behaviour without changing SE1 too.
|
|
692
|
+
|
|
693
|
+
it('[SE1] removing a later handler mid-dispatch only takes effect from the next event', () => {
|
|
694
|
+
const calls: string[] = []
|
|
695
|
+
let removeB = () => {}
|
|
696
|
+
store.sideEffects.registerAfterCreateHandler('book', () => {
|
|
697
|
+
calls.push('A')
|
|
698
|
+
removeB()
|
|
699
|
+
})
|
|
700
|
+
removeB = store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
|
|
701
|
+
|
|
702
|
+
store.put([book1])
|
|
703
|
+
expect(calls).toEqual(['A', 'B'])
|
|
704
|
+
|
|
705
|
+
store.put([book2])
|
|
706
|
+
expect(calls).toEqual(['A', 'B', 'A'])
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('[SE1] registering a handler mid-dispatch only takes effect from the next event', () => {
|
|
710
|
+
const calls: string[] = []
|
|
711
|
+
let hasRegistered = false
|
|
712
|
+
store.sideEffects.registerAfterCreateHandler('book', () => {
|
|
713
|
+
calls.push('A')
|
|
714
|
+
if (hasRegistered) return
|
|
715
|
+
hasRegistered = true
|
|
716
|
+
store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
store.put([book1])
|
|
720
|
+
expect(calls).toEqual(['A'])
|
|
721
|
+
|
|
722
|
+
store.put([book2])
|
|
723
|
+
expect(calls).toEqual(['A', 'A', 'B'])
|
|
724
|
+
})
|
|
725
|
+
})
|
|
@@ -217,6 +217,16 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
217
217
|
private _operationCompleteHandlers: StoreOperationCompleteHandler[] = []
|
|
218
218
|
|
|
219
219
|
private _isEnabled = true
|
|
220
|
+
|
|
221
|
+
// Handler arrays are replaced, never mutated in place: dispatch iterates the array it looked up,
|
|
222
|
+
// so a handler that removes itself (or registers another) while running must not shift the
|
|
223
|
+
// entries out from under that iteration.
|
|
224
|
+
private addHandler<H>(handlers: { [K in string]?: H[] }, typeName: string, handler: H) {
|
|
225
|
+
handlers[typeName] = [...(handlers[typeName] ?? []), handler]
|
|
226
|
+
return () => {
|
|
227
|
+
handlers[typeName] = withoutFirst(handlers[typeName] ?? [], handler)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
220
230
|
/**
|
|
221
231
|
* Checks whether side effects are currently enabled.
|
|
222
232
|
* When disabled, all side effect handlers are bypassed.
|
|
@@ -478,10 +488,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
478
488
|
typeName: T,
|
|
479
489
|
handler: StoreBeforeCreateHandler<R & { typeName: T }>
|
|
480
490
|
) {
|
|
481
|
-
|
|
482
|
-
if (!handlers) this._beforeCreateHandlers[typeName] = []
|
|
483
|
-
this._beforeCreateHandlers[typeName]!.push(handler)
|
|
484
|
-
return () => remove(this._beforeCreateHandlers[typeName]!, handler)
|
|
491
|
+
return this.addHandler(this._beforeCreateHandlers, typeName, handler)
|
|
485
492
|
}
|
|
486
493
|
|
|
487
494
|
/**
|
|
@@ -510,10 +517,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
510
517
|
typeName: T,
|
|
511
518
|
handler: StoreAfterCreateHandler<R & { typeName: T }>
|
|
512
519
|
) {
|
|
513
|
-
|
|
514
|
-
if (!handlers) this._afterCreateHandlers[typeName] = []
|
|
515
|
-
this._afterCreateHandlers[typeName]!.push(handler)
|
|
516
|
-
return () => remove(this._afterCreateHandlers[typeName]!, handler)
|
|
520
|
+
return this.addHandler(this._afterCreateHandlers, typeName, handler)
|
|
517
521
|
}
|
|
518
522
|
|
|
519
523
|
/**
|
|
@@ -546,10 +550,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
546
550
|
typeName: T,
|
|
547
551
|
handler: StoreBeforeChangeHandler<R & { typeName: T }>
|
|
548
552
|
) {
|
|
549
|
-
|
|
550
|
-
if (!handlers) this._beforeChangeHandlers[typeName] = []
|
|
551
|
-
this._beforeChangeHandlers[typeName]!.push(handler)
|
|
552
|
-
return () => remove(this._beforeChangeHandlers[typeName]!, handler)
|
|
553
|
+
return this.addHandler(this._beforeChangeHandlers, typeName, handler)
|
|
553
554
|
}
|
|
554
555
|
|
|
555
556
|
/**
|
|
@@ -577,10 +578,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
577
578
|
typeName: T,
|
|
578
579
|
handler: StoreAfterChangeHandler<R & { typeName: T }>
|
|
579
580
|
) {
|
|
580
|
-
|
|
581
|
-
if (!handlers) this._afterChangeHandlers[typeName] = []
|
|
582
|
-
this._afterChangeHandlers[typeName]!.push(handler as StoreAfterChangeHandler<any>)
|
|
583
|
-
return () => remove(this._afterChangeHandlers[typeName]!, handler)
|
|
581
|
+
return this.addHandler(this._afterChangeHandlers, typeName, handler)
|
|
584
582
|
}
|
|
585
583
|
|
|
586
584
|
/**
|
|
@@ -610,10 +608,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
610
608
|
typeName: T,
|
|
611
609
|
handler: StoreBeforeDeleteHandler<R & { typeName: T }>
|
|
612
610
|
) {
|
|
613
|
-
|
|
614
|
-
if (!handlers) this._beforeDeleteHandlers[typeName] = []
|
|
615
|
-
this._beforeDeleteHandlers[typeName]!.push(handler as StoreBeforeDeleteHandler<any>)
|
|
616
|
-
return () => remove(this._beforeDeleteHandlers[typeName]!, handler)
|
|
611
|
+
return this.addHandler(this._beforeDeleteHandlers, typeName, handler)
|
|
617
612
|
}
|
|
618
613
|
|
|
619
614
|
/**
|
|
@@ -644,10 +639,7 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
644
639
|
typeName: T,
|
|
645
640
|
handler: StoreAfterDeleteHandler<R & { typeName: T }>
|
|
646
641
|
) {
|
|
647
|
-
|
|
648
|
-
if (!handlers) this._afterDeleteHandlers[typeName] = []
|
|
649
|
-
this._afterDeleteHandlers[typeName]!.push(handler as StoreAfterDeleteHandler<any>)
|
|
650
|
-
return () => remove(this._afterDeleteHandlers[typeName]!, handler)
|
|
642
|
+
return this.addHandler(this._afterDeleteHandlers, typeName, handler)
|
|
651
643
|
}
|
|
652
644
|
|
|
653
645
|
/**
|
|
@@ -677,14 +669,16 @@ export class StoreSideEffects<R extends UnknownRecord> {
|
|
|
677
669
|
* @public
|
|
678
670
|
*/
|
|
679
671
|
registerOperationCompleteHandler(handler: StoreOperationCompleteHandler) {
|
|
680
|
-
|
|
681
|
-
|
|
672
|
+
// copy on write, see addHandler
|
|
673
|
+
this._operationCompleteHandlers = [...this._operationCompleteHandlers, handler]
|
|
674
|
+
return () => {
|
|
675
|
+
this._operationCompleteHandlers = withoutFirst(this._operationCompleteHandlers, handler)
|
|
676
|
+
}
|
|
682
677
|
}
|
|
683
678
|
}
|
|
684
679
|
|
|
685
|
-
function
|
|
680
|
+
function withoutFirst<T>(array: T[], item: T): T[] {
|
|
686
681
|
const index = array.indexOf(item)
|
|
687
|
-
if (index
|
|
688
|
-
|
|
689
|
-
}
|
|
682
|
+
if (index < 0) return array
|
|
683
|
+
return [...array.slice(0, index), ...array.slice(index + 1)]
|
|
690
684
|
}
|