Automatic undo/redo (#3364)

Our undo-redo system before this diff is based on commands. A command
is:
- A function that produces some data required to perform and undo a
change
- A function that actually performs the change, based on the data
- Another function that undoes the change, based on the data
- Optionally, a function to _redo_ the change, although in practice we
never use this

Each command that gets run is added to the undo/redo stack unless it
says it shouldn't be.

This diff replaces this system of commands with a new one where all
changes to the store are automatically recorded in the undo/redo stack.
You can imagine the new history manager like a tape recorder - it
automatically records everything that happens to the store in a special
diff, unless you "pause" the recording and ask it not to. Undo and redo
rewind/fast-forward the tape to certain marks.

As the command concept is gone, the things that were commands are now
just functions that manipulate the store.

One other change here is that the store's after-phase callbacks (and the
after-phase side-effects as a result) are now batched up and called at
the end of certain key operations. For example, `applyDiff` would
previously call all the `afterCreate` callbacks before making any
removals from the diff. Now, it (and anything else that uses
`store.atomic(fn)` will defer firing any after callbacks until the end
of an operation. before callbacks are still called part-way through
operations.

## Design options
Automatic recording is a fairly large big semantic change, particularly
to the standalone `store.put`/`store.remove` etc. commands. We could
instead make not-recording the default, and make recording opt-in
instead. However, I think auto-record-by-default is the right choice for
a few reasons:

1. Switching to a recording-based vs command-based undo-redo model is
fundamentally a big semantic change. In the past, `store.put` etc. were
always ignored. Now, regardless of whether we choose record-by-default
or ignore-by-default, the behaviour of `store.put` is _context_
dependant.
2. Switching to ignore-by-default means that either our commands don't
record undo/redo history any more (unless wrapped in
`editor.history.record`, a far larger semantic change) or they have to
always-record/all accept a history options bag. If we choose
always-record, we can't use commands within `history.ignore` as they'll
start recording again. If we choose the history options bag, we have to
accept those options in 10s of methods - basically the entire `Editor`
api surface.

Overall, given that some breaking semantic change here is unavoidable, I
think that record-by-default hits the right balance of tradeoffs. I
think it's a better API going forward, whilst also not being too
disruptive as the APIs it affects are very "deep" ones that we don't
typically encourage people to use.



### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features
- [x] `galaxy brain` — Architectural changes

### Release Note
#### Breaking changes
##### 1. History Options
Previously, some (not all!) commands accepted a history options object
with `squashing`, `ephemeral`, and `preserveRedoStack` flags. Squashing
enabled/disabled a memory optimisation (storing individual commands vs
squashing them together). Ephemeral stopped a command from affecting the
undo/redo stack at all. Preserve redo stack stopped commands from wiping
the redo stack. These flags were never available consistently - some
commands had them and others didn't.

In this version, most of these flags have been removed. `squashing` is
gone entirely (everything squashes & does so much faster than before).
There were a couple of commands that had a special default - for
example, `updateInstanceState` used to default to being `ephemeral`.
Those maintain the defaults, but the options look a little different now
- `{ephemeral: true}` is now `{history: 'ignore'}` and
`{preserveRedoStack: true}` is now `{history:
'record-preserveRedoStack'}`.

If you were previously using these options in places where they've now
been removed, you can use wrap them with `editor.history.ignore(fn)` or
`editor.history.batch(fn, {history: 'record-preserveRedoStack'})`. For
example,
```ts
editor.nudgeShapes(..., { ephemeral: true })
```
can now be written as
```ts
editor.history.ignore(() => {
    editor.nudgeShapes(...)
})
```

##### 2. Automatic recording
Previously, only commands (e.g. `editor.updateShapes` and things that
use it) were added to the undo/redo stack. Everything else (e.g.
`editor.store.put`) wasn't. Now, _everything_ that touches the store is
recorded in the undo/redo stack (unless it's part of
`mergeRemoteChanges`). You can use `editor.history.ignore(fn)` as above
if you want to make other changes to the store that aren't recorded -
this is short for `editor.history.batch(fn, {history: 'ignore'})`

When upgrading to this version of tldraw, you shouldn't need to change
anything unless you're using `store.put`, `store.remove`, or
`store.applyDiff` outside of `store.mergeRemoteChanges`. If you are, you
can preserve the functionality of those not being recorded by wrapping
them either in `mergeRemoteChanges` (if they're multiplayer-related) or
`history.ignore` as appropriate.

##### 3. Side effects
Before this diff, any changes in side-effects weren't captured by the
undo-redo stack. This was actually the motivation for this change in the
first place! But it's a pretty big change, and if you're using side
effects we recommend you double-check how they interact with undo/redo
before/after this change. To get the old behaviour back, wrap your side
effects in `editor.history.ignore`.

##### 4. Mark options
Previously, `editor.mark(id)` accepted two additional boolean
parameters: `onUndo` and `onRedo`. If these were set to false, then when
undoing or redoing we'd skip over that mark and keep going until we
found one with those values set to true. We've removed those options -
if you're using them, let us know and we'll figure out an alternative!
This commit is contained in:
alex 2024-04-24 19:26:10 +01:00 committed by GitHub
parent c9b7d328fe
commit 8151e6f586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2106 additions and 1907 deletions

View file

@ -77,9 +77,7 @@ const ContextToolbarComponent = track(() => {
width: 32, width: 32,
background: isActive ? 'var(--color-muted-2)' : 'transparent', background: isActive ? 'var(--color-muted-2)' : 'transparent',
}} }}
onClick={() => onClick={() => editor.setStyleForSelectedShapes(DefaultSizeStyle, value)}
editor.setStyleForSelectedShapes(DefaultSizeStyle, value, { squashing: false })
}
> >
<TldrawUiIcon icon={icon} /> <TldrawUiIcon icon={icon} />
</div> </div>

View file

@ -25,7 +25,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
<TldrawUiButton <TldrawUiButton
type="menu" type="menu"
onClick={() => { onClick={() => {
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true }) editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
}} }}
> >
<TldrawUiButtonLabel>Red</TldrawUiButtonLabel> <TldrawUiButtonLabel>Red</TldrawUiButtonLabel>
@ -35,7 +35,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
<TldrawUiButton <TldrawUiButton
type="menu" type="menu"
onClick={() => { onClick={() => {
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true }) editor.setStyleForSelectedShapes(DefaultColorStyle, 'green')
}} }}
> >
<TldrawUiButtonLabel>Green</TldrawUiButtonLabel> <TldrawUiButtonLabel>Green</TldrawUiButtonLabel>

View file

@ -29,7 +29,9 @@ export default function UserPresenceExample() {
chatMessage: CURSOR_CHAT_MESSAGE, chatMessage: CURSOR_CHAT_MESSAGE,
}) })
editor.store.put([peerPresence]) editor.store.mergeRemoteChanges(() => {
editor.store.put([peerPresence])
})
// [b] // [b]
const raf = rRaf.current const raf = rRaf.current
@ -67,23 +69,29 @@ export default function UserPresenceExample() {
) )
} }
editor.store.put([ editor.store.mergeRemoteChanges(() => {
{ editor.store.put([
...peerPresence, {
cursor, ...peerPresence,
chatMessage, cursor,
lastActivityTimestamp: now, chatMessage,
}, lastActivityTimestamp: now,
]) },
])
})
rRaf.current = requestAnimationFrame(loop) rRaf.current = requestAnimationFrame(loop)
} }
rRaf.current = requestAnimationFrame(loop) rRaf.current = requestAnimationFrame(loop)
} else { } else {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }]) editor.store.mergeRemoteChanges(() => {
rRaf.current = setInterval(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }]) editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})
rRaf.current = setInterval(() => {
editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})
}, 1000) }, 1000)
} }
}} }}

View file

@ -57,11 +57,11 @@ export const ChangeResponder = () => {
type: 'vscode:editor-loaded', type: 'vscode:editor-loaded',
}) })
editor.on('change-history', handleChange) const dispose = editor.store.listen(handleChange, { scope: 'document' })
return () => { return () => {
handleChange() handleChange()
editor.off('change-history', handleChange) dispose()
} }
}, [editor]) }, [editor])

View file

@ -29,10 +29,12 @@ import { default as React_2 } from 'react';
import * as React_3 from 'react'; import * as React_3 from 'react';
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { RecordsDiff } from '@tldraw/store';
import { SerializedSchema } from '@tldraw/store'; import { SerializedSchema } from '@tldraw/store';
import { SerializedStore } from '@tldraw/store'; import { SerializedStore } from '@tldraw/store';
import { ShapeProps } from '@tldraw/tlschema'; import { ShapeProps } from '@tldraw/tlschema';
import { Signal } from '@tldraw/state'; import { Signal } from '@tldraw/state';
import { Store } from '@tldraw/store';
import { StoreSchema } from '@tldraw/store'; import { StoreSchema } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store'; import { StoreSnapshot } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema'; import { StyleProp } from '@tldraw/tlschema';
@ -375,7 +377,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>; export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
// @public // @public
export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore; export function createTLStore({ initialData, defaultName, id, ...rest }: TLStoreOptions): TLStore;
// @public (undocumented) // @public (undocumented)
export function createTLUser(opts?: { export function createTLUser(opts?: {
@ -602,7 +604,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}): this; }): this;
bail(): this; bail(): this;
bailToMark(id: string): this; bailToMark(id: string): this;
batch(fn: () => void): this; batch(fn: () => void, opts?: TLHistoryBatchOptions): this;
bringForward(shapes: TLShape[] | TLShapeId[]): this; bringForward(shapes: TLShape[] | TLShapeId[]): this;
bringToFront(shapes: TLShape[] | TLShapeId[]): this; bringToFront(shapes: TLShape[] | TLShapeId[]): this;
cancel(): this; cancel(): this;
@ -810,7 +812,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getZoomLevel(): number; getZoomLevel(): number;
groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this; groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this;
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean; hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
readonly history: HistoryManager<this>; readonly history: HistoryManager<TLRecord>;
inputs: { inputs: {
buttons: Set<number>; buttons: Set<number>;
keys: Set<string>; keys: Set<string>;
@ -832,6 +834,7 @@ export class Editor extends EventEmitter<TLEventMap> {
isPointing: boolean; isPointing: boolean;
}; };
interrupt(): this; interrupt(): this;
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isIn(path: string): boolean; isIn(path: string): boolean;
isInAny(...paths: string[]): boolean; isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: { isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
@ -845,9 +848,9 @@ export class Editor extends EventEmitter<TLEventMap> {
isShapeOrAncestorLocked(shape?: TLShape): boolean; isShapeOrAncestorLocked(shape?: TLShape): boolean;
// (undocumented) // (undocumented)
isShapeOrAncestorLocked(id?: TLShapeId): boolean; isShapeOrAncestorLocked(id?: TLShapeId): boolean;
mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this; mark(markId?: string): this;
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this; moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this;
nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike, historyOptions?: TLCommandHistoryOptions): this; nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike): this;
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this; packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
pageToScreen(point: VecLike): { pageToScreen(point: VecLike): {
x: number; x: number;
@ -876,7 +879,7 @@ export class Editor extends EventEmitter<TLEventMap> {
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & { registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
type: T; type: T;
} : TLExternalContent) => void) | null): this; } : TLExternalContent) => void) | null): this;
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this; renamePage(page: TLPage | TLPageId, name: string): this;
renderingBoundsMargin: number; renderingBoundsMargin: number;
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this; reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
resetZoom(point?: Vec, animation?: TLAnimationOptions): this; resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
@ -896,7 +899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
sendToBack(shapes: TLShape[] | TLShapeId[]): this; sendToBack(shapes: TLShape[] | TLShapeId[]): this;
setCamera(point: VecLike, animation?: TLAnimationOptions): this; setCamera(point: VecLike, animation?: TLAnimationOptions): this;
setCroppingShape(shape: null | TLShape | TLShapeId): this; setCroppingShape(shape: null | TLShape | TLShapeId): this;
setCurrentPage(page: TLPage | TLPageId, historyOptions?: TLCommandHistoryOptions): this; setCurrentPage(page: TLPage | TLPageId): this;
setCurrentTool(id: string, info?: {}): this; setCurrentTool(id: string, info?: {}): this;
setCursor: (cursor: Partial<TLCursor>) => this; setCursor: (cursor: Partial<TLCursor>) => this;
setEditingShape(shape: null | TLShape | TLShapeId): this; setEditingShape(shape: null | TLShape | TLShapeId): this;
@ -904,11 +907,11 @@ export class Editor extends EventEmitter<TLEventMap> {
setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this; setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this;
setHintingShapes(shapes: TLShape[] | TLShapeId[]): this; setHintingShapes(shapes: TLShape[] | TLShapeId[]): this;
setHoveredShape(shape: null | TLShape | TLShapeId): this; setHoveredShape(shape: null | TLShape | TLShapeId): this;
setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this; setOpacityForNextShapes(opacity: number, historyOptions?: TLHistoryBatchOptions): this;
setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this; setOpacityForSelectedShapes(opacity: number): this;
setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this; setSelectedShapes(shapes: TLShape[] | TLShapeId[]): this;
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this; setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLHistoryBatchOptions): this;
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>, historyOptions?: TLCommandHistoryOptions): this; setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>): this;
shapeUtils: { shapeUtils: {
readonly [K in string]?: ShapeUtil<TLUnknownShape>; readonly [K in string]?: ShapeUtil<TLUnknownShape>;
}; };
@ -937,14 +940,16 @@ export class Editor extends EventEmitter<TLEventMap> {
// (undocumented) // (undocumented)
ungroupShapes(ids: TLShape[]): this; ungroupShapes(ids: TLShape[]): this;
updateAssets(assets: TLAssetPartial[]): this; updateAssets(assets: TLAssetPartial[]): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLCommandHistoryOptions): this; updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions): this;
// (undocumented)
_updateCurrentPageState: (partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions) => void;
updateDocumentSettings(settings: Partial<TLDocument>): this; updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLCommandHistoryOptions): this; updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, historyOptions?: TLCommandHistoryOptions): this; updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
// @internal // @internal
updateRenderingBounds(): this; updateRenderingBounds(): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, historyOptions?: TLCommandHistoryOptions): this; updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], historyOptions?: TLCommandHistoryOptions): this; updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[]): this;
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this; updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
readonly user: UserPreferencesManager; readonly user: UserPreferencesManager;
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this; visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
@ -1208,6 +1213,55 @@ export function hardResetEditor(): void;
// @internal (undocumented) // @internal (undocumented)
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>; export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
// @public (undocumented)
export class HistoryManager<R extends UnknownRecord> {
constructor(opts: {
annotateError?: (error: unknown) => void;
store: Store<R>;
});
// (undocumented)
bail: () => this;
// (undocumented)
bailToMark: (id: string) => this;
// (undocumented)
batch: (fn: () => void, opts?: TLHistoryBatchOptions) => this;
// (undocumented)
clear(): void;
// @internal (undocumented)
debug(): {
pendingDiff: {
diff: RecordsDiff<R>;
isEmpty: boolean;
};
redos: (NonNullable<TLHistoryEntry<R>> | undefined)[];
state: HistoryRecorderState;
undos: (NonNullable<TLHistoryEntry<R>> | undefined)[];
};
// (undocumented)
readonly dispose: () => void;
// (undocumented)
getNumRedos(): number;
// (undocumented)
getNumUndos(): number;
// (undocumented)
ignore(fn: () => void): this;
// @internal (undocumented)
_isInBatch: boolean;
// (undocumented)
mark: (id?: string) => string;
// (undocumented)
onBatchComplete: () => void;
// (undocumented)
redo: () => this | undefined;
// @internal (undocumented)
stacks: Atom< {
redos: Stack<TLHistoryEntry<R>>;
undos: Stack<TLHistoryEntry<R>>;
}, unknown>;
// (undocumented)
undo: () => this;
}
// @public (undocumented) // @public (undocumented)
export const HIT_TEST_MARGIN = 8; export const HIT_TEST_MARGIN = 8;
@ -1723,6 +1777,17 @@ export class SideEffectManager<CTX extends {
constructor(editor: CTX); constructor(editor: CTX);
// (undocumented) // (undocumented)
editor: CTX; editor: CTX;
// @internal
register(handlersByType: {
[R in TLRecord as R['typeName']]?: {
afterChange?: TLAfterChangeHandler<R>;
afterCreate?: TLAfterCreateHandler<R>;
afterDelete?: TLAfterDeleteHandler<R>;
beforeChange?: TLBeforeChangeHandler<R>;
beforeCreate?: TLBeforeCreateHandler<R>;
beforeDelete?: TLBeforeDeleteHandler<R>;
};
}): () => void;
registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & { registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & {
typeName: T; typeName: T;
}>): () => void; }>): () => void;
@ -2037,29 +2102,6 @@ export type TLCollaboratorHintProps = {
zoom: number; zoom: number;
}; };
// @public (undocumented)
export type TLCommand<Name extends string = any, Data = any> = {
preservesRedoStack?: boolean;
data: Data;
name: Name;
type: 'command';
};
// @public (undocumented)
export type TLCommandHandler<Data> = {
squash?: (prevData: Data, nextData: Data) => Data;
do: (data: Data) => void;
redo?: (data: Data) => void;
undo: (data: Data) => void;
};
// @public (undocumented)
export type TLCommandHistoryOptions = Partial<{
preservesRedoStack: boolean;
squashing: boolean;
ephemeral: boolean;
}>;
// @public (undocumented) // @public (undocumented)
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void; export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
@ -2193,17 +2235,6 @@ export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEvent
// @public (undocumented) // @public (undocumented)
export interface TLEventMap { export interface TLEventMap {
// (undocumented)
'change-history': [{
markId?: string;
reason: 'bail';
} | {
reason: 'push' | 'redo' | 'undo';
}];
// (undocumented)
'mark-history': [{
id: string;
}];
// (undocumented) // (undocumented)
'max-shapes': [{ 'max-shapes': [{
count: number; count: number;
@ -2316,17 +2347,6 @@ export type TLHandlesProps = {
children: ReactNode; children: ReactNode;
}; };
// @public (undocumented)
export type TLHistoryEntry = TLCommand | TLHistoryMark;
// @public (undocumented)
export type TLHistoryMark = {
id: string;
onRedo: boolean;
onUndo: boolean;
type: 'STOP';
};
// @public (undocumented) // @public (undocumented)
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void; export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
@ -2610,6 +2630,7 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>;
// @public (undocumented) // @public (undocumented)
export type TLStoreOptions = { export type TLStoreOptions = {
defaultName?: string; defaultName?: string;
id?: string;
initialData?: SerializedStore<TLRecord>; initialData?: SerializedStore<TLRecord>;
} & ({ } & ({
migrations?: readonly MigrationSequence[]; migrations?: readonly MigrationSequence[];

View file

@ -17,7 +17,6 @@ export {
type Atom, type Atom,
type Signal, type Signal,
} from '@tldraw/state' } from '@tldraw/state'
export type { TLCommandHistoryOptions } from './lib/editor/types/history-types'
// eslint-disable-next-line local/no-export-star // eslint-disable-next-line local/no-export-star
export * from '@tldraw/store' export * from '@tldraw/store'
// eslint-disable-next-line local/no-export-star // eslint-disable-next-line local/no-export-star
@ -131,6 +130,7 @@ export {
type TLEditorOptions, type TLEditorOptions,
type TLResizeShapeOptions, type TLResizeShapeOptions,
} from './lib/editor/Editor' } from './lib/editor/Editor'
export { HistoryManager } from './lib/editor/managers/HistoryManager'
export type { export type {
SideEffectManager, SideEffectManager,
TLAfterChangeHandler, TLAfterChangeHandler,
@ -235,12 +235,6 @@ export {
type TLExternalContent, type TLExternalContent,
type TLExternalContentSource, type TLExternalContentSource,
} from './lib/editor/types/external-content' } from './lib/editor/types/external-content'
export {
type TLCommand,
type TLCommandHandler,
type TLHistoryEntry,
type TLHistoryMark,
} from './lib/editor/types/history-types'
export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types' export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types' export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
export { ContainerProvider, useContainer } from './lib/hooks/useContainer' export { ContainerProvider, useContainer } from './lib/hooks/useContainer'

View file

@ -380,8 +380,11 @@ function useOnMount(onMount?: TLOnMountHandler) {
const editor = useEditor() const editor = useEditor()
const onMountEvent = useEvent((editor: Editor) => { const onMountEvent = useEvent((editor: Editor) => {
const teardown = onMount?.(editor) let teardown: (() => void) | void = undefined
editor.emit('mount') editor.history.ignore(() => {
teardown = onMount?.(editor)
editor.emit('mount')
})
window.tldrawReady = true window.tldrawReady = true
return teardown return teardown
}) })

View file

@ -14,6 +14,7 @@ import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShape
export type TLStoreOptions = { export type TLStoreOptions = {
initialData?: SerializedStore<TLRecord> initialData?: SerializedStore<TLRecord>
defaultName?: string defaultName?: string
id?: string
} & ( } & (
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] } | { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
| { schema?: StoreSchema<TLRecord, TLStoreProps> } | { schema?: StoreSchema<TLRecord, TLStoreProps> }
@ -28,7 +29,12 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>
* @param opts - Options for creating the store. * @param opts - Options for creating the store.
* *
* @public */ * @public */
export function createTLStore({ initialData, defaultName = '', ...rest }: TLStoreOptions): TLStore { export function createTLStore({
initialData,
defaultName = '',
id,
...rest
}: TLStoreOptions): TLStore {
const schema = const schema =
'schema' in rest && rest.schema 'schema' in rest && rest.schema
? // we have a schema ? // we have a schema
@ -42,6 +48,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
}) })
return new Store({ return new Store({
id,
schema, schema,
initialData, initialData,
props: { props: {

File diff suppressed because it is too large Load diff

View file

@ -1,92 +1,75 @@
import { TLCommandHistoryOptions } from '../types/history-types' import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
import { TLHistoryBatchOptions } from '../types/history-types'
import { HistoryManager } from './HistoryManager' import { HistoryManager } from './HistoryManager'
import { stack } from './Stack' import { stack } from './Stack'
interface TestRecord extends BaseRecord<'test', TestRecordId> {
value: number | string
}
type TestRecordId = RecordId<TestRecord>
const testSchema = StoreSchema.create<TestRecord, null>({
test: createRecordType<TestRecord>('test', { scope: 'document' }),
})
const ids = {
count: testSchema.types.test.createId('count'),
name: testSchema.types.test.createId('name'),
age: testSchema.types.test.createId('age'),
a: testSchema.types.test.createId('a'),
b: testSchema.types.test.createId('b'),
}
function createCounterHistoryManager() { function createCounterHistoryManager() {
const manager = new HistoryManager({ emit: () => void null }, () => { const store = new Store({ schema: testSchema, props: null })
return store.put([
}) testSchema.types.test.create({ id: ids.count, value: 0 }),
const state = { testSchema.types.test.create({ id: ids.name, value: 'David' }),
count: 0, testSchema.types.test.create({ id: ids.age, value: 35 }),
name: 'David', ])
age: 35,
const manager = new HistoryManager<TestRecord>({ store })
function getCount() {
return store.get(ids.count)!.value as number
}
function getName() {
return store.get(ids.name)!.value as string
}
function getAge() {
return store.get(ids.age)!.value as number
}
function _setCount(n: number) {
store.update(ids.count, (c) => ({ ...c, value: n }))
}
function _setName(name: string) {
store.update(ids.name, (c) => ({ ...c, value: name }))
}
function _setAge(age: number) {
store.update(ids.age, (c) => ({ ...c, value: age }))
} }
const increment = manager.createCommand(
'increment',
(n = 1, squashing = false) => ({
data: { n },
squashing,
}),
{
do: ({ n }) => {
state.count += n
},
undo: ({ n }) => {
state.count -= n
},
squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
}
)
const decrement = manager.createCommand( const increment = (n = 1) => {
'decrement', _setCount(getCount() + n)
(n = 1, squashing = false) => ({ }
data: { n },
squashing,
}),
{
do: ({ n }) => {
state.count -= n
},
undo: ({ n }) => {
state.count += n
},
squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
}
)
const setName = manager.createCommand( const decrement = (n = 1) => {
'setName', _setCount(getCount() - n)
(name = 'David') => ({ }
data: { name, prev: state.name },
ephemeral: true,
}),
{
do: ({ name }) => {
state.name = name
},
undo: ({ prev }) => {
state.name = prev
},
}
)
const setAge = manager.createCommand( const setName = (name = 'David') => {
'setAge', manager.ignore(() => _setName(name))
(age = 35) => ({ }
data: { age, prev: state.age },
preservesRedoStack: true,
}),
{
do: ({ age }) => {
state.age = age
},
undo: ({ prev }) => {
state.age = prev
},
}
)
const incrementTwice = manager.createCommand('incrementTwice', () => ({ data: {} }), { const setAge = (age = 35) => {
do: () => { manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
}
const incrementTwice = () => {
manager.batch(() => {
increment() increment()
increment() increment()
}, })
undo: () => { }
decrement()
decrement()
},
})
return { return {
increment, increment,
@ -95,9 +78,9 @@ function createCounterHistoryManager() {
setName, setName,
setAge, setAge,
history: manager, history: manager,
getCount: () => state.count, getCount,
getName: () => state.name, getName,
getAge: () => state.age, getAge,
} }
} }
@ -116,9 +99,9 @@ describe(HistoryManager, () => {
editor.decrement() editor.decrement()
expect(editor.getCount()).toBe(3) expect(editor.getCount()).toBe(3)
const undos = [...editor.history._undos.get()] const undos = [...editor.history.stacks.get().undos]
const parsedUndos = JSON.parse(JSON.stringify(undos)) const parsedUndos = JSON.parse(JSON.stringify(undos))
editor.history._undos.set(stack(parsedUndos)) editor.history.stacks.update(({ redos }) => ({ undos: stack(parsedUndos), redos }))
editor.history.undo() editor.history.undo()
@ -200,17 +183,16 @@ describe(HistoryManager, () => {
editor.history.mark('stop at 1') editor.history.mark('stop at 1')
expect(editor.getCount()).toBe(1) expect(editor.getCount()).toBe(1)
editor.increment(1, true) editor.increment(1)
editor.increment(1, true) editor.increment(1)
editor.increment(1, true) editor.increment(1)
editor.increment(1, true) editor.increment(1)
expect(editor.getCount()).toBe(5) expect(editor.getCount()).toBe(5)
expect(editor.history.getNumUndos()).toBe(3) expect(editor.history.getNumUndos()).toBe(3)
}) })
it('allows ignore commands that do not affect the stack', () => {
it('allows ephemeral commands that do not affect the stack', () => {
editor.increment() editor.increment()
editor.history.mark('stop at 1') editor.history.mark('stop at 1')
editor.increment() editor.increment()
@ -263,7 +245,7 @@ describe(HistoryManager, () => {
editor.history.mark('2') editor.history.mark('2')
editor.incrementTwice() editor.incrementTwice()
editor.incrementTwice() editor.incrementTwice()
expect(editor.history.getNumUndos()).toBe(5) expect(editor.history.getNumUndos()).toBe(4)
expect(editor.getCount()).toBe(6) expect(editor.getCount()).toBe(6)
editor.history.bail() editor.history.bail()
expect(editor.getCount()).toBe(2) expect(editor.getCount()).toBe(2)
@ -289,58 +271,35 @@ describe(HistoryManager, () => {
}) })
describe('history options', () => { describe('history options', () => {
let manager: HistoryManager<any> let manager: HistoryManager<TestRecord>
let state: { a: number; b: number }
let setA: (n: number, historyOptions?: TLCommandHistoryOptions) => any let getState: () => { a: number; b: number }
let setB: (n: number, historyOptions?: TLCommandHistoryOptions) => any let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
beforeEach(() => { beforeEach(() => {
manager = new HistoryManager({ emit: () => void null }, () => { const store = new Store({ schema: testSchema, props: null })
return store.put([
}) testSchema.types.test.create({ id: ids.a, value: 0 }),
testSchema.types.test.create({ id: ids.b, value: 0 }),
])
state = { manager = new HistoryManager<TestRecord>({ store })
a: 0,
b: 0, getState = () => {
return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number }
} }
setA = manager.createCommand( setA = (n: number, historyOptions?: TLHistoryBatchOptions) => {
'setA', manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
(n: number, historyOptions?: TLCommandHistoryOptions) => ({ }
data: { next: n, prev: state.a },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, a: next }
},
undo: ({ prev }) => {
state = { ...state, a: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
setB = manager.createCommand( setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
'setB', manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
(n: number, historyOptions?: TLCommandHistoryOptions) => ({ }
data: { next: n, prev: state.b },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, b: next }
},
undo: ({ prev }) => {
state = { ...state, b: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
}) })
it('sets, undoes, redoes', () => { it('undos, redoes, separate marks', () => {
manager.mark() manager.mark()
setA(1) setA(1)
manager.mark() manager.mark()
@ -348,18 +307,18 @@ describe('history options', () => {
manager.mark() manager.mark()
setB(2) setB(2)
expect(state).toMatchObject({ a: 1, b: 2 }) expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo() manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 }) expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo() manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 }) expect(getState()).toMatchObject({ a: 1, b: 2 })
}) })
it('sets, undoes, redoes', () => { it('undos, redos, squashing', () => {
manager.mark() manager.mark()
setA(1) setA(1)
manager.mark() manager.mark()
@ -369,71 +328,107 @@ describe('history options', () => {
setB(3) setB(3)
setB(4) setB(4)
expect(state).toMatchObject({ a: 1, b: 4 }) expect(getState()).toMatchObject({ a: 1, b: 4 })
manager.undo() manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 }) expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo() manager.redo()
expect(state).toMatchObject({ a: 1, b: 4 }) expect(getState()).toMatchObject({ a: 1, b: 4 })
}) })
it('sets ephemeral, undoes, redos', () => { it('undos, redos, ignore', () => {
manager.mark() manager.mark()
setA(1) setA(1)
manager.mark() manager.mark()
setB(1) // B 0->1 setB(1) // B 0->1
manager.mark() manager.mark()
setB(2, { ephemeral: true }) // B 0->2, but ephemeral setB(2, { history: 'ignore' }) // B 0->2, but ignore
expect(state).toMatchObject({ a: 1, b: 2 }) expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo() // undoes B 2->0 manager.undo() // undoes B 2->0
expect(state).toMatchObject({ a: 1, b: 0 }) expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() // redoes B 0->1, but not B 1-> 2 manager.redo() // redoes B 0->1, but not B 1-> 2
expect(state).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ephemeral expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
}) })
it('sets squashing, undoes, redos', () => { it('squashing, undos, redos', () => {
manager.mark() manager.mark()
setA(1) setA(1)
manager.mark() manager.mark()
setB(1) setB(1)
setB(2, { squashing: true }) // squashes with the previous command setB(2) // squashes with the previous command
setB(3, { squashing: true }) // squashes with the previous command setB(3) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 }) expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo() manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 }) expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() manager.redo()
expect(state).toMatchObject({ a: 1, b: 3 }) expect(getState()).toMatchObject({ a: 1, b: 3 })
}) })
it('sets squashing and ephemeral, undoes, redos', () => { it('squashing, undos, redos, ignore', () => {
manager.mark() manager.mark()
setA(1) setA(1)
manager.mark() manager.mark()
setB(1) setB(1)
setB(2, { squashing: true }) // squashes with the previous command setB(2) // squashes with the previous command
setB(3, { squashing: true, ephemeral: true }) // squashes with the previous command setB(3, { history: 'ignore' }) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 }) expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo() manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 }) expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 }) // B2->3 was ephemeral expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
})
it('nested ignore', () => {
manager.mark()
manager.batch(
() => {
setA(1)
// even though we set this to record, it will still be ignored
manager.batch(() => setB(1), { history: 'record' })
setA(2)
},
{ history: 'ignore' }
)
expect(getState()).toMatchObject({ a: 2, b: 1 })
// changes were ignored:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 1 })
manager.mark()
manager.batch(
() => {
setA(3)
manager.batch(() => setB(2), { history: 'ignore' })
},
{ history: 'record-preserveRedoStack' }
)
expect(getState()).toMatchObject({ a: 3, b: 2 })
// changes to A were recorded, but changes to B were ignore:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 2 })
// We can still redo because we preserved the redo stack:
manager.redo()
expect(getState()).toMatchObject({ a: 3, b: 2 })
}) })
}) })

View file

@ -1,156 +1,124 @@
import { atom, transact } from '@tldraw/state' import { atom, transact } from '@tldraw/state'
import { devFreeze } from '@tldraw/store' import {
RecordsDiff,
Store,
UnknownRecord,
createEmptyRecordsDiff,
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffsMutable,
} from '@tldraw/store'
import { exhaustiveSwitchError, noop } from '@tldraw/utils'
import { uniqueId } from '../../utils/uniqueId' import { uniqueId } from '../../utils/uniqueId'
import { TLCommandHandler, TLCommandHistoryOptions, TLHistoryEntry } from '../types/history-types' import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types'
import { Stack, stack } from './Stack' import { stack } from './Stack'
type CommandFn<Data> = (...args: any[]) => enum HistoryRecorderState {
| ({ Recording = 'recording',
data: Data RecordingPreserveRedoStack = 'recordingPreserveRedoStack',
} & TLCommandHistoryOptions) Paused = 'paused',
| null }
| undefined
| void
type ExtractData<Fn> = Fn extends CommandFn<infer Data> ? Data : never /** @public */
type ExtractArgs<Fn> = Parameters<Extract<Fn, (...args: any[]) => any>> export class HistoryManager<R extends UnknownRecord> {
private readonly store: Store<R>
export class HistoryManager< readonly dispose: () => void
CTX extends {
emit: (name: 'change-history' | 'mark-history', ...args: any) => void
},
> {
_undos = atom<Stack<TLHistoryEntry>>('HistoryManager.undos', stack()) // Updated by each action that includes and undo
_redos = atom<Stack<TLHistoryEntry>>('HistoryManager.redos', stack()) // Updated when a user undoes
_batchDepth = 0 // A flag for whether the user is in a batch operation
constructor( private state: HistoryRecorderState = HistoryRecorderState.Recording
private readonly ctx: CTX, private readonly pendingDiff = new PendingDiff<R>()
private readonly annotateError: (error: unknown) => void /** @internal */
) {} stacks = atom(
'HistoryManager.stacks',
{
undos: stack<TLHistoryEntry<R>>(),
redos: stack<TLHistoryEntry<R>>(),
},
{
isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
}
)
private readonly annotateError: (error: unknown) => void
constructor(opts: { store: Store<R>; annotateError?: (error: unknown) => void }) {
this.store = opts.store
this.annotateError = opts.annotateError ?? noop
this.dispose = this.store.addHistoryInterceptor((entry, source) => {
if (source !== 'user') return
switch (this.state) {
case HistoryRecorderState.Recording:
this.pendingDiff.apply(entry.changes)
this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
break
case HistoryRecorderState.RecordingPreserveRedoStack:
this.pendingDiff.apply(entry.changes)
break
case HistoryRecorderState.Paused:
break
default:
exhaustiveSwitchError(this.state)
}
})
}
private flushPendingDiff() {
if (this.pendingDiff.isEmpty()) return
const diff = this.pendingDiff.clear()
this.stacks.update(({ undos, redos }) => ({
undos: undos.push({ type: 'diff', diff }),
redos,
}))
}
onBatchComplete: () => void = () => void null onBatchComplete: () => void = () => void null
private _commands: Record<string, TLCommandHandler<any>> = {}
getNumUndos() { getNumUndos() {
return this._undos.get().length return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
} }
getNumRedos() { getNumRedos() {
return this._redos.get().length return this.stacks.get().redos.length
}
createCommand = <Name extends string, Constructor extends CommandFn<any>>(
name: Name,
constructor: Constructor,
handle: TLCommandHandler<ExtractData<Constructor>>
) => {
if (this._commands[name]) {
throw new Error(`Duplicate command: ${name}`)
}
this._commands[name] = handle
const exec = (...args: ExtractArgs<Constructor>) => {
if (!this._batchDepth) {
// If we're not batching, run again in a batch
this.batch(() => exec(...args))
return this.ctx
}
const result = constructor(...args)
if (!result) {
return this.ctx
}
const { data, ephemeral, squashing, preservesRedoStack } = result
this.ignoringUpdates((undos, redos) => {
handle.do(data)
return { undos, redos }
})
if (!ephemeral) {
const prev = this._undos.get().head
if (
squashing &&
prev &&
prev.type === 'command' &&
prev.name === name &&
prev.preservesRedoStack === preservesRedoStack
) {
// replace the last command with a squashed version
this._undos.update((undos) =>
undos.tail.push({
...prev,
data: devFreeze(handle.squash!(prev.data, data)),
})
)
} else {
// add to the undo stack
this._undos.update((undos) =>
undos.push({
type: 'command',
name,
data: devFreeze(data),
preservesRedoStack: preservesRedoStack,
})
)
}
if (!result.preservesRedoStack) {
this._redos.set(stack())
}
this.ctx.emit('change-history', { reason: 'push' })
}
return this.ctx
}
return exec
} }
batch = (fn: () => void) => { /** @internal */
_isInBatch = false
batch = (fn: () => void, opts?: TLHistoryBatchOptions) => {
const previousState = this.state
// we move to the new state only if we haven't explicitly paused
if (previousState !== HistoryRecorderState.Paused && opts?.history) {
this.state = modeToState[opts.history]
}
try { try {
this._batchDepth++ if (this._isInBatch) {
if (this._batchDepth === 1) {
transact(() => {
const mostRecentAction = this._undos.get().head
fn()
if (mostRecentAction !== this._undos.get().head) {
this.onBatchComplete()
}
})
} else {
fn() fn()
return this
} }
} catch (error) {
this.annotateError(error)
throw error
} finally {
this._batchDepth--
}
return this this._isInBatch = true
try {
transact(() => {
fn()
this.onBatchComplete()
})
} catch (error) {
this.annotateError(error)
throw error
} finally {
this._isInBatch = false
}
return this
} finally {
this.state = previousState
}
} }
private ignoringUpdates = ( ignore(fn: () => void) {
fn: ( return this.batch(fn, { history: 'ignore' })
undos: Stack<TLHistoryEntry>,
redos: Stack<TLHistoryEntry>
) => { undos: Stack<TLHistoryEntry>; redos: Stack<TLHistoryEntry> }
) => {
let undos = this._undos.get()
let redos = this._redos.get()
this._undos.set(stack())
this._redos.set(stack())
try {
;({ undos, redos } = transact(() => fn(undos, redos)))
} finally {
this._undos.set(undos)
this._redos.set(redos)
}
} }
// History // History
@ -161,62 +129,66 @@ export class HistoryManager<
pushToRedoStack: boolean pushToRedoStack: boolean
toMark?: string toMark?: string
}) => { }) => {
this.ignoringUpdates((undos, redos) => { const previousState = this.state
if (undos.length === 0) { this.state = HistoryRecorderState.Paused
return { undos, redos } try {
let { undos, redos } = this.stacks.get()
// start by collecting the pending diff (everything since the last mark).
// we'll accumulate the diff to undo in this variable so we can apply it atomically.
const pendingDiff = this.pendingDiff.clear()
const isPendingDiffEmpty = isRecordsDiffEmpty(pendingDiff)
const diffToUndo = reverseRecordsDiff(pendingDiff)
if (pushToRedoStack && !isPendingDiffEmpty) {
redos = redos.push({ type: 'diff', diff: pendingDiff })
} }
while (undos.head?.type === 'STOP') { let didFindMark = false
const mark = undos.head if (isPendingDiffEmpty) {
undos = undos.tail // if nothing has happened since the last mark, pop any intermediate marks off the stack
if (pushToRedoStack) { while (undos.head?.type === 'stop') {
redos = redos.push(mark) const mark = undos.head
} undos = undos.tail
if (mark.id === toMark) { if (pushToRedoStack) {
this.ctx.emit( redos = redos.push(mark)
'change-history', }
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark } if (mark.id === toMark) {
) didFindMark = true
return { undos, redos } break
}
}
if (undos.length === 0) {
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
while (undos.head) {
const command = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(command)
}
if (command.type === 'STOP') {
if (command.onUndo && (!toMark || command.id === toMark)) {
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
} }
} else {
const handler = this._commands[command.name]
handler.undo(command.data)
} }
} }
this.ctx.emit( if (!didFindMark) {
'change-history', loop: while (undos.head) {
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark } const undo = undos.head
) undos = undos.tail
return { undos, redos }
}) if (pushToRedoStack) {
redos = redos.push(undo)
}
switch (undo.type) {
case 'diff':
squashRecordDiffsMutable(diffToUndo, [reverseRecordsDiff(undo.diff)])
break
case 'stop':
if (!toMark) break loop
if (undo.id === toMark) break loop
break
default:
exhaustiveSwitchError(undo)
}
}
}
this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this return this
} }
@ -228,43 +200,43 @@ export class HistoryManager<
} }
redo = () => { redo = () => {
this.ignoringUpdates((undos, redos) => { const previousState = this.state
this.state = HistoryRecorderState.Paused
try {
this.flushPendingDiff()
let { undos, redos } = this.stacks.get()
if (redos.length === 0) { if (redos.length === 0) {
return { undos, redos } return
} }
while (redos.head?.type === 'STOP') { // ignore any intermediate marks - this should take us to the first `diff` entry
while (redos.head?.type === 'stop') {
undos = undos.push(redos.head) undos = undos.push(redos.head)
redos = redos.tail redos = redos.tail
} }
if (redos.length === 0) { // accumulate diffs to be redone so they can be applied atomically
this.ctx.emit('change-history', { reason: 'redo' }) const diffToRedo = createEmptyRecordsDiff<R>()
return { undos, redos }
}
while (redos.head) { while (redos.head) {
const command = redos.head const redo = redos.head
undos = undos.push(redos.head) undos = undos.push(redo)
redos = redos.tail redos = redos.tail
if (command.type === 'STOP') { if (redo.type === 'diff') {
if (command.onRedo) { squashRecordDiffsMutable(diffToRedo, [redo.diff])
break
}
} else { } else {
const handler = this._commands[command.name] break
if (handler.redo) {
handler.redo(command.data)
} else {
handler.do(command.data)
}
} }
} }
this.ctx.emit('change-history', { reason: 'redo' }) this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
return { undos, redos } this.store.ensureStoreIsUsable()
}) this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this return this
} }
@ -281,24 +253,59 @@ export class HistoryManager<
return this return this
} }
mark = (id = uniqueId(), onUndo = true, onRedo = true) => { mark = (id = uniqueId()) => {
const mostRecent = this._undos.get().head transact(() => {
// dedupe marks, why not this.flushPendingDiff()
if (mostRecent && mostRecent.type === 'STOP') { this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
if (mostRecent.id === id && mostRecent.onUndo === onUndo && mostRecent.onRedo === onRedo) { })
return mostRecent.id
}
}
this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo }))
this.ctx.emit('mark-history', { id })
return id return id
} }
clear() { clear() {
this._undos.set(stack()) this.stacks.set({ undos: stack(), redos: stack() })
this._redos.set(stack()) this.pendingDiff.clear()
}
/** @internal */
debug() {
const { undos, redos } = this.stacks.get()
return {
undos: undos.toArray(),
redos: redos.toArray(),
pendingDiff: this.pendingDiff.debug(),
state: this.state,
}
}
}
const modeToState = {
record: HistoryRecorderState.Recording,
'record-preserveRedoStack': HistoryRecorderState.RecordingPreserveRedoStack,
ignore: HistoryRecorderState.Paused,
} as const
class PendingDiff<R extends UnknownRecord> {
private diff = createEmptyRecordsDiff<R>()
private isEmptyAtom = atom('PendingDiff.isEmpty', true)
clear() {
const diff = this.diff
this.diff = createEmptyRecordsDiff<R>()
this.isEmptyAtom.set(true)
return diff
}
isEmpty() {
return this.isEmptyAtom.get()
}
apply(diff: RecordsDiff<R>) {
squashRecordDiffsMutable(this.diff, [diff])
this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
}
debug() {
return { diff: this.diff, isEmpty: this.isEmpty() }
} }
} }

View file

@ -88,25 +88,13 @@ export class SideEffectManager<
return next return next
} }
let updateDepth = 0
editor.store.onAfterChange = (prev, next, source) => { editor.store.onAfterChange = (prev, next, source) => {
updateDepth++ const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler<TLRecord>[]
if (handlers) {
if (updateDepth > 1000) { for (const handler of handlers) {
console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.') handler(prev, next, source)
} else {
const handlers = this._afterChangeHandlers[
next.typeName
] as TLAfterChangeHandler<TLRecord>[]
if (handlers) {
for (const handler of handlers) {
handler(prev, next, source)
}
} }
} }
updateDepth--
} }
editor.store.onBeforeDelete = (record, source) => { editor.store.onBeforeDelete = (record, source) => {
@ -161,6 +149,46 @@ export class SideEffectManager<
private _batchCompleteHandlers: TLBatchCompleteHandler[] = [] private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
/**
* Internal helper for registering a bunch of side effects at once and keeping them organized.
* @internal
*/
register(handlersByType: {
[R in TLRecord as R['typeName']]?: {
beforeCreate?: TLBeforeCreateHandler<R>
afterCreate?: TLAfterCreateHandler<R>
beforeChange?: TLBeforeChangeHandler<R>
afterChange?: TLAfterChangeHandler<R>
beforeDelete?: TLBeforeDeleteHandler<R>
afterDelete?: TLAfterDeleteHandler<R>
}
}) {
const disposes: (() => void)[] = []
for (const [type, handlers] of Object.entries(handlersByType) as any) {
if (handlers?.beforeCreate) {
disposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate))
}
if (handlers?.afterCreate) {
disposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate))
}
if (handlers?.beforeChange) {
disposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange))
}
if (handlers?.afterChange) {
disposes.push(this.registerAfterChangeHandler(type, handlers.afterChange))
}
if (handlers?.beforeDelete) {
disposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete))
}
if (handlers?.afterDelete) {
disposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete))
}
}
return () => {
for (const dispose of disposes) dispose()
}
}
/** /**
* Register a handler to be called before a record of a certain type is created. Return a * Register a handler to be called before a record of a certain type is created. Return a
* modified record from the handler to change the record that will be created. * modified record from the handler to change the record that will be created.

View file

@ -15,8 +15,6 @@ export interface TLEventMap {
event: [TLEventInfo] event: [TLEventInfo]
tick: [number] tick: [number]
frame: [number] frame: [number]
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
'mark-history': [{ id: string }]
'select-all-text': [{ shapeId: TLShapeId }] 'select-all-text': [{ shapeId: TLShapeId }]
} }

View file

@ -1,50 +1,27 @@
/** @public */ import { RecordsDiff, UnknownRecord } from '@tldraw/store'
export type TLCommandHistoryOptions = Partial<{
/**
* When true, this command will be squashed with the previous command in the undo / redo stack.
*/
squashing: boolean
/**
* When true, this command will not add anything to the undo / redo stack. Its change will never be undone or redone.
*/
ephemeral: boolean
/**
* When true, adding this this command will not clear out the redo stack.
*/
preservesRedoStack: boolean
}>
/** @public */ /** @public */
export type TLHistoryMark = { export interface TLHistoryMark {
type: 'STOP' type: 'stop'
id: string id: string
onUndo: boolean
onRedo: boolean
} }
/** @public */ /** @public */
export type TLCommand<Name extends string = any, Data = any> = { export interface TLHistoryDiff<R extends UnknownRecord> {
type: 'command' type: 'diff'
data: Data diff: RecordsDiff<R>
name: Name }
/** @public */
export type TLHistoryEntry<R extends UnknownRecord> = TLHistoryMark | TLHistoryDiff<R>
/** @public */
export interface TLHistoryBatchOptions {
/** /**
* Allows for commands that change state and should be undoable, but are 'inconsequential' and * How should this change interact with the history stack?
* should not clear the redo stack. e.g. modifying the set of selected ids. * - record: Add to the undo stack and clear the redo stack
* - record-preserveRedoStack: Add to the undo stack but do not clear the redo stack
* - ignore: Do not add to the undo stack or the redo stack
*/ */
preservesRedoStack?: boolean history?: 'record' | 'record-preserveRedoStack' | 'ignore'
}
/** @public */
export type TLHistoryEntry = TLHistoryMark | TLCommand
/** @public */
export type TLCommandHandler<Data> = {
do: (data: Data) => void
undo: (data: Data) => void
redo?: (data: Data) => void
/**
* Allow to combine the next command with the previous one if possible. Useful for, e.g. combining
* a series of shape translation commands into one command in the undo stack
*/
squash?: (prevData: Data, nextData: Data) => Data
} }

View file

@ -33,6 +33,9 @@ export type ComputedCache<Data, R extends UnknownRecord> = {
get(id: IdOf<R>): Data | undefined; get(id: IdOf<R>): Data | undefined;
}; };
// @internal (undocumented)
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R>;
// @public // @public
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): { export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
[K in keyof Versions]: `${ID}/${Versions[K]}`; [K in keyof Versions]: `${ID}/${Versions[K]}`;
@ -58,6 +61,9 @@ export function createRecordMigrationSequence(opts: {
// @public // @public
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: { export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
scope: RecordScope; scope: RecordScope;
validator?: StoreValidator<R>; validator?: StoreValidator<R>;
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>; }): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
@ -98,6 +104,9 @@ export class IncrementalSetConstructor<T> {
remove(item: T): void; remove(item: T): void;
} }
// @internal
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>): boolean;
// @public (undocumented) // @public (undocumented)
export type LegacyMigration<Before = any, After = any> = { export type LegacyMigration<Before = any, After = any> = {
down: (newState: After) => Before; down: (newState: After) => Before;
@ -187,6 +196,9 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
constructor( constructor(
typeName: R['typeName'], config: { typeName: R['typeName'], config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>; readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
readonly ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
readonly scope?: RecordScope; readonly scope?: RecordScope;
readonly validator?: StoreValidator<R>; readonly validator?: StoreValidator<R>;
}); });
@ -197,6 +209,12 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
// (undocumented) // (undocumented)
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>; readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
createId(customUniquePart?: string): IdOf<R>; createId(customUniquePart?: string): IdOf<R>;
// (undocumented)
readonly ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
// (undocumented)
readonly ephemeralKeySet: ReadonlySet<string>;
isId(id?: string): id is IdOf<R>; isId(id?: string): id is IdOf<R>;
isInstance: (record?: UnknownRecord) => record is R; isInstance: (record?: UnknownRecord) => record is R;
parseId(id: IdOf<R>): string; parseId(id: IdOf<R>): string;
@ -244,22 +262,32 @@ export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
// @public // @public
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[]): RecordsDiff<T>; export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[]): RecordsDiff<T>;
// @internal
export function squashRecordDiffsMutable<T extends UnknownRecord>(target: RecordsDiff<T>, diffs: RecordsDiff<T>[]): void;
// @public // @public
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> { export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
constructor(config: { constructor(config: {
schema: StoreSchema<R, Props>; schema: StoreSchema<R, Props>;
initialData?: SerializedStore<R>; initialData?: SerializedStore<R>;
id?: string;
props: Props; props: Props;
}); });
// @internal (undocumented)
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void): () => void;
allRecords: () => R[]; allRecords: () => R[];
// (undocumented) // (undocumented)
applyDiff(diff: RecordsDiff<R>, runCallbacks?: boolean): void; applyDiff(diff: RecordsDiff<R>, { runCallbacks, ignoreEphemeralKeys, }?: {
ignoreEphemeralKeys?: boolean;
runCallbacks?: boolean;
}): void;
// @internal (undocumented)
atomic<T>(fn: () => T, runCallbacks?: boolean): T;
clear: () => void; clear: () => void;
createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined, isEqual?: ((a: V, b: V) => boolean) | undefined) => ComputedCache<T, V>; createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined, isEqual?: ((a: V, b: V) => boolean) | undefined) => ComputedCache<T, V>;
createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>; createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>;
// @internal (undocumented) // @internal (undocumented)
ensureStoreIsUsable(): void; ensureStoreIsUsable(): void;
// (undocumented)
extractingChanges(fn: () => void): RecordsDiff<R>; extractingChanges(fn: () => void): RecordsDiff<R>;
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): { filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
added: { [K in IdOf<R>]: R; }; added: { [K in IdOf<R>]: R; };
@ -269,8 +297,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// (undocumented) // (undocumented)
_flushHistory(): void; _flushHistory(): void;
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined; get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
// (undocumented)
getRecordType: <T extends R>(record: R) => T;
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>; getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
has: <K extends IdOf<R>>(id: K) => boolean; has: <K extends IdOf<R>>(id: K) => boolean;
readonly history: Atom<number, RecordsDiff<R>>; readonly history: Atom<number, RecordsDiff<R>>;
@ -331,6 +357,8 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined; createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
// (undocumented) // (undocumented)
getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>; getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
// @internal (undocumented)
getType(typeName: string): RecordType<R, any>;
// (undocumented) // (undocumented)
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>; migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
// (undocumented) // (undocumented)

View file

@ -1,12 +1,19 @@
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord' export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor' export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
export { RecordType, assertIdType, createRecordType } from './lib/RecordType' export { RecordType, assertIdType, createRecordType } from './lib/RecordType'
export { Store, reverseRecordsDiff, squashRecordDiffs } from './lib/Store' export {
createEmptyRecordsDiff,
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffs,
squashRecordDiffsMutable,
type RecordsDiff,
} from './lib/RecordsDiff'
export { Store } from './lib/Store'
export type { export type {
CollectionDiff, CollectionDiff,
ComputedCache, ComputedCache,
HistoryEntry, HistoryEntry,
RecordsDiff,
SerializedStore, SerializedStore,
StoreError, StoreError,
StoreListener, StoreListener,

View file

@ -1,4 +1,4 @@
import { structuredClone } from '@tldraw/utils' import { objectMapEntries, structuredClone } from '@tldraw/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord' import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord'
import { StoreValidator } from './Store' import { StoreValidator } from './Store'
@ -28,7 +28,8 @@ export class RecordType<
> { > {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly validator: StoreValidator<R> readonly validator: StoreValidator<R>
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
readonly ephemeralKeySet: ReadonlySet<string>
readonly scope: RecordScope readonly scope: RecordScope
constructor( constructor(
@ -43,11 +44,21 @@ export class RecordType<
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly validator?: StoreValidator<R> readonly validator?: StoreValidator<R>
readonly scope?: RecordScope readonly scope?: RecordScope
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
} }
) { ) {
this.createDefaultProperties = config.createDefaultProperties this.createDefaultProperties = config.createDefaultProperties
this.validator = config.validator ?? { validate: (r: unknown) => r as R } this.validator = config.validator ?? { validate: (r: unknown) => r as R }
this.scope = config.scope ?? 'document' this.scope = config.scope ?? 'document'
this.ephemeralKeys = config.ephemeralKeys
const ephemeralKeySet = new Set<string>()
if (config.ephemeralKeys) {
for (const [key, isEphemeral] of objectMapEntries(config.ephemeralKeys)) {
if (isEphemeral) ephemeralKeySet.add(key)
}
}
this.ephemeralKeySet = ephemeralKeySet
} }
/** /**
@ -186,6 +197,7 @@ export class RecordType<
createDefaultProperties: createDefaultProperties as any, createDefaultProperties: createDefaultProperties as any,
validator: this.validator, validator: this.validator,
scope: this.scope, scope: this.scope,
ephemeralKeys: this.ephemeralKeys,
}) })
} }
@ -218,12 +230,14 @@ export function createRecordType<R extends UnknownRecord>(
config: { config: {
validator?: StoreValidator<R> validator?: StoreValidator<R>
scope: RecordScope scope: RecordScope
ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
} }
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> { ): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, { return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
createDefaultProperties: () => ({}) as any, createDefaultProperties: () => ({}) as any,
validator: config.validator, validator: config.validator,
scope: config.scope, scope: config.scope,
ephemeralKeys: config.ephemeralKeys,
}) })
} }

View file

@ -0,0 +1,107 @@
import { objectMapEntries } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
/**
* A diff describing the changes to a record.
*
* @public
*/
export type RecordsDiff<R extends UnknownRecord> = {
added: Record<IdOf<R>, R>
updated: Record<IdOf<R>, [from: R, to: R]>
removed: Record<IdOf<R>, R>
}
/** @internal */
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R> {
return { added: {}, updated: {}, removed: {} } as RecordsDiff<R>
}
/** @public */
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
for (const [from, to] of Object.values(diff.updated)) {
result.updated[from.id] = [to, from]
}
return result
}
/**
* Is a records diff empty?
* @internal
*/
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
return (
Object.keys(diff.added).length === 0 &&
Object.keys(diff.updated).length === 0 &&
Object.keys(diff.removed).length === 0
)
}
/**
* Squash a collection of diffs into a single diff.
*
* @param diffs - An array of diffs to squash.
* @returns A single diff that represents the squashed diffs.
* @public
*/
export function squashRecordDiffs<T extends UnknownRecord>(
diffs: RecordsDiff<T>[]
): RecordsDiff<T> {
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
squashRecordDiffsMutable(result, diffs)
return result
}
/**
* Apply the array `diffs` to the `target` diff, mutating it in-place.
* @internal
*/
export function squashRecordDiffsMutable<T extends UnknownRecord>(
target: RecordsDiff<T>,
diffs: RecordsDiff<T>[]
): void {
for (const diff of diffs) {
for (const [id, value] of objectMapEntries(diff.added)) {
if (target.removed[id]) {
const original = target.removed[id]
delete target.removed[id]
if (original !== value) {
target.updated[id] = [original, value]
}
} else {
target.added[id] = value
}
}
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
if (target.added[id]) {
target.added[id] = to
delete target.updated[id]
delete target.removed[id]
continue
}
if (target.updated[id]) {
target.updated[id] = [target.updated[id][0], to]
delete target.removed[id]
continue
}
target.updated[id] = diff.updated[id]
delete target.removed[id]
}
for (const [id, value] of objectMapEntries(diff.removed)) {
// the same record was added in this diff sequence, just drop it
if (target.added[id]) {
delete target.added[id]
} else if (target.updated[id]) {
target.removed[id] = target.updated[id][0]
delete target.updated[id]
} else {
target.removed[id] = value
}
}
}
}

View file

@ -1,6 +1,8 @@
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state' import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state'
import { import {
assert,
filterEntries, filterEntries,
getOwnProperty,
objectMapEntries, objectMapEntries,
objectMapFromEntries, objectMapFromEntries,
objectMapKeys, objectMapKeys,
@ -11,23 +13,13 @@ import { nanoid } from 'nanoid'
import { IdOf, RecordId, UnknownRecord } from './BaseRecord' import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
import { Cache } from './Cache' import { Cache } from './Cache'
import { RecordScope } from './RecordType' import { RecordScope } from './RecordType'
import { RecordsDiff, squashRecordDiffs } from './RecordsDiff'
import { StoreQueries } from './StoreQueries' import { StoreQueries } from './StoreQueries'
import { SerializedSchema, StoreSchema } from './StoreSchema' import { SerializedSchema, StoreSchema } from './StoreSchema'
import { devFreeze } from './devFreeze' import { devFreeze } from './devFreeze'
type RecFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never type RecFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never
/**
* A diff describing the changes to a record.
*
* @public
*/
export type RecordsDiff<R extends UnknownRecord> = {
added: Record<IdOf<R>, R>
updated: Record<IdOf<R>, [from: R, to: R]>
removed: Record<IdOf<R>, R>
}
/** /**
* A diff describing the changes to a collection. * A diff describing the changes to a collection.
* *
@ -113,7 +105,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
/** /**
* The random id of the store. * The random id of the store.
*/ */
public readonly id = nanoid() public readonly id: string
/** /**
* An atom containing the store's atoms. * An atom containing the store's atoms.
* *
@ -169,6 +161,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> } public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> }
constructor(config: { constructor(config: {
id?: string
/** The store's initial data. */ /** The store's initial data. */
initialData?: SerializedStore<R> initialData?: SerializedStore<R>
/** /**
@ -178,8 +171,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
schema: StoreSchema<R, Props> schema: StoreSchema<R, Props>
props: Props props: Props
}) { }) {
const { initialData, schema } = config const { initialData, schema, id } = config
this.id = id ?? nanoid()
this.schema = schema this.schema = schema
this.props = config.props this.props = config.props
@ -357,7 +351,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* @public * @public
*/ */
put = (records: R[], phaseOverride?: 'initialize'): void => { put = (records: R[], phaseOverride?: 'initialize'): void => {
transact(() => { this.atomic(() => {
const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {} const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {}
const additions: Record<IdOf<UnknownRecord>, R> = {} const additions: Record<IdOf<UnknownRecord>, R> = {}
@ -402,7 +396,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
recordAtom.set(devFreeze(record)) recordAtom.set(devFreeze(record))
didChange = true didChange = true
updates[record.id] = [initialValue, recordAtom.__unsafe__getWithoutCapture()] const updated = recordAtom.__unsafe__getWithoutCapture()
updates[record.id] = [initialValue, updated]
this.addDiffForAfterEvent(initialValue, updated, source)
} else { } else {
if (beforeCreate) record = beforeCreate(record, source) if (beforeCreate) record = beforeCreate(record, source)
@ -420,6 +416,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// Mark the change as a new addition. // Mark the change as a new addition.
additions[record.id] = record additions[record.id] = record
this.addDiffForAfterEvent(null, record, source)
// Assign the atom to the map under the record's id. // Assign the atom to the map under the record's id.
if (!map) { if (!map) {
@ -441,24 +438,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
updated: updates, updated: updates,
removed: {} as Record<IdOf<R>, R>, removed: {} as Record<IdOf<R>, R>,
}) })
if (this._runCallbacks) {
const { onAfterCreate, onAfterChange } = this
if (onAfterCreate) {
// Run the onAfterChange callback for addition.
Object.values(additions).forEach((record) => {
onAfterCreate(record, source)
})
}
if (onAfterChange) {
// Run the onAfterChange callback for update.
Object.values(updates).forEach(([from, to]) => {
onAfterChange(from, to, source)
})
}
}
}) })
} }
@ -469,7 +448,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* @public * @public
*/ */
remove = (ids: IdOf<R>[]): void => { remove = (ids: IdOf<R>[]): void => {
transact(() => { this.atomic(() => {
const cancelled = [] as IdOf<R>[] const cancelled = [] as IdOf<R>[]
const source = this.isMergingRemoteChanges ? 'remote' : 'user' const source = this.isMergingRemoteChanges ? 'remote' : 'user'
@ -496,7 +475,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
if (!result) result = { ...atoms } if (!result) result = { ...atoms }
if (!removed) removed = {} as Record<IdOf<R>, R> if (!removed) removed = {} as Record<IdOf<R>, R>
delete result[id] delete result[id]
removed[id] = atoms[id].get() const record = atoms[id].get()
removed[id] = record
this.addDiffForAfterEvent(record, null, source)
} }
return result ?? atoms return result ?? atoms
@ -505,17 +486,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
if (!removed) return if (!removed) return
// Update the history with the removed records. // Update the history with the removed records.
this.updateHistory({ added: {}, updated: {}, removed } as RecordsDiff<R>) this.updateHistory({ added: {}, updated: {}, removed } as RecordsDiff<R>)
// If we have an onAfterChange, run it for each removed record.
if (this.onAfterDelete && this._runCallbacks) {
let record: R
for (let i = 0, n = ids.length; i < n; i++) {
record = removed[ids[i]]
if (record) {
this.onAfterDelete(record, source)
}
}
}
}) })
} }
@ -620,7 +590,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
const prevRunCallbacks = this._runCallbacks const prevRunCallbacks = this._runCallbacks
try { try {
this._runCallbacks = false this._runCallbacks = false
transact(() => { this.atomic(() => {
this.clear() this.clear()
this.put(Object.values(migrationResult.value)) this.put(Object.values(migrationResult.value))
this.ensureStoreIsUsable() this.ensureStoreIsUsable()
@ -731,9 +701,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
/**
* Run `fn` and return a {@link RecordsDiff} of the changes that occurred as a result.
*/
extractingChanges(fn: () => void): RecordsDiff<R> { extractingChanges(fn: () => void): RecordsDiff<R> {
const changes: Array<RecordsDiff<R>> = [] const changes: Array<RecordsDiff<R>> = []
const dispose = this.historyAccumulator.intercepting((entry) => changes.push(entry.changes)) const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes))
try { try {
transact(fn) transact(fn)
return squashRecordDiffs(changes) return squashRecordDiffs(changes)
@ -742,25 +715,47 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
applyDiff(diff: RecordsDiff<R>, runCallbacks = true) { applyDiff(
const prevRunCallbacks = this._runCallbacks diff: RecordsDiff<R>,
try { {
this._runCallbacks = runCallbacks runCallbacks = true,
transact(() => { ignoreEphemeralKeys = false,
const toPut = objectMapValues(diff.added).concat( }: { runCallbacks?: boolean; ignoreEphemeralKeys?: boolean } = {}
objectMapValues(diff.updated).map(([_from, to]) => to) ) {
) this.atomic(() => {
const toRemove = objectMapKeys(diff.removed) const toPut = objectMapValues(diff.added)
if (toPut.length) {
this.put(toPut) for (const [_from, to] of objectMapValues(diff.updated)) {
const type = this.schema.getType(to.typeName)
if (ignoreEphemeralKeys && type.ephemeralKeySet.size) {
const existing = this.get(to.id)
if (!existing) {
toPut.push(to)
continue
}
let changed: R | null = null
for (const [key, value] of Object.entries(to)) {
if (type.ephemeralKeySet.has(key) || Object.is(value, getOwnProperty(existing, key))) {
continue
}
if (!changed) changed = { ...existing } as R
;(changed as any)[key] = value
}
if (changed) toPut.push(changed)
} else {
toPut.push(to)
} }
if (toRemove.length) { }
this.remove(toRemove)
} const toRemove = objectMapKeys(diff.removed)
}) if (toPut.length) {
} finally { this.put(toPut)
this._runCallbacks = prevRunCallbacks }
} if (toRemove.length) {
this.remove(toRemove)
}
}, runCallbacks)
} }
/** /**
@ -827,20 +822,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
getRecordType = <T extends R>(record: R): T => {
const type = this.schema.types[record.typeName as R['typeName']]
if (!type) {
throw new Error(`Record type ${record.typeName} not found`)
}
return type as unknown as T
}
private _integrityChecker?: () => void | undefined private _integrityChecker?: () => void | undefined
/** @internal */ /** @internal */
ensureStoreIsUsable() { ensureStoreIsUsable() {
this._integrityChecker ??= this.schema.createIntegrityChecker(this) this.atomic(() => {
this._integrityChecker?.() this._integrityChecker ??= this.schema.createIntegrityChecker(this)
this._integrityChecker?.()
})
} }
private _isPossiblyCorrupted = false private _isPossiblyCorrupted = false
@ -852,64 +841,82 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
isPossiblyCorrupted() { isPossiblyCorrupted() {
return this._isPossiblyCorrupted return this._isPossiblyCorrupted
} }
}
/** private pendingAfterEvents: Map<
* Squash a collection of diffs into a single diff. IdOf<R>,
* { before: R | null; after: R | null; source: 'remote' | 'user' }
* @param diffs - An array of diffs to squash. > | null = null
* @returns A single diff that represents the squashed diffs. private addDiffForAfterEvent(before: R | null, after: R | null, source: 'remote' | 'user') {
* @public assert(this.pendingAfterEvents, 'must be in event operation')
*/ if (before === after) return
export function squashRecordDiffs<T extends UnknownRecord>( if (before && after) assert(before.id === after.id)
diffs: RecordsDiff<T>[] if (!before && !after) return
): RecordsDiff<T> { const id = (before || after)!.id
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T> const existing = this.pendingAfterEvents.get(id)
if (existing) {
assert(existing.source === source, 'source cannot change within a single event operation')
existing.after = after
} else {
this.pendingAfterEvents.set(id, { before, after, source })
}
}
private flushAtomicCallbacks() {
let updateDepth = 0
while (this.pendingAfterEvents) {
const events = this.pendingAfterEvents
this.pendingAfterEvents = null
for (const diff of diffs) { if (!this._runCallbacks) continue
for (const [id, value] of objectMapEntries(diff.added)) {
if (result.removed[id]) { updateDepth++
const original = result.removed[id] if (updateDepth > 100) {
delete result.removed[id] throw new Error('Maximum store update depth exceeded, bailing out')
if (original !== value) { }
result.updated[id] = [original, value]
for (const { before, after, source } of events.values()) {
if (before && after) {
this.onAfterChange?.(before, after, source)
} else if (before && !after) {
this.onAfterDelete?.(before, source)
} else if (!before && after) {
this.onAfterCreate?.(after, source)
} }
} else {
result.added[id] = value
}
}
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
if (result.added[id]) {
result.added[id] = to
delete result.updated[id]
delete result.removed[id]
continue
}
if (result.updated[id]) {
result.updated[id] = [result.updated[id][0], to]
delete result.removed[id]
continue
}
result.updated[id] = diff.updated[id]
delete result.removed[id]
}
for (const [id, value] of objectMapEntries(diff.removed)) {
// the same record was added in this diff sequence, just drop it
if (result.added[id]) {
delete result.added[id]
} else if (result.updated[id]) {
result.removed[id] = result.updated[id][0]
delete result.updated[id]
} else {
result.removed[id] = value
} }
} }
} }
private _isInAtomicOp = false
/** @internal */
atomic<T>(fn: () => T, runCallbacks = true): T {
return transact(() => {
if (this._isInAtomicOp) {
if (!this.pendingAfterEvents) this.pendingAfterEvents = new Map()
return fn()
}
return result this.pendingAfterEvents = new Map()
const prevRunCallbacks = this._runCallbacks
this._runCallbacks = runCallbacks ?? prevRunCallbacks
this._isInAtomicOp = true
try {
const result = fn()
this.flushAtomicCallbacks()
return result
} finally {
this.pendingAfterEvents = null
this._runCallbacks = prevRunCallbacks
this._isInAtomicOp = false
}
})
}
/** @internal */
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void) {
return this.historyAccumulator.addInterceptor((entry) =>
fn(entry, this.isMergingRemoteChanges ? 'remote' : 'user')
)
}
} }
/** /**
@ -949,21 +956,12 @@ function squashHistoryEntries<T extends UnknownRecord>(
) )
} }
/** @public */
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
for (const [from, to] of Object.values(diff.updated)) {
result.updated[from.id] = [to, from]
}
return result
}
class HistoryAccumulator<T extends UnknownRecord> { class HistoryAccumulator<T extends UnknownRecord> {
private _history: HistoryEntry<T>[] = [] private _history: HistoryEntry<T>[] = []
private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set() private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
intercepting(fn: (entry: HistoryEntry<T>) => void) { addInterceptor(fn: (entry: HistoryEntry<T>) => void) {
this._interceptors.add(fn) this._interceptors.add(fn)
return () => { return () => {
this._interceptors.delete(fn) this._interceptors.delete(fn)

View file

@ -12,8 +12,9 @@ import isEqual from 'lodash.isequal'
import { IdOf, UnknownRecord } from './BaseRecord' import { IdOf, UnknownRecord } from './BaseRecord'
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery' import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
import { IncrementalSetConstructor } from './IncrementalSetConstructor' import { IncrementalSetConstructor } from './IncrementalSetConstructor'
import { RecordsDiff } from './RecordsDiff'
import { diffSets } from './setUtils' import { diffSets } from './setUtils'
import { CollectionDiff, RecordsDiff } from './Store' import { CollectionDiff } from './Store'
export type RSIndexDiff< export type RSIndexDiff<
R extends UnknownRecord, R extends UnknownRecord,

View file

@ -329,4 +329,11 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
), ),
} }
} }
/** @internal */
getType(typeName: string) {
const type = getOwnProperty(this.types, typeName)
assert(type, 'record type does not exists')
return type
}
} }

View file

@ -1,8 +1,9 @@
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state' import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
import { BaseRecord, RecordId } from '../BaseRecord' import { BaseRecord, RecordId } from '../BaseRecord'
import { createMigrationSequence } from '../migrate' import { createMigrationSequence } from '../migrate'
import { RecordsDiff, reverseRecordsDiff } from '../RecordsDiff'
import { createRecordType } from '../RecordType' import { createRecordType } from '../RecordType'
import { CollectionDiff, RecordsDiff, Store } from '../Store' import { CollectionDiff, Store } from '../Store'
import { StoreSchema } from '../StoreSchema' import { StoreSchema } from '../StoreSchema'
interface Book extends BaseRecord<'book', RecordId<Book>> { interface Book extends BaseRecord<'book', RecordId<Book>> {
@ -881,3 +882,270 @@ describe('snapshots', () => {
expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42) expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
}) })
}) })
describe('diffs', () => {
let store: Store<LibraryType>
const authorId = Author.createId('tolkein')
const bookId = Book.createId('hobbit')
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<LibraryType>({
book: Book,
author: Author,
visit: Visit,
}),
})
})
it('produces diffs from `extractingChanges`', () => {
expect(
store.extractingChanges(() => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })])
store.put([
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
])
})
).toMatchInlineSnapshot(`
{
"added": {
"author:tolkein": {
"id": "author:tolkein",
"isPseudonym": false,
"name": "J.R.R Tolkein",
"typeName": "author",
},
"book:hobbit": {
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit",
"typeName": "book",
},
},
"removed": {},
"updated": {},
}
`)
expect(
store.extractingChanges(() => {
store.remove([authorId])
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
})
).toMatchInlineSnapshot(`
{
"added": {},
"removed": {
"author:tolkein": {
"id": "author:tolkein",
"isPseudonym": false,
"name": "J.R.R Tolkein",
"typeName": "author",
},
},
"updated": {
"book:hobbit": [
{
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit",
"typeName": "book",
},
{
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit: There and Back Again",
"typeName": "book",
},
],
},
}
`)
})
it('produces diffs from `addHistoryInterceptor`', () => {
const diffs: any[] = []
const interceptor = jest.fn((diff) => diffs.push(diff))
store.addHistoryInterceptor(interceptor)
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
])
expect(interceptor).toHaveBeenCalledTimes(1)
store.extractingChanges(() => {
store.remove([authorId])
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
})
expect(interceptor).toHaveBeenCalledTimes(3)
expect(diffs).toMatchInlineSnapshot(`
[
{
"changes": {
"added": {
"author:tolkein": {
"id": "author:tolkein",
"isPseudonym": false,
"name": "J.R.R Tolkein",
"typeName": "author",
},
"book:hobbit": {
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit",
"typeName": "book",
},
},
"removed": {},
"updated": {},
},
"source": "user",
},
{
"changes": {
"added": {},
"removed": {
"author:tolkein": {
"id": "author:tolkein",
"isPseudonym": false,
"name": "J.R.R Tolkein",
"typeName": "author",
},
},
"updated": {},
},
"source": "user",
},
{
"changes": {
"added": {},
"removed": {},
"updated": {
"book:hobbit": [
{
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit",
"typeName": "book",
},
{
"author": "author:tolkein",
"id": "book:hobbit",
"numPages": 300,
"title": "The Hobbit: There and Back Again",
"typeName": "book",
},
],
},
},
"source": "user",
},
]
`)
})
it('can apply and invert diffs', () => {
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
])
const checkpoint1 = store.getSnapshot()
const forwardsDiff = store.extractingChanges(() => {
store.remove([authorId])
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
})
const checkpoint2 = store.getSnapshot()
store.applyDiff(reverseRecordsDiff(forwardsDiff))
expect(store.getSnapshot()).toEqual(checkpoint1)
store.applyDiff(forwardsDiff)
expect(store.getSnapshot()).toEqual(checkpoint2)
})
})
describe('after callbacks', () => {
let store: Store<LibraryType>
let callbacks: any[] = []
const authorId = Author.createId('tolkein')
const bookId = Book.createId('hobbit')
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<LibraryType>({
book: Book,
author: Author,
visit: Visit,
}),
})
store.onAfterCreate = jest.fn((record) => callbacks.push({ type: 'create', record }))
store.onAfterChange = jest.fn((from, to) => callbacks.push({ type: 'change', from, to }))
store.onAfterDelete = jest.fn((record) => callbacks.push({ type: 'delete', record }))
callbacks = []
})
it('fires callbacks at the end of an `atomic` op', () => {
store.atomic(() => {
expect(callbacks).toHaveLength(0)
store.put([
Author.create({ name: 'J.R.R Tolkein', id: authorId }),
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
])
expect(callbacks).toHaveLength(0)
})
expect(callbacks).toMatchObject([
{ type: 'create', record: { id: authorId } },
{ type: 'create', record: { id: bookId } },
])
})
it('doesnt fire callback for a record created then deleted', () => {
store.atomic(() => {
store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })])
store.remove([authorId])
})
expect(callbacks).toHaveLength(0)
})
it('bails out if too many callbacks are fired', () => {
let limit = 10
store.onAfterCreate = (record) => {
if (record.typeName === 'book' && record.numPages < limit) {
store.put([{ ...record, numPages: record.numPages + 1 }])
}
}
store.onAfterChange = (from, to) => {
if (to.typeName === 'book' && to.numPages < limit) {
store.put([{ ...to, numPages: to.numPages + 1 }])
}
}
// this should be fine:
store.put([Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 0 })])
expect(store.get(bookId)!.numPages).toBe(limit)
// if we increase the limit thought, it should crash:
limit = 10000
store.clear()
expect(() => {
store.put([Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 0 })])
}).toThrowErrorMatchingInlineSnapshot(`"Maximum store update depth exceeded, bailing out"`)
})
})

View file

@ -1814,7 +1814,7 @@ export interface TLUiButtonPickerProps<T extends string> {
// (undocumented) // (undocumented)
items: StyleValuesForUi<T>; items: StyleValuesForUi<T>;
// (undocumented) // (undocumented)
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void; onValueChange: (style: StyleProp<T>, value: T) => void;
// (undocumented) // (undocumented)
style: StyleProp<T>; style: StyleProp<T>;
// (undocumented) // (undocumented)
@ -2206,6 +2206,8 @@ export interface TLUiInputProps {
// (undocumented) // (undocumented)
onComplete?: (value: string) => void; onComplete?: (value: string) => void;
// (undocumented) // (undocumented)
onFocus?: () => void;
// (undocumented)
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
// (undocumented) // (undocumented)
placeholder?: string; placeholder?: string;
@ -2332,7 +2334,7 @@ export interface TLUiSliderProps {
// (undocumented) // (undocumented)
label: string; label: string;
// (undocumented) // (undocumented)
onValueChange: (value: number, squashing: boolean) => void; onValueChange: (value: number) => void;
// (undocumented) // (undocumented)
steps: number; steps: number;
// (undocumented) // (undocumented)

View file

@ -115,7 +115,7 @@ export class Pointing extends StateNode {
if (startTerminal?.type === 'binding') { if (startTerminal?.type === 'binding') {
this.editor.setHintingShapes([startTerminal.boundShapeId]) this.editor.setHintingShapes([startTerminal.boundShapeId])
} }
this.editor.updateShapes([change], { squashing: true }) this.editor.updateShapes([change])
} }
// Cache the current shape after those changes // Cache the current shape after those changes
@ -152,7 +152,7 @@ export class Pointing extends StateNode {
if (endTerminal?.type === 'binding') { if (endTerminal?.type === 'binding') {
this.editor.setHintingShapes([endTerminal.boundShapeId]) this.editor.setHintingShapes([endTerminal.boundShapeId])
} }
this.editor.updateShapes([change], { squashing: true }) this.editor.updateShapes([change])
} }
} }
@ -168,7 +168,7 @@ export class Pointing extends StateNode {
}) })
if (change) { if (change) {
this.editor.updateShapes([change], { squashing: true }) this.editor.updateShapes([change])
} }
} }

View file

@ -370,9 +370,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], { this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial])
squashing: true,
})
} }
break break
} }
@ -433,7 +431,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], { squashing: true }) this.editor.updateShapes([shapePartial])
} }
break break
@ -574,7 +572,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], { squashing: true }) this.editor.updateShapes([shapePartial])
break break
} }
@ -621,7 +619,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], { squashing: true }) this.editor.updateShapes([shapePartial])
// Set a maximum length for the lines array; after 200 points, complete the line. // Set a maximum length for the lines array; after 200 points, complete the line.
if (newPoints.length > 500) { if (newPoints.length > 500) {

View file

@ -30,16 +30,13 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value.trim() const value = e.currentTarget.value.trim()
if (name === value) return if (name === value) return
editor.updateShapes( editor.updateShapes([
[ {
{ id,
id, type: 'frame',
type: 'frame', props: { name: value },
props: { name: value }, },
}, ])
],
{ squashing: true }
)
}, },
[id, editor] [id, editor]
) )
@ -53,16 +50,13 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value const value = e.currentTarget.value
if (name === value) return if (name === value) return
editor.updateShapes( editor.updateShapes([
[ {
{ id,
id, type: 'frame',
type: 'frame', props: { name: value },
props: { name: value }, },
}, ])
],
{ squashing: true }
)
}, },
[id, editor] [id, editor]
) )

View file

@ -25,7 +25,6 @@ describe(NoteShapeTool, () => {
editor.cancel() // leave edit mode editor.cancel() // leave edit mode
editor.undo() // undoes the selection change
editor.undo() editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0) expect(editor.getCurrentPageShapes().length).toBe(0)

View file

@ -5,10 +5,7 @@ export class Pointing extends StateNode {
override onEnter = () => { override onEnter = () => {
this.editor.stopCameraAnimation() this.editor.stopCameraAnimation()
this.editor.updateInstanceState( this.editor.setCursor({ type: 'grabbing', rotation: 0 })
{ cursor: { type: 'grabbing', rotation: 0 } },
{ ephemeral: true }
)
} }
override onLongPress: TLEventHandlers['onLongPress'] = () => { override onLongPress: TLEventHandlers['onLongPress'] = () => {

View file

@ -79,7 +79,7 @@ export class Brushing extends StateNode {
} }
override onCancel?: TLCancelEvent | undefined = (info) => { override onCancel?: TLCancelEvent | undefined = (info) => {
this.editor.setSelectedShapes(this.initialSelectedShapeIds, { squashing: true }) this.editor.setSelectedShapes(this.initialSelectedShapeIds)
this.parent.transition('idle', info) this.parent.transition('idle', info)
} }
@ -176,7 +176,7 @@ export class Brushing extends StateNode {
const current = editor.getSelectedShapeIds() const current = editor.getSelectedShapeIds()
if (current.length !== results.size || current.some((id) => !results.has(id))) { if (current.length !== results.size || current.some((id) => !results.has(id))) {
editor.setSelectedShapes(Array.from(results), { squashing: true }) editor.setSelectedShapes(Array.from(results))
} }
} }

View file

@ -6,17 +6,14 @@ export class Idle extends StateNode {
static override id = 'idle' static override id = 'idle'
override onEnter = () => { override onEnter = () => {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
const onlySelectedShape = this.editor.getOnlySelectedShape() const onlySelectedShape = this.editor.getOnlySelectedShape()
// well this fucking sucks. what the fuck. // well this fucking sucks. what the fuck.
// it's possible for a user to enter cropping, then undo // it's possible for a user to enter cropping, then undo
// (which clears the cropping id) but still remain in this state. // (which clears the cropping id) but still remain in this state.
this.editor.on('change-history', this.cleanupCroppingState) this.editor.on('tick', this.cleanupCroppingState)
if (onlySelectedShape) { if (onlySelectedShape) {
this.editor.mark('crop') this.editor.mark('crop')
@ -25,12 +22,9 @@ export class Idle extends StateNode {
} }
override onExit: TLExitEventHandler = () => { override onExit: TLExitEventHandler = () => {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.editor.off('change-history', this.cleanupCroppingState) this.editor.off('tick', this.cleanupCroppingState)
} }
override onCancel: TLEventHandlers['onCancel'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {

View file

@ -32,10 +32,7 @@ export class TranslatingCrop extends StateNode {
} }
override onExit = () => { override onExit = () => {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
override onPointerMove = () => { override onPointerMove = () => {
@ -102,7 +99,7 @@ export class TranslatingCrop extends StateNode {
const partial = getTranslateCroppedImageChange(this.editor, shape, delta) const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
if (partial) { if (partial) {
this.editor.updateShapes([partial], { squashing: true }) this.editor.updateShapes([partial])
} }
} }
} }

View file

@ -65,12 +65,7 @@ export class Cropping extends StateNode {
if (!selectedShape) return if (!selectedShape) return
const cursorType = CursorTypeMap[this.info.handle!] const cursorType = CursorTypeMap[this.info.handle!]
this.editor.updateInstanceState({ this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
cursor: {
type: cursorType,
rotation: this.editor.getSelectionRotation(),
},
})
} }
private getDefaultCrop = (): TLImageShapeCrop => ({ private getDefaultCrop = (): TLImageShapeCrop => ({
@ -201,7 +196,7 @@ export class Cropping extends StateNode {
}, },
} }
this.editor.updateShapes([partial], { squashing: true }) this.editor.updateShapes([partial])
this.updateCursor() this.updateCursor()
} }

View file

@ -82,10 +82,7 @@ export class DraggingHandle extends StateNode {
this.initialPageRotation = this.initialPageTransform.rotation() this.initialPageRotation = this.initialPageTransform.rotation()
this.initialPagePoint = this.editor.inputs.originPagePoint.clone() this.initialPagePoint = this.editor.inputs.originPagePoint.clone()
this.editor.updateInstanceState( this.editor.setCursor({ type: isCreating ? 'cross' : 'grabbing', rotation: 0 })
{ cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } },
{ ephemeral: true }
)
const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex) const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex)
const index = handles.findIndex((h) => h.id === info.handle.id) const index = handles.findIndex((h) => h.id === info.handle.id)
@ -196,10 +193,7 @@ export class DraggingHandle extends StateNode {
this.editor.setHintingShapes([]) this.editor.setHintingShapes([])
this.editor.snaps.clearIndicators() this.editor.snaps.clearIndicators()
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
private complete() { private complete() {
@ -312,7 +306,7 @@ export class DraggingHandle extends StateNode {
} }
if (changes) { if (changes) {
editor.updateShapes([next], { squashing: true }) editor.updateShapes([next])
} }
} }
} }

View file

@ -39,10 +39,7 @@ export class Idle extends StateNode {
override onEnter = () => { override onEnter = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
updateHoveredId(this.editor) updateHoveredId(this.editor)
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {

View file

@ -62,10 +62,7 @@ export class PointingArrowLabel extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
private _labelDragOffset = new Vec(0, 0) private _labelDragOffset = new Vec(0, 0)
@ -105,10 +102,11 @@ export class PointingArrowLabel extends StateNode {
} }
this.didDrag = true this.didDrag = true
this.editor.updateShape<TLArrowShape>( this.editor.updateShape<TLArrowShape>({
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } }, id: shape.id,
{ squashing: true } type: shape.type,
) props: { labelPosition: nextLabelPosition },
})
} }
override onPointerUp = () => { override onPointerUp = () => {

View file

@ -19,20 +19,12 @@ export class PointingCropHandle extends StateNode {
if (!selectedShape) return if (!selectedShape) return
const cursorType = CursorTypeMap[this.info.handle!] const cursorType = CursorTypeMap[this.info.handle!]
this.editor.updateInstanceState({ this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
cursor: {
type: cursorType,
rotation: this.editor.getSelectionRotation(),
},
})
this.editor.setCroppingShape(selectedShape.id) this.editor.setCroppingShape(selectedShape.id)
} }
override onExit = () => { override onExit = () => {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
} }

View file

@ -32,18 +32,12 @@ export class PointingHandle extends StateNode {
} }
} }
this.editor.updateInstanceState( this.editor.setCursor({ type: 'grabbing', rotation: 0 })
{ cursor: { type: 'grabbing', rotation: 0 } },
{ ephemeral: true }
)
} }
override onExit = () => { override onExit = () => {
this.editor.setHintingShapes([]) this.editor.setHintingShapes([])
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
override onPointerUp: TLEventHandlers['onPointerUp'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {

View file

@ -34,11 +34,9 @@ export class PointingResizeHandle extends StateNode {
private updateCursor() { private updateCursor() {
const selected = this.editor.getSelectedShapes() const selected = this.editor.getSelectedShapes()
const cursorType = CursorTypeMap[this.info.handle!] const cursorType = CursorTypeMap[this.info.handle!]
this.editor.updateInstanceState({ this.editor.setCursor({
cursor: { type: cursorType,
type: cursorType, rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
},
}) })
} }

View file

@ -11,11 +11,9 @@ export class PointingRotateHandle extends StateNode {
private info = {} as PointingRotateHandleInfo private info = {} as PointingRotateHandleInfo
private updateCursor() { private updateCursor() {
this.editor.updateInstanceState({ this.editor.setCursor({
cursor: { type: CursorTypeMap[this.info.handle as RotateCorner],
type: CursorTypeMap[this.info.handle as RotateCorner], rotation: this.editor.getSelectionRotation(),
rotation: this.editor.getSelectionRotation(),
},
}) })
} }
@ -27,10 +25,7 @@ export class PointingRotateHandle extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {

View file

@ -61,10 +61,7 @@ export class Resizing extends StateNode {
if (isCreating) { if (isCreating) {
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}` this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
this.editor.updateInstanceState( this.editor.setCursor({ type: 'cross', rotation: 0 })
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true }
)
} else { } else {
this.markId = 'starting resizing' this.markId = 'starting resizing'
this.editor.mark(this.markId) this.editor.mark(this.markId)
@ -407,10 +404,7 @@ export class Resizing extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.editor.snaps.clearIndicators() this.editor.snaps.clearIndicators()
} }

View file

@ -51,11 +51,9 @@ export class Rotating extends StateNode {
}) })
// Update cursor // Update cursor
this.editor.updateInstanceState({ this.editor.setCursor({
cursor: { type: CursorTypeMap[this.info.handle as RotateCorner],
type: CursorTypeMap[this.info.handle as RotateCorner], rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
},
}) })
} }
@ -105,11 +103,9 @@ export class Rotating extends StateNode {
}) })
// Update cursor // Update cursor
this.editor.updateInstanceState({ this.editor.setCursor({
cursor: { type: CursorTypeMap[this.info.handle as RotateCorner],
type: CursorTypeMap[this.info.handle as RotateCorner], rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
},
}) })
} }

View file

@ -164,7 +164,7 @@ export class ScribbleBrushing extends StateNode {
shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds] shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
) )
if (current.length !== next.size || current.some((id) => !next.has(id))) { if (current.length !== next.size || current.some((id) => !next.has(id))) {
this.editor.setSelectedShapes(Array.from(next), { squashing: true }) this.editor.setSelectedShapes(Array.from(next))
} }
} }
@ -174,7 +174,7 @@ export class ScribbleBrushing extends StateNode {
} }
private cancel() { private cancel() {
this.editor.setSelectedShapes([...this.initialSelectedShapeIds], { squashing: true }) this.editor.setSelectedShapes([...this.initialSelectedShapeIds])
this.parent.transition('idle') this.parent.transition('idle')
} }
} }

View file

@ -505,7 +505,6 @@ export function moveShapesToPoint({
y: newLocalPoint.y, y: newLocalPoint.y,
} }
}) })
), )
{ squashing: true }
) )
} }

View file

@ -19,10 +19,7 @@ export class ZoomTool extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
this.editor.updateInstanceState( this.editor.updateInstanceState({ zoomBrush: null, cursor: { type: 'default', rotation: 0 } })
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
} }
@ -53,15 +50,9 @@ export class ZoomTool extends StateNode {
private updateCursor() { private updateCursor() {
if (this.editor.inputs.altKey) { if (this.editor.inputs.altKey) {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'zoom-out', rotation: 0 })
{ cursor: { type: 'zoom-out', rotation: 0 } },
{ ephemeral: true }
)
} else { } else {
this.editor.updateInstanceState( this.editor.setCursor({ type: 'zoom-in', rotation: 0 })
{ cursor: { type: 'zoom-in', rotation: 0 } },
{ ephemeral: true }
)
} }
} }
} }

View file

@ -37,7 +37,7 @@ export function MobileStylePanel() {
const handleStylesOpenChange = useCallback( const handleStylesOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
if (!isOpen) { if (!isOpen) {
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: false })
} }
}, },
[editor] [editor]

View file

@ -15,17 +15,13 @@ export const PageItemInput = function PageItemInput({
const rInput = useRef<HTMLInputElement | null>(null) const rInput = useRef<HTMLInputElement | null>(null)
const handleFocus = useCallback(() => {
editor.mark('rename page')
}, [editor])
const handleChange = useCallback( const handleChange = useCallback(
(value: string) => { (value: string) => {
editor.renamePage(id, value ? value : 'New Page', { ephemeral: true }) editor.renamePage(id, value || 'New Page')
},
[editor, id]
)
const handleComplete = useCallback(
(value: string) => {
editor.mark('rename page')
editor.renamePage(id, value || 'New Page', { ephemeral: false })
}, },
[editor, id] [editor, id]
) )
@ -36,8 +32,7 @@ export const PageItemInput = function PageItemInput({
ref={(el) => (rInput.current = el)} ref={(el) => (rInput.current = el)}
defaultValue={name} defaultValue={name}
onValueChange={handleChange} onValueChange={handleChange}
onComplete={handleComplete} onFocus={handleFocus}
onCancel={handleComplete}
shouldManuallyMaintainScrollPositionWhenFocused shouldManuallyMaintainScrollPositionWhenFocused
autofocus={isCurrentPage} autofocus={isCurrentPage}
autoselect autoselect

View file

@ -21,7 +21,7 @@ export const DefaultStylePanel = memo(function DefaultStylePanel({
const handlePointerOut = useCallback(() => { const handlePointerOut = useCallback(() => {
if (!isMobile) { if (!isMobile) {
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: false })
} }
}, [editor, isMobile]) }, [editor, isMobile])

View file

@ -78,13 +78,13 @@ function useStyleChangeCallback() {
return React.useMemo( return React.useMemo(
() => () =>
function handleStyleChange<T>(style: StyleProp<T>, value: T, squashing: boolean) { function handleStyleChange<T>(style: StyleProp<T>, value: T) {
editor.batch(() => { editor.batch(() => {
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(style, value, { squashing }) editor.setStyleForSelectedShapes(style, value)
} }
editor.setStyleForNextShapes(style, value, { squashing }) editor.setStyleForNextShapes(style, value)
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: true })
}) })
trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string }) trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string })
@ -165,8 +165,8 @@ export function CommonStylePickerSet({
style={DefaultSizeStyle} style={DefaultSizeStyle}
items={STYLES.size} items={STYLES.size}
value={size} value={size}
onValueChange={(style, value, squashing) => { onValueChange={(style, value) => {
handleValueChange(style, value, squashing) handleValueChange(style, value)
const selectedShapeIds = editor.getSelectedShapeIds() const selectedShapeIds = editor.getSelectedShapeIds()
if (selectedShapeIds.length > 0) { if (selectedShapeIds.length > 0) {
kickoutOccludedShapes(editor, selectedShapeIds) kickoutOccludedShapes(editor, selectedShapeIds)
@ -333,14 +333,14 @@ export function OpacitySlider() {
const msg = useTranslation() const msg = useTranslation()
const handleOpacityValueChange = React.useCallback( const handleOpacityValueChange = React.useCallback(
(value: number, squashing: boolean) => { (value: number) => {
const item = tldrawSupportedOpacities[value] const item = tldrawSupportedOpacities[value]
editor.batch(() => { editor.batch(() => {
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setOpacityForSelectedShapes(item, { squashing }) editor.setOpacityForSelectedShapes(item)
} }
editor.setOpacityForNextShapes(item, { squashing }) editor.setOpacityForNextShapes(item)
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: true })
}) })
trackEvent('set-style', { source: 'style-panel', id: 'opacity', value }) trackEvent('set-style', { source: 'style-panel', id: 'opacity', value })

View file

@ -24,7 +24,7 @@ interface DoubleDropdownPickerProps<T extends string> {
styleB: StyleProp<T> styleB: StyleProp<T>
valueA: SharedStyle<T> valueA: SharedStyle<T>
valueB: SharedStyle<T> valueB: SharedStyle<T>
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T) => void
} }
function _DoubleDropdownPicker<T extends string>({ function _DoubleDropdownPicker<T extends string>({
@ -88,7 +88,7 @@ function _DoubleDropdownPicker<T extends string>({
<TldrawUiButton <TldrawUiButton
type="icon" type="icon"
key={item.value} key={item.value}
onClick={() => onValueChange(styleA, item.value, false)} onClick={() => onValueChange(styleA, item.value)}
title={`${msg(labelA)}${msg(`${uiTypeA}-style.${item.value}`)}`} title={`${msg(labelA)}${msg(`${uiTypeA}-style.${item.value}`)}`}
> >
<TldrawUiButtonIcon icon={item.icon} invertIcon /> <TldrawUiButtonIcon icon={item.icon} invertIcon />
@ -124,7 +124,7 @@ function _DoubleDropdownPicker<T extends string>({
type="icon" type="icon"
title={`${msg(labelB)}${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`} title={`${msg(labelB)}${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`}
data-testid={`style.${uiTypeB}.${item.value}`} data-testid={`style.${uiTypeB}.${item.value}`}
onClick={() => onValueChange(styleB, item.value, false)} onClick={() => onValueChange(styleB, item.value)}
> >
<TldrawUiButtonIcon icon={item.icon} /> <TldrawUiButtonIcon icon={item.icon} />
</TldrawUiButton> </TldrawUiButton>

View file

@ -22,7 +22,7 @@ interface DropdownPickerProps<T extends string> {
value: SharedStyle<T> value: SharedStyle<T>
items: StyleValuesForUi<T> items: StyleValuesForUi<T>
type: TLUiButtonProps['type'] type: TLUiButtonProps['type']
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T) => void
} }
function _DropdownPicker<T extends string>({ function _DropdownPicker<T extends string>({
@ -68,7 +68,7 @@ function _DropdownPicker<T extends string>({
title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)} title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)}
onClick={() => { onClick={() => {
editor.mark('select style dropdown item') editor.mark('select style dropdown item')
onValueChange(style, item.value, false) onValueChange(style, item.value)
}} }}
> >
<TldrawUiButtonIcon icon={item.icon} /> <TldrawUiButtonIcon icon={item.icon} />

View file

@ -22,7 +22,7 @@ export interface TLUiButtonPickerProps<T extends string> {
value: SharedStyle<T> value: SharedStyle<T>
items: StyleValuesForUi<T> items: StyleValuesForUi<T>
theme: TLDefaultColorTheme theme: TLDefaultColorTheme
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T) => void
} }
function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) { function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
@ -57,14 +57,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
if (value.type === 'shared' && value.value === id) return if (value.type === 'shared' && value.value === id) return
editor.mark('point picker item') editor.mark('point picker item')
onValueChange(style, id as T, false) onValueChange(style, id as T)
} }
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
const { id } = e.currentTarget.dataset const { id } = e.currentTarget.dataset
editor.mark('point picker item') editor.mark('point picker item')
onValueChange(style, id as T, true) onValueChange(style, id as T)
rPointing.current = true rPointing.current = true
window.addEventListener('pointerup', handlePointerUp) // see TLD-658 window.addEventListener('pointerup', handlePointerUp) // see TLD-658
@ -74,14 +74,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
if (!rPointing.current) return if (!rPointing.current) return
const { id } = e.currentTarget.dataset const { id } = e.currentTarget.dataset
onValueChange(style, id as T, true) onValueChange(style, id as T)
} }
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => { const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
const { id } = e.currentTarget.dataset const { id } = e.currentTarget.dataset
if (value.type === 'shared' && value.value === id) return if (value.type === 'shared' && value.value === id) return
onValueChange(style, id as T, false) onValueChange(style, id as T)
} }
return { return {

View file

@ -21,6 +21,7 @@ export interface TLUiInputProps {
onValueChange?: (value: string) => void onValueChange?: (value: string) => void
onCancel?: (value: string) => void onCancel?: (value: string) => void
onBlur?: (value: string) => void onBlur?: (value: string) => void
onFocus?: () => void
className?: string className?: string
/** /**
* Usually on iOS when you focus an input, the browser will adjust the viewport to bring the input * Usually on iOS when you focus an input, the browser will adjust the viewport to bring the input
@ -49,6 +50,7 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
onComplete, onComplete,
onValueChange, onValueChange,
onCancel, onCancel,
onFocus,
onBlur, onBlur,
shouldManuallyMaintainScrollPositionWhenFocused = false, shouldManuallyMaintainScrollPositionWhenFocused = false,
children, children,
@ -77,8 +79,9 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
elm.select() elm.select()
} }
}) })
onFocus?.()
}, },
[autoselect] [autoselect, onFocus]
) )
const handleChange = React.useCallback( const handleChange = React.useCallback(

View file

@ -10,7 +10,7 @@ export interface TLUiSliderProps {
value: number | null value: number | null
label: string label: string
title: string title: string
onValueChange: (value: number, squashing: boolean) => void onValueChange: (value: number) => void
'data-testid'?: string 'data-testid'?: string
} }
@ -22,7 +22,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
const handleValueChange = useCallback( const handleValueChange = useCallback(
(value: number[]) => { (value: number[]) => {
onValueChange(value[0], true) onValueChange(value[0])
}, },
[onValueChange] [onValueChange]
) )
@ -33,7 +33,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
if (!value) return if (!value) return
onValueChange(value, false) onValueChange(value)
}, [value, onValueChange]) }, [value, onValueChange])
return ( return (

View file

@ -1164,12 +1164,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true, readonlyOk: true,
onSelect(source) { onSelect(source) {
trackEvent('toggle-transparent', { source }) trackEvent('toggle-transparent', { source })
editor.updateInstanceState( editor.updateInstanceState({
{ exportBackground: !editor.getInstanceState().exportBackground,
exportBackground: !editor.getInstanceState().exportBackground, })
},
{ ephemeral: true }
)
}, },
checkbox: true, checkbox: true,
}, },
@ -1326,10 +1323,10 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.batch(() => { editor.batch(() => {
editor.mark('change-color') editor.mark('change-color')
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(style, 'white', { squashing: false }) editor.setStyleForSelectedShapes(style, 'white')
} }
editor.setStyleForNextShapes(style, 'white', { squashing: false }) editor.setStyleForNextShapes(style, 'white')
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: true })
}) })
trackEvent('set-style', { source, id: style.id, value: 'white' }) trackEvent('set-style', { source, id: style.id, value: 'white' })
}, },

View file

@ -24,9 +24,9 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
seletionBoundsBefore?.collides(selectedBoundsAfter) seletionBoundsBefore?.collides(selectedBoundsAfter)
) { ) {
// Creates a 'puff' to show a paste has happened. // Creates a 'puff' to show a paste has happened.
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: true })
setTimeout(() => { setTimeout(() => {
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true }) editor.updateInstanceState({ isChangingStyle: false })
}, 150) }, 150)
} }
} }

View file

@ -134,7 +134,7 @@ export function usePrint() {
} }
const afterPrintHandler = () => { const afterPrintHandler = () => {
editor.once('change-history', () => { editor.once('tick', () => {
clearElements(el, style) clearElements(el, style)
}) })
} }

View file

@ -102,15 +102,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
icon: ('geo-' + id) as TLUiIconType, icon: ('geo-' + id) as TLUiIconType,
onSelect(source: TLUiEventSource) { onSelect(source: TLUiEventSource) {
editor.batch(() => { editor.batch(() => {
editor.updateInstanceState( editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
{
stylesForNextShape: {
...editor.getInstanceState().stylesForNextShape,
[GeoShapeGeoStyle.id]: id,
},
},
{ ephemeral: true }
)
editor.setCurrentTool('geo') editor.setCurrentTool('geo')
trackEvent('select-tool', { source, id: `geo-${id}` }) trackEvent('select-tool', { source, id: `geo-${id}` })
}) })

View file

@ -179,10 +179,7 @@ describe('<TldrawEditor />', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { await act(async () => {
editor.updateInstanceState( editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
}) })
const id = createShapeId() const id = createShapeId()
@ -299,10 +296,7 @@ describe('Custom shapes', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { await act(async () => {
editor.updateInstanceState( editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
}) })
expect(editor.shapeUtils.card).toBeTruthy() expect(editor.shapeUtils.card).toBeTruthy()

View file

@ -23,7 +23,7 @@ describe('when less than two shapes are selected', () => {
editor.setSelectedShapes([ids.boxB]) editor.setSelectedShapes([ids.boxB])
const fn = jest.fn() const fn = jest.fn()
editor.on('update', fn) editor.store.listen(fn)
editor.alignShapes(editor.getSelectedShapeIds(), 'top') editor.alignShapes(editor.getSelectedShapeIds(), 'top')
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled() expect(fn).not.toHaveBeenCalled()

View file

@ -46,7 +46,7 @@ describe('distributeShapes command', () => {
it('does nothing', () => { it('does nothing', () => {
editor.setSelectedShapes([ids.boxA, ids.boxB]) editor.setSelectedShapes([ids.boxA, ids.boxB])
const fn = jest.fn() const fn = jest.fn()
editor.on('change-history', fn) editor.store.listen(fn)
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal') editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled() expect(fn).not.toHaveBeenCalled()

View file

@ -60,8 +60,9 @@ describe('Editor.moveShapesToPage', () => {
it('Adds undo items', () => { it('Adds undo items', () => {
editor.history.clear() editor.history.clear()
expect(editor.history.getNumUndos()).toBe(0)
editor.moveShapesToPage([ids.box1], ids.page2) editor.moveShapesToPage([ids.box1], ids.page2)
expect(editor.history.getNumUndos()).toBeGreaterThan(1) expect(editor.history.getNumUndos()).toBe(1)
}) })
it('Does nothing on an empty ids array', () => { it('Does nothing on an empty ids array', () => {

View file

@ -1,13 +0,0 @@
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('squashing', () => {
editor
it.todo('squashes')
})

View file

@ -52,7 +52,7 @@ describe('distributeShapes command', () => {
it('does nothing', () => { it('does nothing', () => {
editor.setSelectedShapes([ids.boxA, ids.boxB]) editor.setSelectedShapes([ids.boxA, ids.boxB])
const fn = jest.fn() const fn = jest.fn()
editor.on('change-history', fn) editor.store.listen(fn)
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0) editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled() expect(fn).not.toHaveBeenCalled()

View file

@ -27,7 +27,7 @@ describe('when less than two shapes are selected', () => {
it('does nothing', () => { it('does nothing', () => {
editor.setSelectedShapes([ids.boxB]) editor.setSelectedShapes([ids.boxB])
const fn = jest.fn() const fn = jest.fn()
editor.on('change-history', fn) editor.store.listen(fn)
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal') editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)

View file

@ -0,0 +1,129 @@
/* eslint-disable no-console */
import { HistoryManager, RecordsDiff } from '@tldraw/editor'
// eslint-disable-next-line import/no-extraneous-dependencies
import { DiffOptions, diff as jestDiff } from 'jest-diff'
import { inspect } from 'util'
class Printer {
private _output = ''
private _indent = 0
appendLines(str: string) {
const indent = ' '.repeat(this._indent)
this._output +=
str
.split('\n')
.map((line) => indent + line)
.join('\n') + '\n'
}
indent() {
this._indent++
}
dedent() {
this._indent--
}
log(...args: any[]) {
this.appendLines(args.map((arg) => (typeof arg === 'string' ? arg : inspect(arg))).join(' '))
}
print() {
console.log(this._output)
}
get() {
return this._output
}
}
export function prettyPrintDiff(diff: RecordsDiff<any>, opts?: DiffOptions) {
const before = {} as Record<string, any>
const after = {} as Record<string, any>
for (const added of Object.values(diff.added)) {
after[added.id] = added
}
for (const [from, to] of Object.values(diff.updated)) {
before[from.id] = from
after[to.id] = to
}
for (const removed of Object.values(diff.removed)) {
before[removed.id] = removed
}
const prettyDiff = jestDiff(after, before, {
aAnnotation: 'After',
bAnnotation: 'Before',
aIndicator: '+',
bIndicator: '-',
...opts,
})
if (prettyDiff?.includes('Compared values have no visual difference.')) {
const p = new Printer()
p.log('Before & after have no visual difference.')
p.log('Diff:')
p.indent()
p.log(diff)
return p.get()
}
return prettyDiff
}
export function logHistory(history: HistoryManager<any>) {
const { undos, redos, pendingDiff } = history.debug()
const p = new Printer()
p.log('=== History ===')
p.indent()
p.log('Pending diff:')
p.indent()
if (pendingDiff.isEmpty) {
p.log('(empty)')
} else {
p.log(prettyPrintDiff(pendingDiff.diff))
}
p.log('')
p.dedent()
p.log('Undos:')
p.indent()
if (undos.length === 0) {
p.log('(empty)\n')
}
for (const undo of undos) {
if (!undo) continue
if (undo.type === 'stop') {
p.log('Stop', undo.id)
} else {
p.log('- Diff')
p.indent()
p.log(prettyPrintDiff(undo.diff))
p.dedent()
}
p.log('')
}
p.dedent()
p.log('Redos:')
p.indent()
if (redos.length === 0) {
p.log('(empty)\n')
}
for (const redo of redos) {
if (!redo) continue
if (redo.type === 'stop') {
p.log('> Stop', redo.id)
} else {
p.log('- Diff')
p.indent()
p.log(prettyPrintDiff(redo.diff))
p.dedent()
}
p.log('')
}
p.print()
}

View file

@ -27,7 +27,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
currentPageId: TLPageId currentPageId: TLPageId
opacityForNextShape: TLOpacityType opacityForNextShape: TLOpacityType
stylesForNextShape: Record<string, unknown> stylesForNextShape: Record<string, unknown>
// ephemeral
followingUserId: string | null followingUserId: string | null
highlightedUserIds: string[] highlightedUserIds: string[]
brush: BoxModel | null brush: BoxModel | null
@ -129,6 +128,38 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
return createRecordType<TLInstance>('instance', { return createRecordType<TLInstance>('instance', {
validator: instanceTypeValidator, validator: instanceTypeValidator,
scope: 'session', scope: 'session',
ephemeralKeys: {
currentPageId: false,
meta: false,
followingUserId: true,
opacityForNextShape: true,
stylesForNextShape: true,
brush: true,
cursor: true,
scribbles: true,
isFocusMode: true,
isDebugMode: true,
isToolLocked: true,
exportBackground: true,
screenBounds: true,
insets: true,
zoomBrush: true,
isPenMode: true,
isGridMode: true,
chatMessage: true,
isChatting: true,
highlightedUserIds: true,
canMoveCamera: true,
isFocused: true,
devicePixelRatio: true,
isCoarsePointer: true,
isHoveringCanvas: true,
openMenus: true,
isChangingStyle: true,
isReadonly: true,
duplicateProps: true,
},
}).withDefaultProperties( }).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({ (): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
followingUserId: null, followingUserId: null,

View file

@ -138,6 +138,18 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
{ {
validator: instancePageStateValidator, validator: instancePageStateValidator,
scope: 'session', scope: 'session',
ephemeralKeys: {
pageId: false,
selectedShapeIds: false,
editingShapeId: false,
croppingShapeId: false,
meta: false,
hintingShapeIds: true,
erasingShapeIds: true,
hoveredShapeId: true,
focusedGroupId: true,
},
} }
).withDefaultProperties( ).withDefaultProperties(
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({ (): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({

View file

@ -272,7 +272,9 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
this.lastServerClock = 0 this.lastServerClock = 0
} }
// kill all presence state // kill all presence state
this.store.remove(Object.keys(this.store.serialize('presence')) as any) this.store.mergeRemoteChanges(() => {
this.store.remove(Object.keys(this.store.serialize('presence')) as any)
})
this.lastPushedPresenceState = null this.lastPushedPresenceState = null
this.isConnectedToRoom = false this.isConnectedToRoom = false
this.pendingPushRequests = [] this.pendingPushRequests = []
@ -321,7 +323,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
const wipeAll = event.hydrationType === 'wipe_all' const wipeAll = event.hydrationType === 'wipe_all'
if (!wipeAll) { if (!wipeAll) {
// if we're only wiping presence data, undo the speculative changes first // if we're only wiping presence data, undo the speculative changes first
this.store.applyDiff(reverseRecordsDiff(stashedChanges), false) this.store.applyDiff(reverseRecordsDiff(stashedChanges), { runCallbacks: false })
} }
// now wipe all presence data and, if needed, all document data // now wipe all presence data and, if needed, all document data
@ -336,12 +338,22 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
// then apply the upstream changes // then apply the upstream changes
this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true) this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true)
this.isConnectedToRoom = true
// now re-apply the speculative changes creating a new push request with the
// appropriate diff
const speculativeChanges = this.store.filterChangesByScope(
this.store.extractingChanges(() => {
this.store.applyDiff(stashedChanges)
}),
'document'
)
if (speculativeChanges) this.push(speculativeChanges)
}) })
// now re-apply the speculative changes as a 'user' to trigger // this.isConnectedToRoom = true
// creating a new push request with the appropriate diff // this.store.applyDiff(stashedChanges, false)
this.isConnectedToRoom = true
this.store.applyDiff(stashedChanges)
this.store.ensureStoreIsUsable() this.store.ensureStoreIsUsable()
// TODO: reinstate isNew // TODO: reinstate isNew
@ -525,7 +537,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
} }
} }
if (hasChanges) { if (hasChanges) {
this.store.applyDiff(changes, runCallbacks) this.store.applyDiff(changes, { runCallbacks })
} }
} }
@ -541,7 +553,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
try { try {
this.store.mergeRemoteChanges(() => { this.store.mergeRemoteChanges(() => {
// first undo speculative changes // first undo speculative changes
this.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), false) this.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), { runCallbacks: false })
// then apply network diffs on top of known-to-be-synced data // then apply network diffs on top of known-to-be-synced data
for (const diff of diffs) { for (const diff of diffs) {

View file

@ -1,4 +1,3 @@
import isEqual from 'lodash.isequal'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { import {
Editor, Editor,
@ -8,7 +7,9 @@ import {
computed, computed,
createPresenceStateDerivation, createPresenceStateDerivation,
createTLStore, createTLStore,
isRecordsDiffEmpty,
} from 'tldraw' } from 'tldraw'
import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty'
import { TLSyncClient } from '../lib/TLSyncClient' import { TLSyncClient } from '../lib/TLSyncClient'
import { schema } from '../lib/schema' import { schema } from '../lib/schema'
import { FuzzEditor, Op } from './FuzzEditor' import { FuzzEditor, Op } from './FuzzEditor'
@ -74,8 +75,8 @@ class FuzzTestInstance extends RandomSource {
) { ) {
super(seed) super(seed)
this.store = createTLStore({ schema })
this.id = nanoid() this.id = nanoid()
this.store = createTLStore({ schema, id: this.id })
this.socketPair = new TestSocketPair(this.id, server) this.socketPair = new TestSocketPair(this.id, server)
this.client = new TLSyncClient<TLRecord>({ this.client = new TLSyncClient<TLRecord>({
store: this.store, store: this.store,
@ -105,6 +106,13 @@ class FuzzTestInstance extends RandomSource {
} }
} }
function assertPeerStoreIsUsable(peer: FuzzTestInstance) {
const diffToEnsureUsable = peer.store.extractingChanges(() => peer.store.ensureStoreIsUsable())
if (!isRecordsDiffEmpty(diffToEnsureUsable)) {
throw new Error(`store of ${peer.id} was not usable\n${prettyPrintDiff(diffToEnsureUsable)}`)
}
}
let totalNumShapes = 0 let totalNumShapes = 0
let totalNumPages = 0 let totalNumPages = 0
@ -173,6 +181,7 @@ function runTest(seed: number) {
allOk('before applyOp') allOk('before applyOp')
peer.editor.applyOp(op) peer.editor.applyOp(op)
assertPeerStoreIsUsable(peer)
allOk('after applyOp') allOk('after applyOp')
server.flushDebouncingMessages() server.flushDebouncingMessages()
@ -210,6 +219,7 @@ function runTest(seed: number) {
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
peer.socketPair.connect() peer.socketPair.connect()
allOk('final connect') allOk('final connect')
assertPeerStoreIsUsable(peer)
} }
} }
} }
@ -223,33 +233,29 @@ function runTest(seed: number) {
allOk('final flushServer') allOk('final flushServer')
peer.socketPair.flushClientSentEvents() peer.socketPair.flushClientSentEvents()
allOk('final flushClient') allOk('final flushClient')
assertPeerStoreIsUsable(peer)
} }
} }
} }
const equalityResults = [] // peers should all be usable without changes:
for (let i = 0; i < peers.length; i++) { for (const peer of peers) {
const row = [] assertPeerStoreIsUsable(peer)
for (let j = 0; j < peers.length; j++) {
row.push(
isEqual(
peers[i].editor?.store.serialize('document'),
peers[j].editor?.store.serialize('document')
)
)
}
equalityResults.push(row)
} }
const [first, ...rest] = peers.map((peer) => peer.editor?.store.serialize('document')) // all stores should be the same
for (let i = 1; i < peers.length; i++) {
const expected = peers[i - 1]
const actual = peers[i]
try {
expect(actual.store.serialize('document')).toEqual(expected.store.serialize('document'))
} catch (e: any) {
throw new Error(`received = ${actual.id}, expected = ${expected.id}\n${e.message}`)
}
}
// writeFileSync(`./test-results.${seed}.json`, JSON.stringify(ops, null, '\t')) totalNumPages += peers[0].store.query.ids('page').get().size
totalNumShapes += peers[0].store.query.ids('shape').get().size
expect(first).toEqual(rest[0])
// all snapshots should be the same
expect(rest.every((other) => isEqual(other, first))).toBe(true)
totalNumPages += Object.values(first!).filter((v) => v.typeName === 'page').length
totalNumShapes += Object.values(first!).filter((v) => v.typeName === 'shape').length
} catch (e) { } catch (e) {
console.error('seed', seed) console.error('seed', seed)
console.error( console.error(
@ -269,21 +275,25 @@ const NUM_TESTS = 50
const NUM_OPS_PER_TEST = 100 const NUM_OPS_PER_TEST = 100
const MAX_PEERS = 4 const MAX_PEERS = 4
// test.only('seed 8343632005032947', () => { test('seed 8360926944486245 - undo/redo page integrity regression', () => {
// runTest(8343632005032947) runTest(8360926944486245)
// })
test('fuzzzzz', () => {
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
try {
runTest(seed)
} catch (e) {
console.error('seed', seed)
throw e
}
}
}) })
test('seed 3467175630814895 - undo/redo page integrity regression', () => {
runTest(3467175630814895)
})
test('seed 6820615056006575 - undo/redo page integrity regression', () => {
runTest(6820615056006575)
})
test('seed 5279266392988747 - undo/redo page integrity regression', () => {
runTest(5279266392988747)
})
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
test(`seed ${seed}`, () => {
runTest(seed)
})
}
test('totalNumPages', () => { test('totalNumPages', () => {
expect(totalNumPages).not.toBe(0) expect(totalNumPages).not.toBe(0)