From 5cb08711c19c086a013b3a52b06b7cdcfd443fe5 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 20 Jun 2023 14:31:26 +0100 Subject: [PATCH] Incorporate signia as @tldraw/state (#1620) It tried to get out but we're dragging it back in. This PR brings [signia](https://github.com/tldraw/signia) back into tldraw as @tldraw/state. ### Change Type - [x] major --------- Co-authored-by: David Sheldrick --- apps/docs/content/docs/editor.mdx | 8 +- apps/docs/content/docs/installation.mdx | 10 +- apps/examples/package.json | 3 +- .../src/16-custom-styles/FilterStyleUi.tsx | 2 +- .../src/4-custom-ui/CustomUiExample.tsx | 2 +- apps/examples/src/yjs/YjsExample.tsx | 2 +- apps/examples/src/yjs/useYjsStore.ts | 2 +- packages/editor/api-report.md | 35 +- packages/editor/package.json | 5 +- packages/editor/src/index.ts | 13 +- packages/editor/src/lib/components/Canvas.tsx | 4 +- .../lib/components/DefaultErrorFallback.tsx | 2 +- .../src/lib/components/LiveCollaborators.tsx | 2 +- .../editor/src/lib/components/SelectionBg.tsx | 2 +- .../editor/src/lib/components/SelectionFg.tsx | 2 +- packages/editor/src/lib/components/Shape.tsx | 7 +- .../src/lib/components/ShapeIndicator.tsx | 6 +- .../lib/config/TLSessionStateSnapshot.test.ts | 2 +- .../src/lib/config/TLSessionStateSnapshot.ts | 2 +- .../src/lib/config/TLUserPreferences.test.ts | 2 +- .../src/lib/config/TLUserPreferences.ts | 2 +- .../editor/src/lib/config/createTLUser.ts | 2 +- packages/editor/src/lib/editor/Editor.ts | 2 +- .../editor/derivations/arrowBindingsIndex.ts | 2 +- .../parentsToChildrenWithIndexes.ts | 2 +- .../derivations/shapeIdsInCurrentPage.ts | 2 +- .../src/lib/editor/managers/HistoryManager.ts | 2 +- .../src/lib/editor/managers/SnapManager.ts | 2 +- .../editor/src/lib/editor/managers/Stack.ts | 2 +- .../editor/shapes/arrow/ArrowShapeUtil.tsx | 2 +- .../editor/shapes/embed/EmbedShapeUtil.tsx | 2 +- .../editor/shapes/image/ImageShapeUtil.tsx | 2 +- .../lib/editor/shapes/shared/ShapeFill.tsx | 2 +- .../editor/shapes/shared/useEditableText.ts | 2 +- .../lib/editor/shapes/shared/useForceSolid.ts | 2 +- .../editor/shapes/video/VideoShapeUtil.tsx | 2 +- .../editor/src/lib/editor/tools/StateNode.ts | 2 +- packages/editor/src/lib/hooks/useCursor.ts | 2 +- packages/editor/src/lib/hooks/useDarkMode.ts | 2 +- .../editor/src/lib/hooks/useDocumentEvents.ts | 2 +- .../editor/src/lib/hooks/useIsCropping.ts | 2 +- packages/editor/src/lib/hooks/useIsEditing.ts | 2 +- packages/editor/src/lib/hooks/usePeerIds.ts | 2 +- packages/editor/src/lib/hooks/usePresence.ts | 2 +- packages/editor/src/lib/hooks/useZoomCss.ts | 2 +- packages/editor/src/lib/utils/debug-flags.ts | 2 +- .../src/lib/utils/sync/TLLocalSyncClient.ts | 2 +- packages/editor/tsconfig.json | 3 +- packages/state/CHANGELOG.md | 167 +++++ packages/state/README.md | 3 + packages/state/api-extractor.json | 4 + packages/state/api-report.md | 170 +++++ packages/state/docs-ordering.json | 5 + packages/state/package.json | 71 ++ packages/state/src/index.ts | 3 + packages/state/src/lib/core/ArraySet.ts | 146 +++++ packages/state/src/lib/core/Atom.ts | 196 ++++++ packages/state/src/lib/core/Computed.ts | 378 +++++++++++ .../state/src/lib/core/EffectScheduler.ts | 268 ++++++++ packages/state/src/lib/core/HistoryBuffer.ts | 95 +++ .../core/__tests__/EffectScheduler.test.ts | 82 +++ .../lib/core/__tests__/HistoryBuffer.test.ts | 56 ++ .../src/lib/core/__tests__/arraySet.test.ts | 127 ++++ .../state/src/lib/core/__tests__/atom.test.ts | 199 ++++++ .../src/lib/core/__tests__/capture.test.ts | 238 +++++++ .../src/lib/core/__tests__/computed.test.ts | 610 ++++++++++++++++++ .../lib/core/__tests__/fuzz.tlstate.test.ts | 370 +++++++++++ .../src/lib/core/__tests__/reactor.test.ts | 196 ++++++ .../lib/core/__tests__/transactions.test.ts | 202 ++++++ packages/state/src/lib/core/capture.ts | 179 +++++ packages/state/src/lib/core/constants.ts | 2 + packages/state/src/lib/core/helpers.ts | 88 +++ packages/state/src/lib/core/index.ts | 12 + packages/state/src/lib/core/isSignal.ts | 11 + packages/state/src/lib/core/transactions.ts | 251 +++++++ packages/state/src/lib/core/types.ts | 67 ++ packages/state/src/lib/react/index.ts | 7 + packages/state/src/lib/react/track.test.tsx | 227 +++++++ packages/state/src/lib/react/track.ts | 60 ++ packages/state/src/lib/react/useAtom.test.tsx | 51 ++ packages/state/src/lib/react/useAtom.ts | 40 ++ .../state/src/lib/react/useComputed.test.tsx | 117 ++++ packages/state/src/lib/react/useComputed.ts | 41 ++ .../src/lib/react}/useQuickReactor.ts | 2 +- .../src/lib/react}/useReactor.ts | 2 +- .../src/lib/react/useStateTracking.test.tsx | 203 ++++++ .../state/src/lib/react/useStateTracking.ts | 58 ++ .../state/src/lib/react/useValue.test.tsx | 135 ++++ packages/state/src/lib/react/useValue.ts | 98 +++ packages/state/tsconfig.json | 10 + packages/store/api-report.md | 4 +- packages/store/package.json | 4 +- packages/store/src/lib/Store.ts | 2 +- packages/store/src/lib/StoreQueries.ts | 6 +- .../store/src/lib/test/recordStore.test.ts | 2 +- .../src/lib/test/recordStoreFuzzing.test.ts | 2 +- .../src/lib/test/recordStoreQueries.test.ts | 2 +- packages/store/tsconfig.json | 2 +- packages/tlschema/api-report.md | 2 +- packages/tlschema/package.json | 4 +- .../src/createPresenceStateDerivation.ts | 2 +- packages/ui/package.json | 5 +- packages/ui/src/lib/TldrawUi.tsx | 2 +- .../ui/src/lib/components/ContextMenu.tsx | 2 +- packages/ui/src/lib/components/DebugPanel.tsx | 2 +- .../ui/src/lib/components/DuplicateButton.tsx | 2 +- .../ui/src/lib/components/EditLinkDialog.tsx | 2 +- .../ui/src/lib/components/EmbedDialog.tsx | 2 +- .../src/lib/components/FollowingIndicator.tsx | 2 +- packages/ui/src/lib/components/HTMLCanvas.tsx | 2 +- packages/ui/src/lib/components/MenuZone.tsx | 2 +- .../src/lib/components/MobileStylePanel.tsx | 2 +- .../ui/src/lib/components/MoveToPageMenu.tsx | 2 +- .../lib/components/NavigationZone/Minimap.tsx | 2 +- .../components/NavigationZone/ZoomMenu.tsx | 2 +- .../components/PageMenu/PageItemSubmenu.tsx | 2 +- .../src/lib/components/PageMenu/PageMenu.tsx | 2 +- .../ui/src/lib/components/PenModeToggle.tsx | 2 +- .../ui/src/lib/components/StopFollowing.tsx | 2 +- .../lib/components/StylePanel/StylePanel.tsx | 2 +- .../Toolbar/ToggleToolLockedButton.tsx | 2 +- .../ui/src/lib/components/Toolbar/Toolbar.tsx | 2 +- .../ui/src/lib/components/TrashButton.tsx | 2 +- packages/ui/src/lib/hooks/menuHelpers.ts | 2 +- .../ui/src/lib/hooks/useActionsMenuSchema.tsx | 2 +- packages/ui/src/lib/hooks/useBreakpoint.tsx | 2 +- packages/ui/src/lib/hooks/useCanRedo.ts | 2 +- packages/ui/src/lib/hooks/useCanUndo.ts | 2 +- .../ui/src/lib/hooks/useContextMenuSchema.tsx | 2 +- .../ui/src/lib/hooks/useEditorIsFocused.ts | 2 +- .../src/lib/hooks/useHasLinkShapeSelected.ts | 2 +- .../ui/src/lib/hooks/useHelpMenuSchema.tsx | 2 +- .../lib/hooks/useKeyboardShortcutsSchema.tsx | 2 +- packages/ui/src/lib/hooks/useMenuIsOpen.ts | 2 +- packages/ui/src/lib/hooks/useMenuSchema.tsx | 2 +- .../ui/src/lib/hooks/useOnlyFlippableShape.ts | 2 +- packages/ui/src/lib/hooks/useReadonly.ts | 2 +- .../ui/src/lib/hooks/useShowAutoSizeToggle.ts | 2 +- .../ui/src/lib/hooks/useToolbarSchema.tsx | 2 +- packages/ui/src/lib/hooks/useTools.tsx | 2 +- .../hooks/useTranslation/useTranslation.tsx | 2 +- public-yarn.lock | 54 +- scripts/api-check.ts | 1 - 143 files changed, 5419 insertions(+), 168 deletions(-) create mode 100644 packages/state/CHANGELOG.md create mode 100644 packages/state/README.md create mode 100644 packages/state/api-extractor.json create mode 100644 packages/state/api-report.md create mode 100644 packages/state/docs-ordering.json create mode 100644 packages/state/package.json create mode 100644 packages/state/src/index.ts create mode 100644 packages/state/src/lib/core/ArraySet.ts create mode 100644 packages/state/src/lib/core/Atom.ts create mode 100644 packages/state/src/lib/core/Computed.ts create mode 100644 packages/state/src/lib/core/EffectScheduler.ts create mode 100644 packages/state/src/lib/core/HistoryBuffer.ts create mode 100644 packages/state/src/lib/core/__tests__/EffectScheduler.test.ts create mode 100644 packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts create mode 100644 packages/state/src/lib/core/__tests__/arraySet.test.ts create mode 100644 packages/state/src/lib/core/__tests__/atom.test.ts create mode 100644 packages/state/src/lib/core/__tests__/capture.test.ts create mode 100644 packages/state/src/lib/core/__tests__/computed.test.ts create mode 100644 packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts create mode 100644 packages/state/src/lib/core/__tests__/reactor.test.ts create mode 100644 packages/state/src/lib/core/__tests__/transactions.test.ts create mode 100644 packages/state/src/lib/core/capture.ts create mode 100644 packages/state/src/lib/core/constants.ts create mode 100644 packages/state/src/lib/core/helpers.ts create mode 100644 packages/state/src/lib/core/index.ts create mode 100644 packages/state/src/lib/core/isSignal.ts create mode 100644 packages/state/src/lib/core/transactions.ts create mode 100644 packages/state/src/lib/core/types.ts create mode 100644 packages/state/src/lib/react/index.ts create mode 100644 packages/state/src/lib/react/track.test.tsx create mode 100644 packages/state/src/lib/react/track.ts create mode 100644 packages/state/src/lib/react/useAtom.test.tsx create mode 100644 packages/state/src/lib/react/useAtom.ts create mode 100644 packages/state/src/lib/react/useComputed.test.tsx create mode 100644 packages/state/src/lib/react/useComputed.ts rename packages/{editor/src/lib/hooks => state/src/lib/react}/useQuickReactor.ts (87%) rename packages/{editor/src/lib/hooks => state/src/lib/react}/useReactor.ts (91%) create mode 100644 packages/state/src/lib/react/useStateTracking.test.tsx create mode 100644 packages/state/src/lib/react/useStateTracking.ts create mode 100644 packages/state/src/lib/react/useValue.test.tsx create mode 100644 packages/state/src/lib/react/useValue.ts create mode 100644 packages/state/tsconfig.json diff --git a/apps/docs/content/docs/editor.mdx b/apps/docs/content/docs/editor.mdx index b2c596804..c555c7628 100644 --- a/apps/docs/content/docs/editor.mdx +++ b/apps/docs/content/docs/editor.mdx @@ -26,11 +26,10 @@ For example, the store contains a `page` record for each page in the current doc The editor also exposes many _computed_ values which are derived from other records in the store. For example, `editor.selectedIds` is a computed property that will return the editor's current selected shape ids for its current page. -You can use these properties directly or you can use them in [signia](https://github.com/tldraw/signia) signals. +You can use these properties directly or you can use them in signals. ```tsx -import { track } from "@tldraw/signia" -import { useEditor } from "@tldraw/tldraw" +import { track, useEditor } from "@tldraw/tldraw" export const SelectedIdsCount = track(() => { const editor = useEditor() @@ -151,8 +150,7 @@ Note that the paths you pass to `isIn` or `isInAny` can be the full path or a pa > If all you're interested in is the state below `root`, there is a convenience property, `editor.currentToolId`, that can help with the editor's currently selected tool. ```tsx -import { track } from "@tldraw/signia" -import { useEditor } from "@tldraw/tldraw" +import { track, useEditor } from "@tldraw/tldraw" export const CreatingBubbleToolUi = track(() => { const editor = useEditor() diff --git a/apps/docs/content/docs/installation.mdx b/apps/docs/content/docs/installation.mdx index dca2d70e3..1bc66bcc5 100644 --- a/apps/docs/content/docs/installation.mdx +++ b/apps/docs/content/docs/installation.mdx @@ -10,12 +10,12 @@ At the moment the `@tldraw/tldraw` package is in alpha. We also ship a canary ve ## Alpha -First, install the `@tldraw/tldraw` package using `@alpha` for the latest. The package also has peer dependencies on `signia` and `signia-react` which you will need to install at the same time. +First, install the `@tldraw/tldraw` package using `@alpha` for the latest. ```bash -yarn add @tldraw/tldraw@alpha signia signia-react +yarn add @tldraw/tldraw@alpha # or -npm install @tldraw/tldraw@alpha signia signia-react +npm install @tldraw/tldraw@alpha ``` ## Canary @@ -23,7 +23,7 @@ npm install @tldraw/tldraw@alpha signia signia-react To get the very latest version, use the [latest canary release](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions). Docs for the very latest version are also available at [canary.tldraw.dev](https://canary.tldraw.dev). ```bash -yarn add @tldraw/tldraw@canary signia signia-react +yarn add @tldraw/tldraw@canary # or -npm install @tldraw/tldraw@canary signia signia-react +npm install @tldraw/tldraw@canary ``` \ No newline at end of file diff --git a/apps/examples/package.json b/apps/examples/package.json index 0418c7ba1..01eb796d7 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -37,6 +37,7 @@ "@babel/plugin-proposal-decorators": "^7.21.0", "@playwright/test": "^1.34.3", "@tldraw/assets": "workspace:*", + "@tldraw/state": "workspace:*", "@tldraw/tldraw": "workspace:*", "@tldraw/utils": "workspace:*", "@tldraw/validate": "workspace:*", @@ -45,8 +46,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.9.0", - "signia": "0.1.4", - "signia-react": "0.1.4", "vite": "^4.3.4", "y-websocket": "^1.5.0", "yjs": "^13.6.2" diff --git a/apps/examples/src/16-custom-styles/FilterStyleUi.tsx b/apps/examples/src/16-custom-styles/FilterStyleUi.tsx index ed3e35cc9..4fe28d3a0 100644 --- a/apps/examples/src/16-custom-styles/FilterStyleUi.tsx +++ b/apps/examples/src/16-custom-styles/FilterStyleUi.tsx @@ -1,5 +1,5 @@ +import { track } from '@tldraw/state' import { useEditor } from '@tldraw/tldraw' -import { track } from 'signia-react' import { MyFilterStyle } from './CardShape' export const FilterStyleUi = track(function FilterStyleUi() { diff --git a/apps/examples/src/4-custom-ui/CustomUiExample.tsx b/apps/examples/src/4-custom-ui/CustomUiExample.tsx index 1bb8c5b31..7bd248812 100644 --- a/apps/examples/src/4-custom-ui/CustomUiExample.tsx +++ b/apps/examples/src/4-custom-ui/CustomUiExample.tsx @@ -1,7 +1,7 @@ +import { track } from '@tldraw/state' import { Canvas, TldrawEditor, defaultShapes, defaultTools, useEditor } from '@tldraw/tldraw' import '@tldraw/tldraw/tldraw.css' import { useEffect } from 'react' -import { track } from 'signia-react' import './custom-ui.css' export default function CustomUiExample() { diff --git a/apps/examples/src/yjs/YjsExample.tsx b/apps/examples/src/yjs/YjsExample.tsx index 917f2c440..c8f884d5c 100644 --- a/apps/examples/src/yjs/YjsExample.tsx +++ b/apps/examples/src/yjs/YjsExample.tsx @@ -1,6 +1,6 @@ +import { track } from '@tldraw/state' import { Tldraw, useEditor } from '@tldraw/tldraw' import '@tldraw/tldraw/tldraw.css' -import { track } from 'signia-react' import { useYjsStore } from './useYjsStore' const HOST_URL = diff --git a/apps/examples/src/yjs/useYjsStore.ts b/apps/examples/src/yjs/useYjsStore.ts index 58bbbff73..fa94bc7bb 100644 --- a/apps/examples/src/yjs/useYjsStore.ts +++ b/apps/examples/src/yjs/useYjsStore.ts @@ -1,3 +1,4 @@ +import { computed, react, transact } from '@tldraw/state' import { DocumentRecordType, InstancePresenceRecordType, @@ -13,7 +14,6 @@ import { getUserPreferences, } from '@tldraw/tldraw' import { useEffect, useMemo, useState } from 'react' -import { computed, react, transact } from 'signia' import { WebsocketProvider } from 'y-websocket' import * as Y from 'yjs' diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index c97514e28..84598d709 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -6,10 +6,12 @@ /// -import { Atom } from 'signia'; +import { Atom } from '@tldraw/state'; +import { atom } from '@tldraw/state'; import { Box2d } from '@tldraw/primitives'; import { Box2dModel } from '@tldraw/tlschema'; -import { Computed } from 'signia'; +import { Computed } from '@tldraw/state'; +import { computed } from '@tldraw/state'; import { ComputedCache } from '@tldraw/store'; import { CubicSpline2d } from '@tldraw/primitives'; import { defineMigrations } from '@tldraw/store'; @@ -23,6 +25,7 @@ import { Matrix2d } from '@tldraw/primitives'; import { Matrix2dModel } from '@tldraw/primitives'; import { Migrations } from '@tldraw/store'; import { Polyline2d } from '@tldraw/primitives'; +import { react } from '@tldraw/state'; import { default as React_2 } from 'react'; import * as React_3 from 'react'; import { RecursivePartial } from '@tldraw/utils'; @@ -32,7 +35,7 @@ import { SelectionEdge } from '@tldraw/primitives'; import { SelectionHandle } from '@tldraw/primitives'; import { SerializedSchema } from '@tldraw/store'; import { ShapeProps } from '@tldraw/tlschema'; -import { Signal } from 'signia'; +import { Signal } from '@tldraw/state'; import { StoreSchema } from '@tldraw/store'; import { StoreSnapshot } from '@tldraw/store'; import { StrokePoint } from '@tldraw/primitives'; @@ -78,10 +81,16 @@ import { TLTextShape } from '@tldraw/tlschema'; import { TLUnknownShape } from '@tldraw/tlschema'; import { TLVideoAsset } from '@tldraw/tlschema'; import { TLVideoShape } from '@tldraw/tlschema'; +import { track } from '@tldraw/state'; import { UnknownRecord } from '@tldraw/store'; +import { useComputed } from '@tldraw/state'; +import { useQuickReactor } from '@tldraw/state'; +import { useReactor } from '@tldraw/state'; +import { useValue } from '@tldraw/state'; import { Vec2d } from '@tldraw/primitives'; import { Vec2dModel } from '@tldraw/tlschema'; import { VecLike } from '@tldraw/primitives'; +import { whyAmIRunning } from '@tldraw/state'; // @public (undocumented) export const ACCEPTED_ASSET_TYPE: string; @@ -163,6 +172,8 @@ export class ArrowShapeUtil extends ShapeUtil { static type: "arrow"; } +export { atom } + // @public (undocumented) export abstract class BaseBoxShapeTool extends StateNode { // (undocumented) @@ -225,6 +236,8 @@ export const Canvas: React_2.MemoExoticComponent<() => JSX.Element>; // @public (undocumented) export const checkFlag: (flag: (() => boolean) | boolean | undefined) => boolean | undefined; +export { computed } + // @public export function containBoxSize(originalSize: BoxWidthHeight, containBoxSize: BoxWidthHeight): BoxWidthHeight; @@ -1764,6 +1777,8 @@ export class PlopManager { // @public export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void; +export { react } + // @public (undocumented) export class ReadonlySharedStyleMap { // (undocumented) @@ -2705,6 +2720,8 @@ export type TLWheelEventInfo = TLBaseEventInfo & { delta: Vec2dModel; }; +export { track } + // @public (undocumented) export const truncateStringWithEllipsis: (str: string, maxLength: number) => string; @@ -2717,6 +2734,8 @@ export type UiEventType = 'click' | 'keyboard' | 'pinch' | 'pointer' | 'wheel' | // @public export function uniqueId(): string; +export { useComputed } + // @public (undocumented) export function useContainer(): HTMLDivElement; @@ -2738,18 +2757,18 @@ export function usePrefersReducedMotion(): boolean; // @internal (undocumented) export function usePresence(userId: string): null | TLInstancePresence; -// @public (undocumented) -export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void; +export { useQuickReactor } // @internal (undocumented) export const USER_COLORS: readonly ["#FF802B", "#EC5E41", "#F2555A", "#F04F88", "#E34BA9", "#BD54C6", "#9D5BD2", "#7B66DC", "#02B1CC", "#11B3A3", "#39B178", "#55B467"]; -// @public (undocumented) -export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void; +export { useReactor } // @public (undocumented) export function useTLStore(opts: TLStoreOptions): TLStore; +export { useValue } + // @public (undocumented) export const VideoShape: TLShapeInfo; @@ -2789,6 +2808,8 @@ export class WeakMapCache { set(item: T, value: K): void; } +export { whyAmIRunning } + // @internal (undocumented) export const ZOOMS: number[]; diff --git a/packages/editor/package.json b/packages/editor/package.json index 1cb08b2c7..af37cab77 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -47,6 +47,7 @@ "dependencies": { "@tldraw/indices": "workspace:*", "@tldraw/primitives": "workspace:*", + "@tldraw/state": "workspace:*", "@tldraw/store": "workspace:*", "@tldraw/tlschema": "workspace:*", "@tldraw/utils": "workspace:*", @@ -66,9 +67,7 @@ }, "peerDependencies": { "react": "^18", - "react-dom": "^18", - "signia": "*", - "signia-react": "*" + "react-dom": "^18" }, "devDependencies": { "@peculiar/webcrypto": "^1.4.0", diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index efba78a56..d97253fcd 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -3,6 +3,17 @@ // eslint-disable-next-line local/no-export-star export * from '@tldraw/indices' +export { + atom, + computed, + react, + track, + useComputed, + useQuickReactor, + useReactor, + useValue, + whyAmIRunning, +} from '@tldraw/state' export { defineMigrations } from '@tldraw/store' // eslint-disable-next-line local/no-export-star export * from '@tldraw/tlschema' @@ -184,8 +195,6 @@ export type { TLEditorComponents } from './lib/hooks/useEditorComponents' export { useLocalStore } from './lib/hooks/useLocalStore' export { usePeerIds } from './lib/hooks/usePeerIds' export { usePresence } from './lib/hooks/usePresence' -export { useQuickReactor } from './lib/hooks/useQuickReactor' -export { useReactor } from './lib/hooks/useReactor' export { useTLStore } from './lib/hooks/useTLStore' export { ReadonlySharedStyleMap, diff --git a/packages/editor/src/lib/components/Canvas.tsx b/packages/editor/src/lib/components/Canvas.tsx index d27b2a2cc..a57fb9700 100644 --- a/packages/editor/src/lib/components/Canvas.tsx +++ b/packages/editor/src/lib/components/Canvas.tsx @@ -1,9 +1,8 @@ import { Matrix2d, toDomPrecision } from '@tldraw/primitives' +import { react, track, useQuickReactor, useValue } from '@tldraw/state' import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { dedupe, modulate } from '@tldraw/utils' import React from 'react' -import { react } from 'signia' -import { track, useValue } from 'signia-react' import { useCanvasEvents } from '../hooks/useCanvasEvents' import { useCoarsePointer } from '../hooks/useCoarsePointer' import { useDocumentEvents } from '../hooks/useDocumentEvents' @@ -13,7 +12,6 @@ import { useFixSafariDoubleTapZoomPencilEvents } from '../hooks/useFixSafariDoub import { useGestureEvents } from '../hooks/useGestureEvents' import { useHandleEvents } from '../hooks/useHandleEvents' import { usePattern } from '../hooks/usePattern' -import { useQuickReactor } from '../hooks/useQuickReactor' import { useScreenBounds } from '../hooks/useScreenBounds' import { debugFlags } from '../utils/debug-flags' import { LiveCollaborators } from './LiveCollaborators' diff --git a/packages/editor/src/lib/components/DefaultErrorFallback.tsx b/packages/editor/src/lib/components/DefaultErrorFallback.tsx index 005795aa9..9c867f436 100644 --- a/packages/editor/src/lib/components/DefaultErrorFallback.tsx +++ b/packages/editor/src/lib/components/DefaultErrorFallback.tsx @@ -1,6 +1,6 @@ +import { useValue } from '@tldraw/state' import classNames from 'classnames' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useValue } from 'signia-react' import { Editor } from '../editor/Editor' import { EditorContext } from '../hooks/useEditor' import { hardResetEditor } from '../utils/hard-reset' diff --git a/packages/editor/src/lib/components/LiveCollaborators.tsx b/packages/editor/src/lib/components/LiveCollaborators.tsx index 9ef15a352..bd65295f0 100644 --- a/packages/editor/src/lib/components/LiveCollaborators.tsx +++ b/packages/editor/src/lib/components/LiveCollaborators.tsx @@ -1,5 +1,5 @@ +import { track } from '@tldraw/state' import { useEffect, useRef, useState } from 'react' -import { track } from 'signia-react' import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' diff --git a/packages/editor/src/lib/components/SelectionBg.tsx b/packages/editor/src/lib/components/SelectionBg.tsx index edcb814cb..912f27b72 100644 --- a/packages/editor/src/lib/components/SelectionBg.tsx +++ b/packages/editor/src/lib/components/SelectionBg.tsx @@ -1,6 +1,6 @@ import { Matrix2d, toDomPrecision } from '@tldraw/primitives' +import { track } from '@tldraw/state' import * as React from 'react' -import { track } from 'signia-react' import { TLPointerEventInfo } from '../editor/types/event-types' import { useEditor } from '../hooks/useEditor' import { releasePointerCapture, setPointerCapture } from '../utils/dom' diff --git a/packages/editor/src/lib/components/SelectionFg.tsx b/packages/editor/src/lib/components/SelectionFg.tsx index 0771721d2..c51912233 100644 --- a/packages/editor/src/lib/components/SelectionFg.tsx +++ b/packages/editor/src/lib/components/SelectionFg.tsx @@ -1,7 +1,7 @@ import { RotateCorner, toDomPrecision } from '@tldraw/primitives' +import { track } from '@tldraw/state' import classNames from 'classnames' import { useRef } from 'react' -import { track } from 'signia-react' import { EmbedShapeUtil } from '../editor/shapes/embed/EmbedShapeUtil' import { TextShapeUtil } from '../editor/shapes/text/TextShapeUtil' import { getCursor } from '../hooks/useCursor' diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index f47d4f92b..7550771da 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -1,15 +1,10 @@ import { Matrix2d } from '@tldraw/primitives' +import { track, useQuickReactor, useStateTracking } from '@tldraw/state' import { TLShape, TLShapeId } from '@tldraw/tlschema' import * as React from 'react' -import { - track, - // @ts-expect-error 'private' export - useStateTracking, -} from 'signia-react' import { useEditor } from '../..' import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { useEditorComponents } from '../hooks/useEditorComponents' -import { useQuickReactor } from '../hooks/useQuickReactor' import { useShapeEvents } from '../hooks/useShapeEvents' import { OptionalErrorBoundary } from './ErrorBoundary' diff --git a/packages/editor/src/lib/components/ShapeIndicator.tsx b/packages/editor/src/lib/components/ShapeIndicator.tsx index aad20a197..1d135637a 100644 --- a/packages/editor/src/lib/components/ShapeIndicator.tsx +++ b/packages/editor/src/lib/components/ShapeIndicator.tsx @@ -1,11 +1,7 @@ +import { useStateTracking, useValue } from '@tldraw/state' import { TLShape, TLShapeId } from '@tldraw/tlschema' import classNames from 'classnames' import * as React from 'react' -import { - // @ts-expect-error 'private' export - useStateTracking, - useValue, -} from 'signia-react' import { useEditor } from '../..' import type { Editor } from '../editor/Editor' import { ShapeUtil } from '../editor/shapes/ShapeUtil' diff --git a/packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts b/packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts index d19a58812..d795dfc46 100644 --- a/packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts +++ b/packages/editor/src/lib/config/TLSessionStateSnapshot.test.ts @@ -1,4 +1,4 @@ -import { react } from 'signia' +import { react } from '@tldraw/state' import { TestEditor } from '../test/TestEditor' import { TLSessionStateSnapshot, diff --git a/packages/editor/src/lib/config/TLSessionStateSnapshot.ts b/packages/editor/src/lib/config/TLSessionStateSnapshot.ts index ae1ac735c..8ae4237d9 100644 --- a/packages/editor/src/lib/config/TLSessionStateSnapshot.ts +++ b/packages/editor/src/lib/config/TLSessionStateSnapshot.ts @@ -1,3 +1,4 @@ +import { Signal, computed, transact } from '@tldraw/state' import { RecordsDiff, UnknownRecord, @@ -18,7 +19,6 @@ import { } from '@tldraw/tlschema' import { objectMapFromEntries } from '@tldraw/utils' import { T } from '@tldraw/validate' -import { Signal, computed, transact } from 'signia' import { uniqueId } from '../utils/data' const tabIdKey = 'TLDRAW_TAB_ID_v2' as const diff --git a/packages/editor/src/lib/config/TLUserPreferences.test.ts b/packages/editor/src/lib/config/TLUserPreferences.test.ts index f38a29fee..394eca2fb 100644 --- a/packages/editor/src/lib/config/TLUserPreferences.test.ts +++ b/packages/editor/src/lib/config/TLUserPreferences.test.ts @@ -1,4 +1,4 @@ -import { atom } from 'signia' +import { atom } from '@tldraw/state' import { TestEditor } from '../test/TestEditor' import { TLUserPreferences } from './TLUserPreferences' import { createTLUser } from './createTLUser' diff --git a/packages/editor/src/lib/config/TLUserPreferences.ts b/packages/editor/src/lib/config/TLUserPreferences.ts index d7f8e9874..d18b06fe5 100644 --- a/packages/editor/src/lib/config/TLUserPreferences.ts +++ b/packages/editor/src/lib/config/TLUserPreferences.ts @@ -1,7 +1,7 @@ +import { atom } from '@tldraw/state' import { defineMigrations, migrate } from '@tldraw/store' import { getDefaultTranslationLocale } from '@tldraw/tlschema' import { T } from '@tldraw/validate' -import { atom } from 'signia' import { uniqueId } from '../utils/data' const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3' diff --git a/packages/editor/src/lib/config/createTLUser.ts b/packages/editor/src/lib/config/createTLUser.ts index 3e78fdd53..5b0a91eac 100644 --- a/packages/editor/src/lib/config/createTLUser.ts +++ b/packages/editor/src/lib/config/createTLUser.ts @@ -1,5 +1,5 @@ +import { Signal, computed } from '@tldraw/state' import { TLInstancePresence, TLStore } from '@tldraw/tlschema' -import { Signal, computed } from 'signia' import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences' /** @public */ diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 471ec2401..5559c6089 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -21,6 +21,7 @@ import { intersectPolygonPolygon, pointInPolygon, } from '@tldraw/primitives' +import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state' import { ComputedCache, RecordType } from '@tldraw/store' import { Box2dModel, @@ -79,7 +80,6 @@ import { } from '@tldraw/utils' import { EventEmitter } from 'eventemitter3' import { nanoid } from 'nanoid' -import { EMPTY_ARRAY, atom, computed, transact } from 'signia' import { TLUser, createTLUser } from '../config/createTLUser' import { checkShapesAndAddCore } from '../config/defaultShapes' import { AnyTLShapeInfo } from '../config/defineShape' diff --git a/packages/editor/src/lib/editor/derivations/arrowBindingsIndex.ts b/packages/editor/src/lib/editor/derivations/arrowBindingsIndex.ts index 319037e9c..0db7aa1e0 100644 --- a/packages/editor/src/lib/editor/derivations/arrowBindingsIndex.ts +++ b/packages/editor/src/lib/editor/derivations/arrowBindingsIndex.ts @@ -1,5 +1,5 @@ +import { Computed, RESET_VALUE, computed, isUninitialized } from '@tldraw/state' import { TLArrowShape, TLShape, TLShapeId } from '@tldraw/tlschema' -import { Computed, RESET_VALUE, computed, isUninitialized } from 'signia' import { Editor } from '../Editor' import { ArrowShapeUtil } from '../shapes/arrow/ArrowShapeUtil' diff --git a/packages/editor/src/lib/editor/derivations/parentsToChildrenWithIndexes.ts b/packages/editor/src/lib/editor/derivations/parentsToChildrenWithIndexes.ts index ab71a981f..632028f04 100644 --- a/packages/editor/src/lib/editor/derivations/parentsToChildrenWithIndexes.ts +++ b/packages/editor/src/lib/editor/derivations/parentsToChildrenWithIndexes.ts @@ -1,6 +1,6 @@ +import { computed, isUninitialized, RESET_VALUE } from '@tldraw/state' import { RecordsDiff } from '@tldraw/store' import { isShape, TLParentId, TLRecord, TLShapeId, TLStore } from '@tldraw/tlschema' -import { computed, isUninitialized, RESET_VALUE } from 'signia' type Parents2Children = Record diff --git a/packages/editor/src/lib/editor/derivations/shapeIdsInCurrentPage.ts b/packages/editor/src/lib/editor/derivations/shapeIdsInCurrentPage.ts index 9940be495..50d52dc35 100644 --- a/packages/editor/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +++ b/packages/editor/src/lib/editor/derivations/shapeIdsInCurrentPage.ts @@ -1,3 +1,4 @@ +import { computed, isUninitialized, RESET_VALUE, withDiff } from '@tldraw/state' import { IncrementalSetConstructor } from '@tldraw/store' import { isPageId, @@ -8,7 +9,6 @@ import { TLShapeId, TLStore, } from '@tldraw/tlschema' -import { computed, isUninitialized, RESET_VALUE, withDiff } from 'signia' /** * Get whether a shape is in the current page. diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.ts b/packages/editor/src/lib/editor/managers/HistoryManager.ts index 0faef048c..6e970e160 100644 --- a/packages/editor/src/lib/editor/managers/HistoryManager.ts +++ b/packages/editor/src/lib/editor/managers/HistoryManager.ts @@ -1,5 +1,5 @@ +import { atom, transact } from '@tldraw/state' import { devFreeze } from '@tldraw/store' -import { atom, transact } from 'signia' import { uniqueId } from '../../utils/data' import { TLCommandHandler, TLHistoryEntry } from '../types/history-types' import { Stack, stack } from './Stack' diff --git a/packages/editor/src/lib/editor/managers/SnapManager.ts b/packages/editor/src/lib/editor/managers/SnapManager.ts index f88617b82..00f898da4 100644 --- a/packages/editor/src/lib/editor/managers/SnapManager.ts +++ b/packages/editor/src/lib/editor/managers/SnapManager.ts @@ -11,9 +11,9 @@ import { Vec2d, VecLike, } from '@tldraw/primitives' +import { atom, computed, EMPTY_ARRAY } from '@tldraw/state' import { TLParentId, TLShape, TLShapeId, Vec2dModel } from '@tldraw/tlschema' import { dedupe, deepCopy } from '@tldraw/utils' -import { atom, computed, EMPTY_ARRAY } from 'signia' import { uniqueId } from '../../utils/data' import type { Editor } from '../Editor' import { GroupShapeUtil } from '../shapes/group/GroupShapeUtil' diff --git a/packages/editor/src/lib/editor/managers/Stack.ts b/packages/editor/src/lib/editor/managers/Stack.ts index 1b2e75146..4a5fcbca4 100644 --- a/packages/editor/src/lib/editor/managers/Stack.ts +++ b/packages/editor/src/lib/editor/managers/Stack.ts @@ -1,4 +1,4 @@ -import { EMPTY_ARRAY } from 'signia' +import { EMPTY_ARRAY } from '@tldraw/state' export type Stack = StackItem | EmptyStackItem diff --git a/packages/editor/src/lib/editor/shapes/arrow/ArrowShapeUtil.tsx b/packages/editor/src/lib/editor/shapes/arrow/ArrowShapeUtil.tsx index 53205bf2a..43d9ce867 100644 --- a/packages/editor/src/lib/editor/shapes/arrow/ArrowShapeUtil.tsx +++ b/packages/editor/src/lib/editor/shapes/arrow/ArrowShapeUtil.tsx @@ -10,6 +10,7 @@ import { Vec2d, VecLike, } from '@tldraw/primitives' +import { computed, EMPTY_ARRAY } from '@tldraw/state' import { ComputedCache } from '@tldraw/store' import { TLArrowShape, @@ -23,7 +24,6 @@ import { } from '@tldraw/tlschema' import { deepCopy, last, minBy } from '@tldraw/utils' import * as React from 'react' -import { computed, EMPTY_ARRAY } from 'signia' import { SVGContainer } from '../../../components/SVGContainer' import { ShapeUtil, diff --git a/packages/editor/src/lib/editor/shapes/embed/EmbedShapeUtil.tsx b/packages/editor/src/lib/editor/shapes/embed/EmbedShapeUtil.tsx index bf7483ac6..671b61bd2 100644 --- a/packages/editor/src/lib/editor/shapes/embed/EmbedShapeUtil.tsx +++ b/packages/editor/src/lib/editor/shapes/embed/EmbedShapeUtil.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { toDomPrecision } from '@tldraw/primitives' +import { useValue } from '@tldraw/state' import { TLEmbedShape, TLEmbedShapePermissions, @@ -7,7 +8,6 @@ import { } from '@tldraw/tlschema' import * as React from 'react' import { useMemo } from 'react' -import { useValue } from 'signia-react' import { DefaultSpinner } from '../../../components/DefaultSpinner' import { HTMLContainer } from '../../../components/HTMLContainer' import { useIsEditing } from '../../../hooks/useIsEditing' diff --git a/packages/editor/src/lib/editor/shapes/image/ImageShapeUtil.tsx b/packages/editor/src/lib/editor/shapes/image/ImageShapeUtil.tsx index 9594c6c2a..bab208ffb 100644 --- a/packages/editor/src/lib/editor/shapes/image/ImageShapeUtil.tsx +++ b/packages/editor/src/lib/editor/shapes/image/ImageShapeUtil.tsx @@ -1,9 +1,9 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { Vec2d, toDomPrecision } from '@tldraw/primitives' +import { useValue } from '@tldraw/state' import { TLImageShape, TLShapePartial } from '@tldraw/tlschema' import { deepCopy } from '@tldraw/utils' import { useEffect, useState } from 'react' -import { useValue } from 'signia-react' import { DefaultSpinner } from '../../../components/DefaultSpinner' import { HTMLContainer } from '../../../components/HTMLContainer' import { useIsCropping } from '../../../hooks/useIsCropping' diff --git a/packages/editor/src/lib/editor/shapes/shared/ShapeFill.tsx b/packages/editor/src/lib/editor/shapes/shared/ShapeFill.tsx index a5ebeb68c..9175b6937 100644 --- a/packages/editor/src/lib/editor/shapes/shared/ShapeFill.tsx +++ b/packages/editor/src/lib/editor/shapes/shared/ShapeFill.tsx @@ -1,6 +1,6 @@ +import { useValue } from '@tldraw/state' import { TLDefaultColorStyle, TLDefaultFillStyle } from '@tldraw/tlschema' import * as React from 'react' -import { useValue } from 'signia-react' import { HASH_PATERN_ZOOM_NAMES } from '../../../constants' import { useEditor } from '../../../hooks/useEditor' import { TLExportColors } from './TLExportColors' diff --git a/packages/editor/src/lib/editor/shapes/shared/useEditableText.ts b/packages/editor/src/lib/editor/shapes/shared/useEditableText.ts index dd1935d0d..a0cf4453f 100644 --- a/packages/editor/src/lib/editor/shapes/shared/useEditableText.ts +++ b/packages/editor/src/lib/editor/shapes/shared/useEditableText.ts @@ -1,7 +1,7 @@ /* eslint-disable no-inner-declarations */ +import { useValue } from '@tldraw/state' import { TLShape, TLUnknownShape } from '@tldraw/tlschema' import React, { useCallback, useEffect, useRef } from 'react' -import { useValue } from 'signia-react' import { useEditor } from '../../../hooks/useEditor' import { preventDefault, stopEventPropagation } from '../../../utils/dom' import { INDENT, TextHelpers } from '../text/TextHelpers' diff --git a/packages/editor/src/lib/editor/shapes/shared/useForceSolid.ts b/packages/editor/src/lib/editor/shapes/shared/useForceSolid.ts index 93f633ea8..9f3dba036 100644 --- a/packages/editor/src/lib/editor/shapes/shared/useForceSolid.ts +++ b/packages/editor/src/lib/editor/shapes/shared/useForceSolid.ts @@ -1,4 +1,4 @@ -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' import { useEditor } from '../../../hooks/useEditor' export function useForceSolid() { diff --git a/packages/editor/src/lib/editor/shapes/video/VideoShapeUtil.tsx b/packages/editor/src/lib/editor/shapes/video/VideoShapeUtil.tsx index 1035bb012..f308fba17 100644 --- a/packages/editor/src/lib/editor/shapes/video/VideoShapeUtil.tsx +++ b/packages/editor/src/lib/editor/shapes/video/VideoShapeUtil.tsx @@ -1,7 +1,7 @@ import { toDomPrecision } from '@tldraw/primitives' +import { track } from '@tldraw/state' import { TLVideoShape } from '@tldraw/tlschema' import * as React from 'react' -import { track } from 'signia-react' import { DefaultSpinner } from '../../../components/DefaultSpinner' import { HTMLContainer } from '../../../components/HTMLContainer' import { useIsEditing } from '../../../hooks/useIsEditing' diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index 8d4f9f385..9f296289d 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -1,5 +1,5 @@ +import { Atom, Computed, atom, computed } from '@tldraw/state' import { TLBaseShape } from '@tldraw/tlschema' -import { Atom, Computed, atom, computed } from 'signia' import type { Editor } from '../Editor' import { TLShapeUtilConstructor } from '../shapes/ShapeUtil' import { diff --git a/packages/editor/src/lib/hooks/useCursor.ts b/packages/editor/src/lib/hooks/useCursor.ts index 1f46da393..b4db96e29 100644 --- a/packages/editor/src/lib/hooks/useCursor.ts +++ b/packages/editor/src/lib/hooks/useCursor.ts @@ -1,8 +1,8 @@ import { PI, radiansToDegrees } from '@tldraw/primitives' +import { useQuickReactor } from '@tldraw/state' import { TLCursorType } from '@tldraw/tlschema' import { useContainer } from './useContainer' import { useEditor } from './useEditor' -import { useQuickReactor } from './useQuickReactor' const DEFAULT_SVG = `` const POINTER_SVG = `` diff --git a/packages/editor/src/lib/hooks/useDarkMode.ts b/packages/editor/src/lib/hooks/useDarkMode.ts index d49ccb54e..8da32982f 100644 --- a/packages/editor/src/lib/hooks/useDarkMode.ts +++ b/packages/editor/src/lib/hooks/useDarkMode.ts @@ -1,5 +1,5 @@ +import { useValue } from '@tldraw/state' import React from 'react' -import { useValue } from 'signia-react' import { debugFlags } from '../utils/debug-flags' import { useContainer } from './useContainer' import { useEditor } from './useEditor' diff --git a/packages/editor/src/lib/hooks/useDocumentEvents.ts b/packages/editor/src/lib/hooks/useDocumentEvents.ts index 28a93dabf..30cd879f0 100644 --- a/packages/editor/src/lib/hooks/useDocumentEvents.ts +++ b/packages/editor/src/lib/hooks/useDocumentEvents.ts @@ -1,5 +1,5 @@ +import { useValue } from '@tldraw/state' import { useEffect } from 'react' -import { useValue } from 'signia-react' import { TLKeyboardEventInfo, TLPointerEventInfo } from '../editor/types/event-types' import { preventDefault } from '../utils/dom' import { useContainer } from './useContainer' diff --git a/packages/editor/src/lib/hooks/useIsCropping.ts b/packages/editor/src/lib/hooks/useIsCropping.ts index da9ea65aa..5202df30c 100644 --- a/packages/editor/src/lib/hooks/useIsCropping.ts +++ b/packages/editor/src/lib/hooks/useIsCropping.ts @@ -1,5 +1,5 @@ +import { useValue } from '@tldraw/state' import { TLShapeId } from '@tldraw/tlschema' -import { useValue } from 'signia-react' import { useEditor } from './useEditor' export function useIsCropping(shapeId: TLShapeId) { diff --git a/packages/editor/src/lib/hooks/useIsEditing.ts b/packages/editor/src/lib/hooks/useIsEditing.ts index 2e13f86f7..9178f1557 100644 --- a/packages/editor/src/lib/hooks/useIsEditing.ts +++ b/packages/editor/src/lib/hooks/useIsEditing.ts @@ -1,5 +1,5 @@ +import { useValue } from '@tldraw/state' import { TLShapeId } from '@tldraw/tlschema' -import { useValue } from 'signia-react' import { useEditor } from './useEditor' export function useIsEditing(shapeId: TLShapeId) { diff --git a/packages/editor/src/lib/hooks/usePeerIds.ts b/packages/editor/src/lib/hooks/usePeerIds.ts index fefb3ce4e..57032fa3c 100644 --- a/packages/editor/src/lib/hooks/usePeerIds.ts +++ b/packages/editor/src/lib/hooks/usePeerIds.ts @@ -1,6 +1,6 @@ +import { useComputed, useValue } from '@tldraw/state' import uniq from 'lodash.uniq' import { useMemo } from 'react' -import { useComputed, useValue } from 'signia-react' import { useEditor } from './useEditor' // TODO: maybe move this to a computed property on the App class? diff --git a/packages/editor/src/lib/hooks/usePresence.ts b/packages/editor/src/lib/hooks/usePresence.ts index e1f218f19..d4a36874d 100644 --- a/packages/editor/src/lib/hooks/usePresence.ts +++ b/packages/editor/src/lib/hooks/usePresence.ts @@ -1,6 +1,6 @@ +import { useValue } from '@tldraw/state' import { TLInstancePresence } from '@tldraw/tlschema' import { useMemo } from 'react' -import { useValue } from 'signia-react' import { useEditor } from './useEditor' // TODO: maybe move this to a computed property on the App class? diff --git a/packages/editor/src/lib/hooks/useZoomCss.ts b/packages/editor/src/lib/hooks/useZoomCss.ts index f7f27f99e..88664d300 100644 --- a/packages/editor/src/lib/hooks/useZoomCss.ts +++ b/packages/editor/src/lib/hooks/useZoomCss.ts @@ -1,6 +1,6 @@ +import { EffectScheduler } from '@tldraw/state' import { debounce } from '@tldraw/utils' import * as React from 'react' -import { EffectScheduler } from 'signia' import { useContainer } from './useContainer' import { useEditor } from './useEditor' diff --git a/packages/editor/src/lib/utils/debug-flags.ts b/packages/editor/src/lib/utils/debug-flags.ts index 02012654c..2215a013d 100644 --- a/packages/editor/src/lib/utils/debug-flags.ts +++ b/packages/editor/src/lib/utils/debug-flags.ts @@ -1,4 +1,4 @@ -import { Atom, atom, react } from 'signia' +import { Atom, atom, react } from '@tldraw/state' // --- 1. DEFINE --- // diff --git a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts index d368f26f9..b1b199fa9 100644 --- a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts +++ b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts @@ -1,3 +1,4 @@ +import { Signal, transact } from '@tldraw/state' import { RecordsDiff, SerializedSchema, @@ -7,7 +8,6 @@ import { } from '@tldraw/store' import { TLStore } from '@tldraw/tlschema' import { assert } from '@tldraw/utils' -import { Signal, transact } from 'signia' import { TAB_ID, TLSessionStateSnapshot, diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index 056095fb8..be1a289df 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../store" }, { "path": "../validate" }, { "path": "../utils" }, - { "path": "../indices" } + { "path": "../indices" }, + { "path": "../state" } ] } diff --git a/packages/state/CHANGELOG.md b/packages/state/CHANGELOG.md new file mode 100644 index 000000000..836db4df9 --- /dev/null +++ b/packages/state/CHANGELOG.md @@ -0,0 +1,167 @@ +# v2.0.0-alpha.12 (Mon Apr 03 2023) + +#### 🐛 Bug Fix + +- Make sure all types and build stuff get run in CI [#1548](https://github.com/tldraw/tldraw-lite/pull/1548) ([@SomeHats](https://github.com/SomeHats)) +- add pre-commit api report generation [#1517](https://github.com/tldraw/tldraw-lite/pull/1517) ([@SomeHats](https://github.com/SomeHats)) +- [chore] restore api extractor [#1500](https://github.com/tldraw/tldraw-lite/pull/1500) ([@steveruizok](https://github.com/steveruizok)) +- Asset loading overhaul [#1457](https://github.com/tldraw/tldraw-lite/pull/1457) ([@SomeHats](https://github.com/SomeHats)) +- David/publish good [#1488](https://github.com/tldraw/tldraw-lite/pull/1488) ([@ds300](https://github.com/ds300)) +- [chore] alpha 10 [#1486](https://github.com/tldraw/tldraw-lite/pull/1486) ([@ds300](https://github.com/ds300)) +- [chore] package build improvements [#1484](https://github.com/tldraw/tldraw-lite/pull/1484) ([@ds300](https://github.com/ds300)) +- [chore] bump for alpha 8 [#1485](https://github.com/tldraw/tldraw-lite/pull/1485) ([@steveruizok](https://github.com/steveruizok)) +- [fix] page point offset [#1483](https://github.com/tldraw/tldraw-lite/pull/1483) ([@steveruizok](https://github.com/steveruizok)) +- stop using broken-af turbo for publishing [#1476](https://github.com/tldraw/tldraw-lite/pull/1476) ([@ds300](https://github.com/ds300)) +- [chore] add canary release script [#1423](https://github.com/tldraw/tldraw-lite/pull/1423) ([@ds300](https://github.com/ds300) [@steveruizok](https://github.com/steveruizok)) +- [chore] upgrade yarn [#1430](https://github.com/tldraw/tldraw-lite/pull/1430) ([@ds300](https://github.com/ds300)) +- flush store on attach [#1449](https://github.com/tldraw/tldraw-lite/pull/1449) ([@ds300](https://github.com/ds300)) +- [fix] dev version number for tldraw/tldraw [#1434](https://github.com/tldraw/tldraw-lite/pull/1434) ([@steveruizok](https://github.com/steveruizok)) +- repo cleanup [#1426](https://github.com/tldraw/tldraw-lite/pull/1426) ([@steveruizok](https://github.com/steveruizok)) +- [fix] use polyfill for `structuredClone` [#1408](https://github.com/tldraw/tldraw-lite/pull/1408) ([@TodePond](https://github.com/TodePond) [@steveruizok](https://github.com/steveruizok)) +- Run all the tests. Fix linting for tests. [#1389](https://github.com/tldraw/tldraw-lite/pull/1389) ([@MitjaBezensek](https://github.com/MitjaBezensek)) + +#### Authors: 5 + +- alex ([@SomeHats](https://github.com/SomeHats)) +- David Sheldrick ([@ds300](https://github.com/ds300)) +- Lu[ke] Wilson ([@TodePond](https://github.com/TodePond)) +- Mitja Bezenšek ([@MitjaBezensek](https://github.com/MitjaBezensek)) +- Steve Ruiz ([@steveruizok](https://github.com/steveruizok)) + +--- + +# @tldraw/tlstore + +## 2.0.0-alpha.11 + +### Patch Changes + +- fix some package build scripting +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.10 + +## 2.0.0-alpha.10 + +### Patch Changes + +- 4b4399b6e: redeploy with yarn to prevent package version issues +- Updated dependencies [4b4399b6e] + - @tldraw/utils@2.0.0-alpha.9 + +## 2.0.0-alpha.9 + +### Patch Changes + +- Release day! +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.8 + +## 2.0.0-alpha.8 + +### Patch Changes + +- 23dd81cfe: Make signia a peer dependency + +## 2.0.0-alpha.7 + +### Patch Changes + +- Bug fixes. +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.7 + +## 2.0.0-alpha.6 + +### Patch Changes + +- Add licenses. +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.6 + +## 2.0.0-alpha.5 + +### Patch Changes + +- Add CSS files to tldraw/tldraw. +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.5 + +## 2.0.0-alpha.4 + +### Patch Changes + +- Add children to tldraw/tldraw +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.4 + +## 2.0.0-alpha.3 + +### Patch Changes + +- Change permissions. +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.3 + +## 2.0.0-alpha.2 + +### Patch Changes + +- Add tldraw, editor +- Updated dependencies + - @tldraw/utils@2.0.0-alpha.2 + +## 0.1.0-alpha.11 + +### Patch Changes + +- Fix stale reactors. +- Updated dependencies + - @tldraw/utils@0.1.0-alpha.11 + +## 0.1.0-alpha.10 + +### Patch Changes + +- Fix type export bug. +- Updated dependencies + - @tldraw/utils@0.1.0-alpha.10 + +## 0.1.0-alpha.9 + +### Patch Changes + +- Fix import bugs. +- Updated dependencies + - @tldraw/utils@0.1.0-alpha.9 + +## 0.1.0-alpha.8 + +### Patch Changes + +- Changes validation requirements, exports validation helpers. +- Updated dependencies + - @tldraw/utils@0.1.0-alpha.8 + +## 0.1.0-alpha.7 + +### Patch Changes + +- - Pre-pre-release update +- Updated dependencies + - @tldraw/utils@0.1.0-alpha.7 + +## 0.0.2-alpha.1 + +### Patch Changes + +- Fix error with HMR +- Updated dependencies + - @tldraw/utils@0.0.2-alpha.1 + +## 0.0.2-alpha.0 + +### Patch Changes + +- Initial release +- Updated dependencies + - @tldraw/utils@0.0.2-alpha.0 diff --git a/packages/state/README.md b/packages/state/README.md new file mode 100644 index 000000000..565c1831e --- /dev/null +++ b/packages/state/README.md @@ -0,0 +1,3 @@ +# @tldraw/state + +... diff --git a/packages/state/api-extractor.json b/packages/state/api-extractor.json new file mode 100644 index 000000000..f1ed80e93 --- /dev/null +++ b/packages/state/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../config/api-extractor.json" +} diff --git a/packages/state/api-report.md b/packages/state/api-report.md new file mode 100644 index 000000000..d62691588 --- /dev/null +++ b/packages/state/api-report.md @@ -0,0 +1,170 @@ +## API Report File for "@tldraw/state" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { FunctionComponent } from 'react'; +import { default as React_2 } from 'react'; + +// @public +export interface Atom extends Signal { + set(value: Value, diff?: Diff): Value; + update(updater: (value: Value) => Value): Value; +} + +// @public +export function atom( +name: string, +initialValue: Value, +options?: AtomOptions): Atom; + +// @public +export interface AtomOptions { + computeDiff?: ComputeDiff; + historyLength?: number; + isEqual?: (a: any, b: any) => boolean; +} + +// @public +export interface Computed extends Signal { + readonly isActivelyListening: boolean; + // @internal (undocumented) + readonly parentEpochs: number[]; + // @internal (undocumented) + readonly parents: Signal[]; +} + +// @public +export function computed(name: string, compute: (previousValue: typeof UNINITIALIZED | Value, lastComputedEpoch: number) => Value | WithDiff, options?: ComputedOptions): Computed; + +// @public (undocumented) +export function computed(target: any, key: string, descriptor: PropertyDescriptor): PropertyDescriptor; + +// @public (undocumented) +export function computed(options?: ComputedOptions): (target: any, key: string, descriptor: PropertyDescriptor) => PropertyDescriptor; + +// @public +export interface ComputedOptions { + computeDiff?: ComputeDiff; + historyLength?: number; + isEqual?: (a: any, b: any) => boolean; +} + +// @public +export class EffectScheduler { + constructor(name: string, runEffect: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions); + attach(): void; + detach(): void; + execute(): Result; + get isActivelyListening(): boolean; + // @internal (undocumented) + lastTraversedEpoch: number; + // @internal (undocumented) + maybeScheduleEffect(): void; + // (undocumented) + readonly name: string; + // @internal (undocumented) + parentEpochs: number[]; + // @internal (undocumented) + parents: Signal[]; + get scheduleCount(): number; + // @internal (undocumented) + scheduleEffect(): void; +} + +// @public (undocumented) +export const EMPTY_ARRAY: []; + +// @public +export function getComputedInstance(obj: Obj, propertyName: Prop): Computed; + +// @public +export function isAtom(value: unknown): value is Atom; + +// @public +export function isSignal(value: any): value is Signal; + +// @public +export const isUninitialized: (value: any) => value is typeof UNINITIALIZED; + +// @public +export function react(name: string, fn: (lastReactedEpoch: number) => any, options?: EffectSchedulerOptions): () => void; + +// @public +export interface Reactor { + scheduler: EffectScheduler; + start(options?: { + force?: boolean; + }): void; + stop(): void; +} + +// @public +export function reactor(name: string, fn: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions): Reactor; + +// @public (undocumented) +export const RESET_VALUE: unique symbol; + +// @public (undocumented) +export type RESET_VALUE = typeof RESET_VALUE; + +// @public +export interface Signal { + __unsafe__getWithoutCapture(): Value; + // @internal (undocumented) + children: ArraySet; + getDiffSince(epoch: number): Diff[] | RESET_VALUE; + lastChangedEpoch: number; + name: string; + readonly value: Value; +} + +// @public +export function track>(baseComponent: T): T extends React_2.MemoExoticComponent ? T : React_2.MemoExoticComponent; + +// @public +export function transact(fn: () => T): T; + +// @public +export function transaction(fn: (rollback: () => void) => T): T; + +// @public +export function unsafe__withoutCapture(fn: () => T): T; + +// @public +export function useAtom( +name: string, +valueOrInitialiser: (() => Value) | Value, +options?: AtomOptions): Atom; + +// @public +export function useComputed(name: string, compute: () => Value, deps: any[]): Computed; + +// @public (undocumented) +export function useComputed(name: string, compute: () => Value, opts: ComputedOptions, deps: any[]): Computed; + +// @public (undocumented) +export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void; + +// @public (undocumented) +export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void; + +// @internal (undocumented) +export function useStateTracking(name: string, render: () => T): T; + +// @public +export function useValue(value: Signal): Value; + +// @public (undocumented) +export function useValue(name: string, fn: () => Value, deps: unknown[]): Value; + +// @public +export function whyAmIRunning(): void; + +// @public +export function withDiff(value: Value, diff: Diff): WithDiff; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/state/docs-ordering.json b/packages/state/docs-ordering.json new file mode 100644 index 000000000..1355f2b66 --- /dev/null +++ b/packages/state/docs-ordering.json @@ -0,0 +1,5 @@ +[ + ["functions", ["atom-1", "computed-1", "react", "reactor-1", "transact", "transaction"]], + ["classes"], + ["interfaces", ["Signal"]] +] diff --git a/packages/state/package.json b/packages/state/package.json new file mode 100644 index 000000000..a6b177b04 --- /dev/null +++ b/packages/state/package.json @@ -0,0 +1,71 @@ +{ + "name": "@tldraw/state", + "description": "A tiny little drawing app (state).", + "version": "2.0.0-alpha.12", + "packageManager": "yarn@3.5.0", + "author": { + "name": "tldraw GB Ltd.", + "email": "hello@tldraw.com" + }, + "homepage": "https://tldraw.dev", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/tldraw/tldraw" + }, + "bugs": { + "url": "https://github.com/tldraw/tldraw/issues" + }, + "keywords": [ + "tldraw", + "drawing", + "app", + "development", + "whiteboard", + "canvas", + "infinite" + ], + "/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish", + "main": "./src/index.ts", + "types": "./.tsbuild/index.d.ts", + "/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here", + "files": [], + "scripts": { + "test": "lazy inherit", + "test-coverage": "lazy inherit", + "build": "yarn run -T tsx ../../scripts/build-package.ts", + "build-api": "yarn run -T tsx ../../scripts/build-api.ts", + "prepack": "yarn run -T tsx ../../scripts/prepack.ts", + "postpack": "../../scripts/postpack.sh", + "pack-tarball": "yarn pack", + "lint": "yarn run -T tsx ../../scripts/lint.ts" + }, + "jest": { + "preset": "config/jest/node", + "setupFiles": [ + "raf/polyfill" + ], + "moduleNameMapper": { + "^~(.*)": "/src/$1" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(nanoid|escape-string-regexp)/)" + ] + }, + "devDependencies": { + "@types/lodash": "^4.14.188", + "@types/react": "^18.0.24", + "@types/react-test-renderer": "^18.0.0", + "lodash": "^4.17.21", + "react-test-renderer": "^18.2.0" + }, + "peerDependencies": { + "react": "^18" + }, + "typedoc": { + "readmeFile": "none", + "entryPoint": "./src/index.ts", + "displayName": "@tldraw/state", + "tsconfig": "./tsconfig.json" + } +} diff --git a/packages/state/src/index.ts b/packages/state/src/index.ts new file mode 100644 index 000000000..f56786f8a --- /dev/null +++ b/packages/state/src/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable local/no-export-star */ +export * from './lib/core' +export * from './lib/react' diff --git a/packages/state/src/lib/core/ArraySet.ts b/packages/state/src/lib/core/ArraySet.ts new file mode 100644 index 000000000..6ac59400d --- /dev/null +++ b/packages/state/src/lib/core/ArraySet.ts @@ -0,0 +1,146 @@ +// The maximum size for an array in an ArraySet +export const ARRAY_SIZE_THRESHOLD = 8 + +/** + * An ArraySet operates as an array until it reaches a certain size, after which a Set is used + * instead. In either case, the same methods are used to get, set, remove, and visit the items. + * @internal + */ +export class ArraySet { + private arraySize = 0 + + private array: (T | undefined)[] | null = Array(ARRAY_SIZE_THRESHOLD) + + private set: Set | null = null + + /** + * Get whether this ArraySet has any elements. + * + * @returns True if this ArraySet has any elements, false otherwise. + */ + get isEmpty() { + if (this.array) { + return this.arraySize === 0 + } + + if (this.set) { + return this.set.size === 0 + } + + throw new Error('no set or array') + } + + /** + * Add an item to the ArraySet if it is not already present. + * + * @param elem - The element to add. + */ + + add(elem: T) { + if (this.array) { + const idx = this.array.indexOf(elem) + + // Return false if the element is already in the array. + if (idx !== -1) { + return false + } + + if (this.arraySize < ARRAY_SIZE_THRESHOLD) { + // If the array is below the size threshold, push items into the array. + + // Insert the element into the array's next available slot. + this.array[this.arraySize] = elem + this.arraySize++ + + return true + } else { + // If the array is full, convert it to a set and remove the array. + this.set = new Set(this.array as any) + this.array = null + this.set.add(elem) + + return true + } + } + + if (this.set) { + // Return false if the element is already in the set. + if (this.set.has(elem)) { + return false + } + + this.set.add(elem) + return true + } + + throw new Error('no set or array') + } + + /** + * Remove an item from the ArraySet if it is present. + * + * @param elem - The element to remove + */ + remove(elem: T) { + if (this.array) { + const idx = this.array.indexOf(elem) + + // If the item is not in the array, return false. + if (idx === -1) { + return false + } + + this.array[idx] = undefined + this.arraySize-- + + if (idx !== this.arraySize) { + // If the item is not the last item in the array, move the last item into the + // removed item's slot. + this.array[idx] = this.array[this.arraySize] + this.array[this.arraySize] = undefined + } + + return true + } + + if (this.set) { + // If the item is not in the set, return false. + if (!this.set.has(elem)) { + return false + } + + this.set.delete(elem) + + return true + } + + throw new Error('no set or array') + } + + /** + * Run a callback for each element in the ArraySet. + * + * @param visitor - The callback to run for each element. + */ + visit(visitor: (item: T) => void) { + if (this.array) { + for (let i = 0; i < this.arraySize; i++) { + const elem = this.array[i] + + if (typeof elem !== 'undefined') { + visitor(elem) + } + } + + return + } + + if (this.set) { + this.set.forEach(visitor) + + return + } + + throw new Error('no set or array') + } +} diff --git a/packages/state/src/lib/core/Atom.ts b/packages/state/src/lib/core/Atom.ts new file mode 100644 index 000000000..6836a80b5 --- /dev/null +++ b/packages/state/src/lib/core/Atom.ts @@ -0,0 +1,196 @@ +import { ArraySet } from './ArraySet' +import { maybeCaptureParent } from './capture' +import { EMPTY_ARRAY, equals } from './helpers' +import { HistoryBuffer } from './HistoryBuffer' +import { advanceGlobalEpoch, atomDidChange, globalEpoch } from './transactions' +import { Child, ComputeDiff, RESET_VALUE, Signal } from './types' + +/** + * The options to configure an atom, passed into the [[atom]] function. + * @public + */ +export interface AtomOptions { + /** + * The maximum number of diffs to keep in the history buffer. + * + * If you don't need to compute diffs, or if you will supply diffs manually via [[Atom.set]], you can leave this as `undefined` and no history buffer will be created. + * + * If you expect the value to be part of an active effect subscription all the time, and to not change multiple times inside of a single transaction, you can set this to a relatively low number (e.g. 10). + * + * Otherwise, set this to a higher number based on your usage pattern and memory constraints. + * + */ + historyLength?: number + /** + * A method used to compute a diff between the atom's old and new values. If provided, it will not be used unless you also specify [[AtomOptions.historyLength]]. + */ + computeDiff?: ComputeDiff + /** + * If provided, this will be used to compare the old and new values of the atom to determine if the value has changed. + * By default, values are compared using first using strict equality (`===`), then `Object.is`, and finally any `.equals` method present in the object's prototype chain. + * @param a - The old value + * @param b - The new value + * @returns + */ + isEqual?: (a: any, b: any) => boolean +} + +/** + * An Atom is a signal that can be updated directly by calling [[Atom.set]] or [[Atom.update]]. + * + * Atoms are created using the [[atom]] function. + * + * @example + * ```ts + * const name = atom('name', 'John') + * + * console.log(name.value) // 'John' + * ``` + * + * @public + */ +export interface Atom extends Signal { + /** + * Sets the value of this atom to the given value. If the value is the same as the current value, this is a no-op. + * + * @param value - The new value to set. + * @param diff - The diff to use for the update. If not provided, the diff will be computed using [[AtomOptions.computeDiff]]. + */ + set(value: Value, diff?: Diff): Value + /** + * Updates the value of this atom using the given updater function. If the returned value is the same as the current value, this is a no-op. + * + * @param updater - A function that takes the current value and returns the new value. + */ + update(updater: (value: Value) => Value): Value +} + +/** + * @internal + */ +export class _Atom implements Atom { + constructor( + public readonly name: string, + private current: Value, + options?: AtomOptions + ) { + this.isEqual = options?.isEqual ?? null + + if (!options) return + + if (options.historyLength) { + this.historyBuffer = new HistoryBuffer(options.historyLength) + } + + this.computeDiff = options.computeDiff + } + + readonly isEqual: null | ((a: any, b: any) => boolean) + + computeDiff?: ComputeDiff + + lastChangedEpoch = globalEpoch + + children = new ArraySet() + + historyBuffer?: HistoryBuffer + + __unsafe__getWithoutCapture(): Value { + return this.current + } + + get value() { + maybeCaptureParent(this) + return this.current + } + + set(value: Value, diff?: Diff): Value { + // If the value has not changed, do nothing. + if (this.isEqual?.(this.current, value) ?? equals(this.current, value)) { + return this.current + } + + // Tick forward the global epoch + advanceGlobalEpoch() + + // Add the diff to the history buffer. + if (this.historyBuffer) { + this.historyBuffer.pushEntry( + this.lastChangedEpoch, + globalEpoch, + diff ?? + this.computeDiff?.(this.current, value, this.lastChangedEpoch, globalEpoch) ?? + RESET_VALUE + ) + } + + // Update the atom's record of the epoch when last changed. + this.lastChangedEpoch = globalEpoch + + const oldValue = this.current + this.current = value + + // Notify all children that this atom has changed. + atomDidChange(this, oldValue) + + return value + } + + update(updater: (value: Value) => Value): Value { + return this.set(updater(this.current)) + } + + getDiffSince(epoch: number): RESET_VALUE | Diff[] { + maybeCaptureParent(this) + + // If no changes have occurred since the given epoch, return an empty array. + if (epoch >= this.lastChangedEpoch) { + return EMPTY_ARRAY + } + + return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE + } +} + +/** + * Creates a new [[Atom]]. + * + * An Atom is a signal that can be updated directly by calling [[Atom.set]] or [[Atom.update]]. + * + * @example + * ```ts + * const name = atom('name', 'John') + * + * name.value // 'John' + * + * name.set('Jane') + * + * name.value // 'Jane' + * ``` + * + * @public + */ +export function atom( + /** + * A name for the signal. This is used for debugging and profiling purposes, it does not need to be unique. + */ + name: string, + /** + * The initial value of the signal. + */ + initialValue: Value, + /** + * The options to configure the atom. See [[AtomOptions]]. + */ + options?: AtomOptions +): Atom { + return new _Atom(name, initialValue, options) +} + +/** + * Returns true if the given value is an [[Atom]]. + * @public + */ +export function isAtom(value: unknown): value is Atom { + return value instanceof _Atom +} diff --git a/packages/state/src/lib/core/Computed.ts b/packages/state/src/lib/core/Computed.ts new file mode 100644 index 000000000..625944ed0 --- /dev/null +++ b/packages/state/src/lib/core/Computed.ts @@ -0,0 +1,378 @@ +/* eslint-disable prefer-rest-params */ +import { ArraySet } from './ArraySet' +import { HistoryBuffer } from './HistoryBuffer' +import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture' +import { GLOBAL_START_EPOCH } from './constants' +import { EMPTY_ARRAY, equals, haveParentsChanged } from './helpers' +import { globalEpoch } from './transactions' +import { Child, ComputeDiff, RESET_VALUE, Signal } from './types' + +const UNINITIALIZED = Symbol('UNINITIALIZED') +/** + * The type of the first value passed to a computed signal function as the 'prevValue' parameter. + * + * @see [[isUninitialized]]. + * @public + */ +type UNINITIALIZED = typeof UNINITIALIZED + +/** + * Call this inside a computed signal function to determine whether it is the first time the function is being called. + * + * Mainly useful for incremental signal computation. + * + * @example + * ```ts + * const count = atom('count', 0) + * const double = computed('double', (prevValue) => { + * if (isUninitialized(prevValue)) { + * console.log('First time!') + * } + * return count.value * 2 + * }) + * ``` + * + * @param value - The value to check. + * @public + */ +export const isUninitialized = (value: any): value is UNINITIALIZED => { + return value === UNINITIALIZED +} + +class WithDiff { + constructor(public value: Value, public diff: Diff) {} +} + +/** + * When writing incrementally-computed signals it is convenient (and usually more performant) to incrementally compute the diff too. + * + * You can use this function to wrap the return value of a computed signal function to indicate that the diff should be used instead of calculating a new one with [[AtomOptions.computeDiff]]. + * + * @example + * ```ts + * const count = atom('count', 0) + * const double = computed('double', (prevValue) => { + * const nextValue = count.value * 2 + * if (isUninitialized(prevValue)) { + * return nextValue + * } + * return withDiff(nextValue, nextValue - prevValue) + * }, { historyLength: 10 }) + * ``` + * + * + * @param value - The value. + * @param diff - The diff. + * @public + */ +export function withDiff(value: Value, diff: Diff): WithDiff { + return new WithDiff(value, diff) +} + +/** + * Options for creating computed signals. Used when calling [[computed]]. + * @public + */ +export interface ComputedOptions { + /** + * The maximum number of diffs to keep in the history buffer. + * + * If you don't need to compute diffs, or if you will supply diffs manually via [[Atom.set]], you can leave this as `undefined` and no history buffer will be created. + * + * If you expect the value to be part of an active effect subscription all the time, and to not change multiple times inside of a single transaction, you can set this to a relatively low number (e.g. 10). + * + * Otherwise, set this to a higher number based on your usage pattern and memory constraints. + * + */ + historyLength?: number + /** + * A method used to compute a diff between the atom's old and new values. If provided, it will not be used unless you also specify [[AtomOptions.historyLength]]. + */ + computeDiff?: ComputeDiff + /** + * If provided, this will be used to compare the old and new values of the atom to determine if the value has changed. + * By default, values are compared using first using strict equality (`===`), then `Object.is`, and finally any `.equals` method present in the object's prototype chain. + * @param a - The old value + * @param b - The new value + * @returns + */ + isEqual?: (a: any, b: any) => boolean +} + +/** + * A computed signal created via [[computed]]. + * + * @public + */ +export interface Computed extends Signal { + /** + * Whether this computed child is involved in an actively-running effect graph. + * @public + */ + readonly isActivelyListening: boolean + + /** @internal */ + readonly parents: Signal[] + /** @internal */ + readonly parentEpochs: number[] +} + +/** + * @internal + */ +export class _Computed implements Computed { + lastChangedEpoch = GLOBAL_START_EPOCH + lastTraversedEpoch = GLOBAL_START_EPOCH + + /** + * The epoch when the reactor was last checked. + */ + private lastCheckedEpoch = GLOBAL_START_EPOCH + + parents: Signal[] = [] + parentEpochs: number[] = [] + + children = new ArraySet() + + get isActivelyListening(): boolean { + return !this.children.isEmpty + } + + historyBuffer?: HistoryBuffer + + // The last-computed value of this signal. + private state: Value = UNINITIALIZED as unknown as Value + + private computeDiff?: ComputeDiff + + private readonly isEqual: (a: any, b: any) => boolean + + constructor( + /** + * The name of the signal. This is used for debugging and performance profiling purposes. It does not need to be globally unique. + */ + public readonly name: string, + /** + * The function that computes the value of the signal. + */ + private readonly derive: ( + previousValue: Value | UNINITIALIZED, + lastComputedEpoch: number + ) => Value | WithDiff, + options?: ComputedOptions + ) { + if (options?.historyLength) { + this.historyBuffer = new HistoryBuffer(options.historyLength) + } + this.computeDiff = options?.computeDiff + this.isEqual = options?.isEqual ?? equals + } + + __unsafe__getWithoutCapture(): Value { + const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH + + if (!isNew && (this.lastCheckedEpoch === globalEpoch || !haveParentsChanged(this))) { + this.lastCheckedEpoch = globalEpoch + return this.state + } + + try { + startCapturingParents(this) + const result = this.derive(this.state, this.lastCheckedEpoch) + const newState = result instanceof WithDiff ? result.value : result + if (this.state === UNINITIALIZED || !this.isEqual(newState, this.state)) { + if (this.historyBuffer && !isNew) { + const diff = result instanceof WithDiff ? result.diff : undefined + this.historyBuffer.pushEntry( + this.lastChangedEpoch, + globalEpoch, + diff ?? + this.computeDiff?.(this.state, newState, this.lastCheckedEpoch, globalEpoch) ?? + RESET_VALUE + ) + } + this.lastChangedEpoch = globalEpoch + this.state = newState + } + this.lastCheckedEpoch = globalEpoch + + return this.state + } finally { + stopCapturingParents() + } + } + + get value(): Value { + const value = this.__unsafe__getWithoutCapture() + maybeCaptureParent(this) + return value + } + + getDiffSince(epoch: number): RESET_VALUE | Diff[] { + // need to call .value to ensure both that this derivation is up to date + // and that tracking happens correctly + this.value + + if (epoch >= this.lastChangedEpoch) { + return EMPTY_ARRAY + } + + return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE + } +} + +function computedAnnotation( + options: ComputedOptions = {}, + _target: any, + key: string, + descriptor: PropertyDescriptor +) { + const originalMethod = descriptor.get + const derivationKey = Symbol.for('__@tldraw/state__computed__' + key) + + descriptor.get = function (this: any) { + let d = this[derivationKey] as _Computed | undefined + + if (!d) { + d = new _Computed(key, originalMethod!.bind(this) as any, options) + Object.defineProperty(this, derivationKey, { + enumerable: false, + configurable: false, + writable: false, + value: d, + }) + } + return d.value + } + + return descriptor +} + +/** + * Retrieves the underlying computed instance for a given property created with the [[computed]] + * decorator. + * + * @example + * ```ts + * class Counter { + * max = 100 + * count = atom(0) + * + * @computed get remaining() { + * return this.max - this.count.value + * } + * } + * + * const c = new Counter() + * const remaining = getComputedInstance(c, 'remaining') + * remaining.value === 100 // true + * c.count.set(13) + * remaining.value === 87 // true + * ``` + * + * @param obj - The object + * @param propertyName - The property name + * @public + */ +export function getComputedInstance( + obj: Obj, + propertyName: Prop +): Computed { + // deref to make sure it exists first + const key = Symbol.for('__@tldraw/state__computed__' + propertyName.toString()) + let inst = obj[key as keyof typeof obj] as _Computed | undefined + if (!inst) { + // deref to make sure it exists first + obj[propertyName] + inst = obj[key as keyof typeof obj] as _Computed | undefined + } + return inst as any +} + +/** + * Creates a computed signal. + * + * @example + * ```ts + * const name = atom('name', 'John') + * const greeting = computed('greeting', () => `Hello ${name.value}!`) + * console.log(greeting.value) // 'Hello John!' + * ``` + * + * `computed` may also be used as a decorator for creating computed class properties. + * + * @example + * ```ts + * class Counter { + * max = 100 + * count = atom(0) + * + * @computed get remaining() { + * return this.max - this.count.value + * } + * } + * ``` + * + * You may optionally pass in a [[ComputedOptions]] when used as a decorator: + * + * @example + * ```ts + * class Counter { + * max = 100 + * count = atom(0) + * + * @computed({isEqual: (a, b) => a === b}) + * get remaining() { + * return this.max - this.count.value + * } + * } + * ``` + * + * @param name - The name of the signal. + * @param compute - The function that computes the value of the signal. + * @param options - Options for the signal. + * + * @public + */ +export function computed( + name: string, + compute: ( + previousValue: Value | typeof UNINITIALIZED, + lastComputedEpoch: number + ) => Value | WithDiff, + options?: ComputedOptions +): Computed + +/** @public */ +export function computed( + target: any, + key: string, + descriptor: PropertyDescriptor +): PropertyDescriptor +/** @public */ +export function computed( + options?: ComputedOptions +): (target: any, key: string, descriptor: PropertyDescriptor) => PropertyDescriptor +/** @public */ +export function computed() { + if (arguments.length === 1) { + const options = arguments[0] + return (target: any, key: string, descriptor: PropertyDescriptor) => + computedAnnotation(options, target, key, descriptor) + } else if (typeof arguments[0] === 'string') { + return new _Computed(arguments[0], arguments[1], arguments[2]) + } else { + return computedAnnotation(undefined, arguments[0], arguments[1], arguments[2]) + } +} + +/** + * Returns true if the given value is a computed signal. + * + * @param value + * @returns {value is Computed} + * @public + */ +export function isComputed(value: any): value is Computed { + return value && value instanceof _Computed +} diff --git a/packages/state/src/lib/core/EffectScheduler.ts b/packages/state/src/lib/core/EffectScheduler.ts new file mode 100644 index 000000000..6b995d76f --- /dev/null +++ b/packages/state/src/lib/core/EffectScheduler.ts @@ -0,0 +1,268 @@ +import { startCapturingParents, stopCapturingParents } from './capture' +import { GLOBAL_START_EPOCH } from './constants' +import { attach, detach, haveParentsChanged } from './helpers' +import { globalEpoch } from './transactions' +import { Signal } from './types' + +interface EffectSchedulerOptions { + /** + * scheduleEffect is a function that will be called when the effect is scheduled. + * + * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame. + * + * + * @example + * ```ts + * let isRafScheduled = false + * const scheduledEffects: Array<() => void> = [] + * const scheduleEffect = (runEffect: () => void) => { + * scheduledEffects.push(runEffect) + * if (!isRafScheduled) { + * isRafScheduled = true + * requestAnimationFrame(() => { + * isRafScheduled = false + * scheduledEffects.forEach((runEffect) => runEffect()) + * scheduledEffects.length = 0 + * }) + * } + * } + * const stop = react('set page title', () => { + * document.title = doc.title, + * }, scheduleEffect) + * ``` + * + * @param execute - A function that will execute the effect. + * @returns + */ + scheduleEffect?: (execute: () => void) => void +} + +/** + * An EffectScheduler is responsible for executing side effects in response to changes in state. + * + * You probably don't need to use this directly unless you're integrating this library with a framework of some kind. + * + * Instead, use the [[react]] and [[reactor]] functions. + * + * @example + * ```ts + * const render = new EffectScheduler('render', drawToCanvas) + * + * render.attach() + * render.execute() + * ``` + * + * @public + */ +export class EffectScheduler { + private _isActivelyListening = false + /** + * Whether this scheduler is attached and actively listening to its parents. + * @public + */ + get isActivelyListening() { + return this._isActivelyListening + } + /** @internal */ + lastTraversedEpoch = GLOBAL_START_EPOCH + + private lastReactedEpoch = GLOBAL_START_EPOCH + private _scheduleCount = 0 + + /** + * The number of times this effect has been scheduled. + * @public + */ + get scheduleCount() { + return this._scheduleCount + } + + /** @internal */ + parentEpochs: number[] = [] + /** @internal */ + parents: Signal[] = [] + private readonly _scheduleEffect?: (execute: () => void) => void + constructor( + public readonly name: string, + private readonly runEffect: (lastReactedEpoch: number) => Result, + options?: EffectSchedulerOptions + ) { + this._scheduleEffect = options?.scheduleEffect + } + + /** @internal */ + maybeScheduleEffect() { + // bail out if we have been cancelled by another effect + if (!this._isActivelyListening) return + // bail out if no atoms have changed since the last time we ran this effect + if (this.lastReactedEpoch === globalEpoch) return + + // bail out if we have parents and they have not changed since last time + if (this.parents.length && !haveParentsChanged(this)) { + this.lastReactedEpoch = globalEpoch + return + } + // if we don't have parents it's probably the first time this is running. + this.scheduleEffect() + } + + /** @internal */ + scheduleEffect() { + this._scheduleCount++ + if (this._scheduleEffect) { + // if the effect should be deferred (e.g. until a react render), do so + this._scheduleEffect(this.maybeExecute) + } else { + // otherwise execute right now! + this.execute() + } + } + + private maybeExecute = () => { + // bail out if we have been detached before this runs + if (!this._isActivelyListening) return + this.execute() + } + + /** + * Makes this scheduler become 'actively listening' to its parents. + * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls. + * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling [[EffectScheduler.execute]]. + * @public + */ + attach() { + this._isActivelyListening = true + for (let i = 0, n = this.parents.length; i < n; i++) { + attach(this.parents[i], this) + } + } + + /** + * Makes this scheduler stop 'actively listening' to its parents. + * It will no longer be eligible to receive 'maybeScheduleEffect' calls until [[EffectScheduler.attach]] is called again. + */ + detach() { + this._isActivelyListening = false + for (let i = 0, n = this.parents.length; i < n; i++) { + detach(this.parents[i], this) + } + } + + /** + * Executes the effect immediately and returns the result. + * @returns The result of the effect. + */ + execute(): Result { + try { + startCapturingParents(this) + const result = this.runEffect(this.lastReactedEpoch) + this.lastReactedEpoch = globalEpoch + return result + } finally { + stopCapturingParents() + } + } +} + +/** + * Starts a new effect scheduler, scheduling the effect immediately. + * + * Returns a function that can be called to stop the scheduler. + * + * @example + * ```ts + * const color = atom('color', 'red') + * const stop = react('set style', () => { + * divElem.style.color = color.value + * }) + * color.set('blue') + * // divElem.style.color === 'blue' + * stop() + * color.set('green') + * // divElem.style.color === 'blue' + * ``` + * + * + * Also useful in React applications for running effects outside of the render cycle. + * + * @example + * ```ts + * useEffect(() => react('set style', () => { + * divRef.current.style.color = color.value + * }), []) + * ``` + * + * @public + */ +export function react( + name: string, + fn: (lastReactedEpoch: number) => any, + options?: EffectSchedulerOptions +) { + const scheduler = new EffectScheduler(name, fn, options) + scheduler.attach() + scheduler.scheduleEffect() + return () => { + scheduler.detach() + } +} + +/** + * The reactor is a user-friendly interface for starting and stopping an [[EffectScheduler]]. + * + * Calling .start() will attach the scheduler and execute the effect immediately the first time it is called. + * + * If the reactor is stopped, calling `.start()` will re-attach the scheduler but will only execute the effect if any of its parents have changed since it was stopped. + * + * You can create a reactor with [[reactor]]. + * @public + */ +export interface Reactor { + /** + * The underlying effect scheduler. + * @public + */ + scheduler: EffectScheduler + /** + * Start the scheduler. The first time this is called the effect will be scheduled immediately. + * + * If the reactor is stopped, calling this will start the scheduler again but will only execute the effect if any of its parents have changed since it was stopped. + * + * If you need to force re-execution of the effect, pass `{ force: true }`. + * @public + */ + start(options?: { force?: boolean }): void + /** + * Stop the scheduler. + * @public + */ + stop(): void +} + +/** + * Creates a [[Reactor]], which is a thin wrapper around an [[EffectScheduler]]. + * + * @public + */ +export function reactor( + name: string, + fn: (lastReactedEpoch: number) => Result, + options?: EffectSchedulerOptions +): Reactor { + const scheduler = new EffectScheduler(name, fn, options) + return { + scheduler, + start: (options?: { force?: boolean }) => { + const force = options?.force ?? false + scheduler.attach() + if (force) { + scheduler.scheduleEffect() + } else { + scheduler.maybeScheduleEffect() + } + }, + stop: () => { + scheduler.detach() + }, + } +} diff --git a/packages/state/src/lib/core/HistoryBuffer.ts b/packages/state/src/lib/core/HistoryBuffer.ts new file mode 100644 index 000000000..607161ff3 --- /dev/null +++ b/packages/state/src/lib/core/HistoryBuffer.ts @@ -0,0 +1,95 @@ +import { RESET_VALUE } from './types' + +type RangeTuple = [fromEpoch: number, toEpoch: number, diff: Diff] + +/** + * A structure that stores diffs between values of an atom. + * + * @internal + */ +export class HistoryBuffer { + private index = 0 + + // use a wrap around buffer to store the last N values + buffer: Array | undefined> + + constructor(private readonly capacity: number) { + this.buffer = new Array(capacity) + } + + /** + * Add a diff to the history buffer. + * + * @param lastComputedEpoch - The epoch when the diff was computed. + * @param currentEpoch - The current epoch. + * @param diff - (optional) The diff to add, or else a reset value. + */ + pushEntry(lastComputedEpoch: number, currentEpoch: number, diff: Diff | RESET_VALUE) { + if (diff === undefined) { + return + } + + if (diff === RESET_VALUE) { + this.clear() + return + } + + // Add the diff to the buffer as a range tuple. + this.buffer[this.index] = [lastComputedEpoch, currentEpoch, diff] + + // Bump the index, wrapping around if necessary. + this.index = (this.index + 1) % this.capacity + } + + /** + * Clear the history buffer. + */ + clear() { + this.index = 0 + this.buffer.fill(undefined) + } + + /** + * Get the diffs since the given epoch. + * + * @param epoch - The epoch to get diffs since. + * @returns An array of diffs or a flag to reset the history buffer. + */ + getChangesSince(sinceEpoch: number): RESET_VALUE | Diff[] { + const { index, capacity, buffer } = this + + // For each item in the buffer... + for (let i = 0; i < capacity; i++) { + const offset = (index - 1 + capacity - i) % capacity + + const elem = buffer[offset] + + // If there's no element in the offset position, return the reset value + if (!elem) { + return RESET_VALUE + } + + const [fromEpoch, toEpoch] = elem + + // If the first element is already too early, bail + if (i === 0 && sinceEpoch >= toEpoch) { + return [] + } + + // If the element is since the given epoch, return an array with all diffs from this element and all following elements + if (fromEpoch <= sinceEpoch && sinceEpoch < toEpoch) { + const len = i + 1 + const result = new Array(len) + + for (let j = 0; j < len; j++) { + result[j] = buffer[(offset + j) % capacity]![2] + } + + return result + } + } + + // If we haven't returned yet, return the reset value + return RESET_VALUE + } +} diff --git a/packages/state/src/lib/core/__tests__/EffectScheduler.test.ts b/packages/state/src/lib/core/__tests__/EffectScheduler.test.ts new file mode 100644 index 000000000..c9af538da --- /dev/null +++ b/packages/state/src/lib/core/__tests__/EffectScheduler.test.ts @@ -0,0 +1,82 @@ +import { atom } from '../Atom' +import { EffectScheduler } from '../EffectScheduler' + +describe(EffectScheduler, () => { + test('when you detatch and reattach, it retains the parents without rerunning', () => { + const a = atom('a', 1) + let numReactions = 0 + const scheduler = new EffectScheduler('test', () => { + a.value + numReactions++ + }) + scheduler.attach() + scheduler.execute() + expect(numReactions).toBe(1) + a.set(2) + expect(numReactions).toBe(2) + scheduler.detach() + expect(numReactions).toBe(2) + scheduler.attach() + expect(numReactions).toBe(2) + a.set(3) + expect(numReactions).toBe(3) + }) + + test('when you detatch and reattach, it retains the parents while rerunning if the parent has changed', () => { + const a = atom('a', 1) + let numReactions = 0 + const scheduler = new EffectScheduler('test', () => { + a.value + numReactions++ + }) + scheduler.attach() + scheduler.execute() + expect(numReactions).toBe(1) + a.set(2) + expect(numReactions).toBe(2) + scheduler.detach() + a.set(3) + expect(numReactions).toBe(2) + scheduler.attach() + scheduler.execute() + expect(numReactions).toBe(3) + a.set(4) + expect(numReactions).toBe(4) + }) + + test('when an effect is scheduled it increments a schedule count, even if the effect never runs', () => { + const a = atom('a', 1) + let numReactions = 0 + let numSchedules = 0 + const scheduler = new EffectScheduler( + 'test', + () => { + a.value + numReactions++ + }, + { + scheduleEffect: () => { + numSchedules++ + }, + } + ) + scheduler.attach() + scheduler.execute() + + expect(scheduler.scheduleCount).toBe(0) + expect(numSchedules).toBe(0) + expect(numReactions).toBe(1) + + a.set(2) + + expect(scheduler.scheduleCount).toBe(1) + expect(numSchedules).toBe(1) + expect(numReactions).toBe(1) + + a.set(3) + + expect(scheduler.scheduleCount).toBe(2) + expect(numSchedules).toBe(2) + expect(numReactions).toBe(1) + }) +}) diff --git a/packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts b/packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts new file mode 100644 index 000000000..110c71d36 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts @@ -0,0 +1,56 @@ +import { HistoryBuffer } from '../HistoryBuffer' +import { RESET_VALUE } from '../types' + +describe('HistoryBuffer', () => { + it('should wrap around', () => { + const buf = new HistoryBuffer(3) + buf.pushEntry(0, 1, 'a') + expect(buf.getChangesSince(0)).toEqual(['a']) + expect(buf.getChangesSince(1)).toEqual([]) + + buf.pushEntry(1, 2, 'b') + + expect(buf.getChangesSince(0)).toEqual(['a', 'b']) + expect(buf.getChangesSince(1)).toEqual(['b']) + expect(buf.getChangesSince(2)).toEqual([]) + + buf.pushEntry(2, 3, 'c') + + expect(buf.getChangesSince(0)).toEqual(['a', 'b', 'c']) + expect(buf.getChangesSince(1)).toEqual(['b', 'c']) + expect(buf.getChangesSince(2)).toEqual(['c']) + expect(buf.getChangesSince(3)).toEqual([]) + + buf.pushEntry(3, 4, 'd') + + expect(buf.getChangesSince(0)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(1)).toEqual(['b', 'c', 'd']) + expect(buf.getChangesSince(2)).toEqual(['c', 'd']) + expect(buf.getChangesSince(3)).toEqual(['d']) + expect(buf.getChangesSince(4)).toEqual([]) + + buf.pushEntry(4, 5, 'e') + + expect(buf.getChangesSince(0)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(1)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(2)).toEqual(['c', 'd', 'e']) + expect(buf.getChangesSince(3)).toEqual(['d', 'e']) + expect(buf.getChangesSince(4)).toEqual(['e']) + expect(buf.getChangesSince(5)).toEqual([]) + }) + + it('will clear if you push RESET_VALUE', () => { + const buf = new HistoryBuffer(10) + buf.pushEntry(0, 1, 'a') + buf.pushEntry(1, 2, 'b') + buf.pushEntry(2, 3, 'c') + buf.pushEntry(3, 4, 'd') + buf.pushEntry(4, 5, RESET_VALUE) + expect(buf.getChangesSince(0)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(1)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(2)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(3)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(4)).toEqual(RESET_VALUE) + expect(buf.getChangesSince(5)).toEqual(RESET_VALUE) + }) +}) diff --git a/packages/state/src/lib/core/__tests__/arraySet.test.ts b/packages/state/src/lib/core/__tests__/arraySet.test.ts new file mode 100644 index 000000000..fee4b082c --- /dev/null +++ b/packages/state/src/lib/core/__tests__/arraySet.test.ts @@ -0,0 +1,127 @@ +import { ARRAY_SIZE_THRESHOLD, ArraySet } from '../ArraySet' + +const get = (set: ArraySet) => { + const s = new Set() + + set.visit((i) => s.add(i)) + return s +} + +describe(ArraySet, () => { + it('works with small numbers of things', () => { + const as = new ArraySet() + + expect(as.isEmpty).toBe(true) + + as.add(3) + as.add(5) + as.add(8) + as.add(9) + + expect(get(as)).toEqual(new Set([3, 5, 8, 9])) + + as.remove(8) + + expect(get(as)).toEqual(new Set([3, 5, 9])) + + as.add(10) + + expect(get(as)).toEqual(new Set([3, 5, 9, 10])) + + as.add(12) + + expect(get(as)).toEqual(new Set([3, 5, 9, 10, 12])) + + as.remove(5) + as.remove(9) + + expect(get(as)).toEqual(new Set([3, 10, 12])) + + as.remove(9) + + expect(get(as)).toEqual(new Set([3, 10, 12])) + + as.add(123) + as.add(234) + + expect(get(as)).toEqual(new Set([3, 10, 12, 123, 234])) + + expect(as.isEmpty).toBe(false) + + as.remove(123) + as.remove(234) + as.remove(3) + as.remove(10) + expect(as.isEmpty).toBe(false) + as.remove(12) + expect(as.isEmpty).toBe(true) + }) + + it('works with large numbers of things', () => { + const as = new ArraySet() + + expect(as.isEmpty).toBe(true) + for (let i = 0; i < 100; i++) { + as.add(i) + } + + expect(get(as)).toEqual(new Set(Array.from({ length: 100 }, (_, i) => i))) + + expect(as.isEmpty).toBe(false) + for (let i = 0; i < 100; i++) { + as.remove(i) + } + + expect(get(as)).toEqual(new Set()) + expect(as.isEmpty).toBe(true) + }) +}) + +function rng(seed: number) { + return () => { + const x = Math.sin(seed++) * 10000 + return x - Math.floor(x) + } +} + +function runTest(seed: number) { + const as = new ArraySet() + const s = new Set() + const r = rng(seed) + + const nums = new Array(ARRAY_SIZE_THRESHOLD * 2).fill(0).map(() => Math.floor(r() * 100)) + + for (let i = 0; i < 1000; i++) { + const num = nums[Math.floor(r() * nums.length)] + + if (r() > 0.5) { + as.add(num) + s.add(num) + } else { + as.remove(num) + s.delete(num) + } + + try { + expect(get(as)).toEqual(s) + } catch (e) { + console.error('Failed on iteration', i, 'with seed', seed) + throw e + } + } +} + +describe('fuzzing this thing (if this fails tell david)', () => { + new Array(10).fill(0).forEach(() => { + const seed = Math.floor(Math.random() * 1000000) + it(`fuzz with seed ${seed}`, () => { + runTest(seed) + }) + }) +}) + +describe('regression tests', () => { + it('passes with seed 354923', () => { + runTest(354923) + }) +}) diff --git a/packages/state/src/lib/core/__tests__/atom.test.ts b/packages/state/src/lib/core/__tests__/atom.test.ts new file mode 100644 index 000000000..0c0ad8499 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/atom.test.ts @@ -0,0 +1,199 @@ +import { atom } from '../Atom' +import { reactor } from '../EffectScheduler' +import { globalEpoch, transact, transaction } from '../transactions' +import { RESET_VALUE } from '../types' + +describe('atoms', () => { + it('contain data', () => { + const a = atom('', 1) + + expect(a.value).toBe(1) + }) + it('can be updated', () => { + const a = atom('', 1) + + a.set(2) + + expect(a.value).toBe(2) + }) + it('will not advance the global epoch on creation', () => { + const startEpoch = globalEpoch + atom('', 3) + expect(globalEpoch).toBe(startEpoch) + }) + it('will advance the global epoch on .set', () => { + const startEpoch = globalEpoch + const a = atom('', 3) + a.set(4) + expect(globalEpoch).toBe(startEpoch + 1) + }) + it('can store history', () => { + const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a }) + + const startEpoch = globalEpoch + + expect(a.getDiffSince(startEpoch)).toEqual([]) + + a.set(5) + + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + + a.set(10) + + expect(a.getDiffSince(startEpoch)).toEqual([+4, +5]) + + a.set(20) + + expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10]) + + a.set(30) + + // will be RESET_VALUE because we don't have enough history + expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE) + }) + it('has history independent of other atoms', () => { + const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a }) + const b = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a }) + + const startEpoch = globalEpoch + + b.set(-5) + b.set(-10) + b.set(-20) + expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10]) + expect(b.getDiffSince(globalEpoch)).toEqual([]) + + expect(a.getDiffSince(startEpoch)).toEqual([]) + a.set(5) + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10]) + expect(b.getDiffSince(globalEpoch)).toEqual([]) + }) + it('still updates history during transactions', () => { + const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a }) + + const startEpoch = globalEpoch + + transact(() => { + expect(a.getDiffSince(startEpoch)).toEqual([]) + + a.set(5) + + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + + a.set(10) + + expect(a.getDiffSince(startEpoch)).toEqual([+4, +5]) + + a.set(20) + + expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10]) + }) + + expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10]) + }) + it('will clear the history if the transaction aborts', () => { + const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a }) + + const startEpoch = globalEpoch + + transaction((rollback) => { + expect(a.getDiffSince(startEpoch)).toEqual([]) + + a.set(5) + + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + + rollback() + }) + + expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE) + }) + it('supports an update operation', () => { + const startEpoch = globalEpoch + const a = atom('', 1) + + a.update((value) => value + 1) + + expect(a.value).toBe(2) + expect(globalEpoch).toBe(startEpoch + 1) + }) + it('supports passing diffs in .set', () => { + const a = atom('', 1, { historyLength: 3 }) + + const startEpoch = globalEpoch + + a.set(5, +4) + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + + a.set(6, +1) + expect(a.getDiffSince(startEpoch)).toEqual([+4, +1]) + }) + it('does not push history if nothing changed', () => { + const a = atom('', 1, { historyLength: 3 }) + + const startEpoch = globalEpoch + + a.set(5, +4) + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + a.set(5, +4) + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + }) + it('clears the history buffer if you fail to provide a diff', () => { + const a = atom('', 1, { historyLength: 3 }) + const startEpoch = globalEpoch + + a.set(5, +4) + + expect(a.getDiffSince(startEpoch)).toEqual([+4]) + + a.set(6) + + expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE) + }) +}) + +describe('reacting to atoms', () => { + it('should work', async () => { + const a = atom('', 234) + + let val = 0 + const r = reactor('', () => { + val = a.value + }) + + expect(val).toBe(0) + + r.start() + + expect(val).toBe(234) + + a.set(939) + + expect(val).toBe(939) + + r.stop() + + a.set(2342) + + expect(val).toBe(939) + expect(a.value).toBe(2342) + }) +}) + +test('isEqual can provide custom equality checks', () => { + const foo = { hello: true } + const bar = { hello: true } + + const a = atom('a', foo) + + a.set(bar) + + expect(a.value).toBe(bar) + + const b = atom('b', foo, { isEqual: (a, b) => a.hello === b.hello }) + + b.set(bar) + + expect(b.value).toBe(foo) +}) diff --git a/packages/state/src/lib/core/__tests__/capture.test.ts b/packages/state/src/lib/core/__tests__/capture.test.ts new file mode 100644 index 000000000..a95ff0ed7 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/capture.test.ts @@ -0,0 +1,238 @@ +import { atom } from '../Atom' +import { computed } from '../Computed' +import { react } from '../EffectScheduler' +import { + maybeCaptureParent, + startCapturingParents, + stopCapturingParents, + unsafe__withoutCapture, +} from '../capture' +import { advanceGlobalEpoch, globalEpoch } from '../transactions' +import { Child } from '../types' + +const emptyChild = (props: Partial = {}) => + ({ + parentEpochs: [], + parents: [], + isActivelyListening: false, + lastTraversedEpoch: 0, + ...props, + } as Child) + +describe('capturing parents', () => { + it('can be started and stopped', () => { + const a = atom('', 1) + const startEpoch = globalEpoch + + const child = emptyChild() + const originalParentEpochs = child.parentEpochs + const originalParents = child.parents + + startCapturingParents(child) + maybeCaptureParent(a) + stopCapturingParents() + + // the parents should be kept because no sharing is possible and we don't want to reallocate + // when parents change + expect(child.parentEpochs).toBe(originalParentEpochs) + expect(child.parents).toBe(originalParents) + expect(child.parentEpochs).toEqual([startEpoch]) + expect(child.parents).toEqual([a]) + }) + + it('can handle several parents', () => { + const atomA = atom('', 1) + const atomAEpoch = globalEpoch + advanceGlobalEpoch() // let's say time has passed + const atomB = atom('', 1) + const atomBEpoch = globalEpoch + advanceGlobalEpoch() // let's say time has passed + const atomC = atom('', 1) + const atomCEpoch = globalEpoch + + expect(atomAEpoch < atomBEpoch).toBe(true) + expect(atomBEpoch < atomCEpoch).toBe(true) + + const child = emptyChild() + + const originalParentEpochs = child.parentEpochs + const originalParents = child.parents + + startCapturingParents(child) + maybeCaptureParent(atomA) + maybeCaptureParent(atomB) + maybeCaptureParent(atomC) + stopCapturingParents() + + // the parents should be kept because no sharing is possible and we don't want to reallocate + // when parents change + expect(child.parentEpochs).toBe(originalParentEpochs) + expect(child.parents).toBe(originalParents) + + expect(child.parentEpochs).toEqual([atomAEpoch, atomBEpoch, atomCEpoch]) + expect(child.parents).toEqual([atomA, atomB, atomC]) + }) + + it('will reorder if parents are captured in different orders each time', () => { + const atomA = atom('', 1) + advanceGlobalEpoch() // let's say time has passed + const atomB = atom('', 1) + advanceGlobalEpoch() // let's say time has passed + const atomC = atom('', 1) + + const child = emptyChild() + + startCapturingParents(child) + maybeCaptureParent(atomA) + maybeCaptureParent(atomB) + maybeCaptureParent(atomC) + stopCapturingParents() + + expect(child.parents).toEqual([atomA, atomB, atomC]) + + startCapturingParents(child) + maybeCaptureParent(atomB) + maybeCaptureParent(atomA) + maybeCaptureParent(atomC) + stopCapturingParents() + + expect(child.parents).toEqual([atomB, atomA, atomC]) + + startCapturingParents(child) + maybeCaptureParent(atomA) + maybeCaptureParent(atomC) + maybeCaptureParent(atomB) + stopCapturingParents() + + expect(child.parents).toEqual([atomA, atomC, atomB]) + }) + + it('will shrink the parent arrays if the number of captured parents shrinks', () => { + const atomA = atom('', 1) + const atomAEpoch = globalEpoch + advanceGlobalEpoch() // let's say time has passed + const atomB = atom('', 1) + const atomBEpoch = globalEpoch + advanceGlobalEpoch() // let's say time has passed + const atomC = atom('', 1) + const atomCEpoch = globalEpoch + + const child = emptyChild() + + const originalParents = child.parents + const originalParentEpochs = child.parentEpochs + + startCapturingParents(child) + maybeCaptureParent(atomA) + maybeCaptureParent(atomB) + maybeCaptureParent(atomC) + stopCapturingParents() + + expect(child.parents).toEqual([atomA, atomB, atomC]) + expect(child.parents).toBe(originalParents) + + startCapturingParents(child) + maybeCaptureParent(atomB) + maybeCaptureParent(atomA) + stopCapturingParents() + + expect(child.parents).toEqual([atomB, atomA]) + expect(child.parentEpochs).toEqual([atomBEpoch, atomAEpoch]) + expect(child.parents).toBe(originalParents) + + startCapturingParents(child) + stopCapturingParents() + + expect(child.parents).toEqual([]) + expect(child.parentEpochs).toEqual([]) + expect(child.parents).toBe(originalParents) + expect(child.parentEpochs).toBe(originalParentEpochs) + + startCapturingParents(child) + maybeCaptureParent(atomC) + stopCapturingParents() + + expect(child.parents).toEqual([atomC]) + expect(child.parentEpochs).toEqual([atomCEpoch]) + expect(child.parents).toBe(originalParents) + expect(child.parentEpochs).toBe(originalParentEpochs) + }) + + it('doesnt do anything if you dont start capturing', () => { + expect(() => { + maybeCaptureParent(atom('', 1)) + }).not.toThrow() + }) +}) + +describe(unsafe__withoutCapture, () => { + it('allows executing comptuer code in a context that short-circuits the current capture frame', () => { + const atomA = atom('a', 1) + const atomB = atom('b', 1) + const atomC = atom('c', 1) + + const child = computed('', () => { + return atomA.value + atomB.value + unsafe__withoutCapture(() => atomC.value) + }) + + let lastValue: number | undefined + let numReactions = 0 + + react('', () => { + numReactions++ + lastValue = child.value + }) + + expect(lastValue).toBe(3) + expect(numReactions).toBe(1) + + atomA.set(2) + + expect(lastValue).toBe(4) + expect(numReactions).toBe(2) + + atomB.set(2) + + expect(lastValue).toBe(5) + expect(numReactions).toBe(3) + + atomC.set(2) + + // The reaction should not have run because C was not captured + expect(lastValue).toBe(5) + expect(numReactions).toBe(3) + }) + + it('allows executing reactor code in a context that short-circuits the current capture frame', () => { + const atomA = atom('a', 1) + const atomB = atom('b', 1) + const atomC = atom('c', 1) + + let lastValue: number | undefined + let numReactions = 0 + + react('', () => { + numReactions++ + lastValue = atomA.value + atomB.value + unsafe__withoutCapture(() => atomC.value) + }) + + expect(lastValue).toBe(3) + expect(numReactions).toBe(1) + + atomA.set(2) + + expect(lastValue).toBe(4) + expect(numReactions).toBe(2) + + atomB.set(2) + + expect(lastValue).toBe(5) + expect(numReactions).toBe(3) + + atomC.set(2) + + // The reaction should not have run because C was not captured + expect(lastValue).toBe(5) + expect(numReactions).toBe(3) + }) +}) diff --git a/packages/state/src/lib/core/__tests__/computed.test.ts b/packages/state/src/lib/core/__tests__/computed.test.ts new file mode 100644 index 000000000..0e2e20636 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/computed.test.ts @@ -0,0 +1,610 @@ +import { atom } from '../Atom' +import { Computed, _Computed, computed, getComputedInstance, isUninitialized } from '../Computed' +import { reactor } from '../EffectScheduler' +import { assertNever } from '../helpers' +import { advanceGlobalEpoch, globalEpoch, transact, transaction } from '../transactions' +import { RESET_VALUE, Signal } from '../types' + +function getLastCheckedEpoch(derivation: Computed): number { + return (derivation as any).lastCheckedEpoch +} + +describe('derivations', () => { + it('will cache a value forever if it has no parents', () => { + const derive = jest.fn(() => 1) + const startEpoch = globalEpoch + const derivation = computed('', derive) + + expect(derive).toHaveBeenCalledTimes(0) + + expect(derivation.value).toBe(1) + expect(derivation.value).toBe(1) + expect(derivation.value).toBe(1) + + expect(derive).toHaveBeenCalledTimes(1) + + advanceGlobalEpoch() + advanceGlobalEpoch() + advanceGlobalEpoch() + advanceGlobalEpoch() + + expect(derivation.value).toBe(1) + expect(derivation.value).toBe(1) + expect(derivation.value).toBe(1) + advanceGlobalEpoch() + advanceGlobalEpoch() + expect(derivation.value).toBe(1) + expect(derivation.value).toBe(1) + + expect(derive).toHaveBeenCalledTimes(1) + + expect(derivation.parents.length).toBe(0) + + expect(derivation.lastChangedEpoch).toBe(startEpoch) + }) + + it('will update when parent atoms update', () => { + const a = atom('', 1) + const double = jest.fn(() => a.value * 2) + const derivation = computed('', double) + const startEpoch = globalEpoch + expect(double).toHaveBeenCalledTimes(0) + + expect(derivation.value).toBe(2) + expect(double).toHaveBeenCalledTimes(1) + + expect(derivation.lastChangedEpoch).toBe(startEpoch) + + expect(derivation.value).toBe(2) + expect(derivation.value).toBe(2) + expect(double).toHaveBeenCalledTimes(1) + expect(derivation.lastChangedEpoch).toBe(startEpoch) + + a.set(2) + const nextEpoch = globalEpoch + expect(nextEpoch > startEpoch).toBe(true) + + expect(double).toHaveBeenCalledTimes(1) + expect(derivation.lastChangedEpoch).toBe(startEpoch) + expect(derivation.value).toBe(4) + + expect(double).toHaveBeenCalledTimes(2) + expect(derivation.lastChangedEpoch).toBe(nextEpoch) + + expect(derivation.value).toBe(4) + expect(double).toHaveBeenCalledTimes(2) + expect(derivation.lastChangedEpoch).toBe(nextEpoch) + + // creating an unrelated atom and setting it will have no effect + const unrelatedAtom = atom('', 1) + unrelatedAtom.set(2) + unrelatedAtom.set(3) + unrelatedAtom.set(5) + + expect(derivation.value).toBe(4) + expect(double).toHaveBeenCalledTimes(2) + expect(derivation.lastChangedEpoch).toBe(nextEpoch) + }) + + it('supports history', () => { + const startEpoch = globalEpoch + const a = atom('', 1) + + const derivation = computed('', () => a.value * 2, { + historyLength: 3, + computeDiff: (a, b) => { + return b - a + }, + }) + + derivation.value + + expect(derivation.getDiffSince(startEpoch)).toHaveLength(0) + + a.set(2) + + expect(derivation.getDiffSince(startEpoch)).toEqual([+2]) + + a.set(3) + + expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2]) + + a.set(5) + + expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2, +4]) + + a.set(6) + // should fail now because we don't have enough hisstory + expect(derivation.getDiffSince(startEpoch)).toEqual(RESET_VALUE) + }) + + it('doesnt update history if it doesnt change', () => { + const startEpoch = globalEpoch + const a = atom('', 1) + + const floor = jest.fn((n: number) => Math.floor(n)) + const derivation = computed('', () => floor(a.value), { + historyLength: 3, + computeDiff: (a, b) => { + return b - a + }, + }) + + expect(derivation.value).toBe(1) + expect(derivation.getDiffSince(startEpoch)).toHaveLength(0) + + a.set(1.2) + + expect(derivation.value).toBe(1) + expect(derivation.getDiffSince(startEpoch)).toHaveLength(0) + expect(floor).toHaveBeenCalledTimes(2) + + a.set(1.5) + + expect(derivation.value).toBe(1) + expect(derivation.getDiffSince(startEpoch)).toHaveLength(0) + expect(floor).toHaveBeenCalledTimes(3) + + a.set(1.9) + + expect(derivation.value).toBe(1) + expect(derivation.getDiffSince(startEpoch)).toHaveLength(0) + expect(floor).toHaveBeenCalledTimes(4) + + a.set(2.3) + + expect(derivation.value).toBe(2) + expect(derivation.getDiffSince(startEpoch)).toEqual([+1]) + expect(floor).toHaveBeenCalledTimes(5) + }) + + it('updates the lastCheckedEpoch whenever the globalEpoch advances', () => { + const startEpoch = globalEpoch + const a = atom('', 1) + + const double = jest.fn(() => a.value * 2) + const derivation = computed('', double) + + derivation.value + + expect(getLastCheckedEpoch(derivation)).toEqual(startEpoch) + + advanceGlobalEpoch() + derivation.value + + expect(getLastCheckedEpoch(derivation)).toBeGreaterThan(startEpoch) + + expect(double).toHaveBeenCalledTimes(1) + }) + + it('receives UNINTIALIZED as the previousValue the first time it computes', () => { + const a = atom('', 1) + const double = jest.fn((_prevValue) => a.value * 2) + const derivation = computed('', double) + + expect(derivation.value).toBe(2) + + expect(isUninitialized(double.mock.calls[0][0])).toBe(true) + + a.set(2) + + expect(derivation.value).toBe(4) + expect(isUninitialized(double.mock.calls[1][0])).toBe(false) + expect(double.mock.calls[1][0]).toBe(2) + }) + + it('receives the lastChangedEpoch as the second parameter each time it recomputes', () => { + const a = atom('', 1) + const double = jest.fn((_prevValue, lastChangedEpoch) => { + expect(lastChangedEpoch).toBe(derivation.lastChangedEpoch) + return a.value * 2 + }) + const derivation = computed('', double) + + expect(derivation.value).toBe(2) + + const startEpoch = globalEpoch + + a.set(2) + + expect(derivation.value).toBe(4) + expect(derivation.lastChangedEpoch).toBeGreaterThan(startEpoch) + + expect(double).toHaveBeenCalledTimes(2) + expect.assertions(6) + }) + + it('can be reacted to', () => { + const firstName = atom('', 'John') + const lastName = atom('', 'Doe') + + let numTimesComputed = 0 + const fullName = computed('', () => { + numTimesComputed++ + return `${firstName.value} ${lastName.value}` + }) + + let numTimesReacted = 0 + let name = '' + const r = reactor('', () => { + name = fullName.value + numTimesReacted++ + }) + + expect(numTimesReacted).toBe(0) + expect(name).toBe('') + + r.start() + + expect(numTimesReacted).toBe(1) + expect(numTimesComputed).toBe(1) + expect(name).toBe('John Doe') + + firstName.set('Jane') + + expect(numTimesComputed).toBe(2) + expect(numTimesReacted).toBe(2) + expect(name).toBe('Jane Doe') + + firstName.set('Jane') + firstName.set('Jane') + firstName.set('Jane') + + expect(numTimesComputed).toBe(2) + expect(numTimesReacted).toBe(2) + expect(name).toBe('Jane Doe') + + transact(() => { + firstName.set('Wilbur') + expect(numTimesComputed).toBe(2) + expect(numTimesReacted).toBe(2) + expect(name).toBe('Jane Doe') + lastName.set('Jones') + expect(numTimesComputed).toBe(2) + expect(numTimesReacted).toBe(2) + expect(name).toBe('Jane Doe') + expect(fullName.value).toBe('Wilbur Jones') + + expect(numTimesComputed).toBe(3) + expect(numTimesReacted).toBe(2) + expect(name).toBe('Jane Doe') + }) + + expect(numTimesComputed).toBe(3) + expect(numTimesReacted).toBe(3) + expect(name).toBe('Wilbur Jones') + }) + + it('will roll back to their initial value if a transaciton is aborted', () => { + const firstName = atom('', 'John') + const lastName = atom('', 'Doe') + + const fullName = computed('', () => `${firstName.value} ${lastName.value}`) + + transaction((rollback) => { + firstName.set('Jane') + lastName.set('Jones') + expect(fullName.value).toBe('Jane Jones') + rollback() + }) + + expect(fullName.value).toBe('John Doe') + }) + + it('will add history items if a transaction is aborted', () => { + const a = atom('', 1) + const b = atom('', 1) + + const c = computed('', () => a.value + b.value, { + historyLength: 3, + computeDiff: (a, b) => b - a, + }) + + const startEpoch = globalEpoch + + transaction((rollback) => { + expect(c.getDiffSince(startEpoch)).toEqual([]) + a.set(2) + b.set(2) + expect(c.getDiffSince(startEpoch)).toEqual([+2]) + rollback() + }) + + expect(c.getDiffSince(startEpoch)).toEqual([2, -2]) + }) + + it('will return RESET_VALUE if .getDiffSince is called with an epoch before initialization', () => { + const a = atom('', 1) + const b = atom('', 1) + + const c = computed('', () => a.value + b.value, { + historyLength: 3, + computeDiff: (a, b) => b - a, + }) + + expect(c.getDiffSince(globalEpoch - 1)).toEqual(RESET_VALUE) + }) +}) + +type Difference = + | { + type: 'CHANGE' + path: string[] + value: any + oldValue: any + } + | { type: 'CREATE'; path: string[]; value: any } + | { type: 'REMOVE'; path: string[]; oldValue: any } + +function getIncrementalRecordMapper( + obj: Signal, Difference[]>, + mapper: (t: In, k: string) => Out +): Computed> { + function computeFromScratch() { + const input = obj.value + return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, mapper(v, k)])) + } + return computed('', (previousValue, lastComputedEpoch) => { + if (isUninitialized(previousValue)) { + return computeFromScratch() + } + const diff = obj.getDiffSince(lastComputedEpoch) + if (diff === RESET_VALUE) { + return computeFromScratch() + } + if (diff.length === 0) { + return previousValue + } + + const newUpstream = obj.value + + const result = { ...previousValue } as Record + + const changedKeys = new Set() + for (const change of diff.flat()) { + const key = change.path[0] as string + if (changedKeys.has(key)) { + continue + } + switch (change.type) { + case 'CHANGE': + case 'CREATE': + changedKeys.add(key) + if (key in newUpstream) { + result[key] = mapper(newUpstream[key], change.path[0] as string) + } else { + // key was removed later in this patch + } + break + case 'REMOVE': + if (key in result) { + delete result[key] + } + break + default: + assertNever(change) + } + } + + return result + }) +} + +describe('incremental derivations', () => { + it('should be possible', () => { + type NumberMap = Record + + const nodes = atom( + '', + { + a: 1, + b: 2, + c: 3, + d: 4, + e: 5, + }, + { + historyLength: 10, + computeDiff: (valA, valB) => { + const result: Difference[] = [] + for (const keyA in valA) { + if (!(keyA in valB)) { + result.push({ + type: 'REMOVE', + oldValue: valA[keyA], + path: [keyA], + }) + } else if (valA[keyA] != valB[keyA]) { + result.push({ + type: 'CHANGE', + oldValue: valA[keyA], + path: [keyA], + value: valB[keyA], + }) + } + } + + for (const keyB in valB) { + if (!(keyB in valA)) { + result.push({ + type: 'CREATE', + value: valB[keyB], + path: [keyB], + }) + } + } + return result + }, + } + ) + + const mapper = jest.fn((val) => val * 2) + + const doubledNodes = getIncrementalRecordMapper(nodes, mapper) + + expect(doubledNodes.value).toEqual({ + a: 2, + b: 4, + c: 6, + d: 8, + e: 10, + }) + expect(mapper).toHaveBeenCalledTimes(5) + + nodes.update((ns) => ({ ...ns, a: 10 })) + + expect(doubledNodes.value).toEqual({ + a: 20, + b: 4, + c: 6, + d: 8, + e: 10, + }) + + expect(mapper).toHaveBeenCalledTimes(6) + + // remove d + nodes.update(({ d: _d, ...others }) => others) + + expect(doubledNodes.value).toEqual({ + a: 20, + b: 4, + c: 6, + e: 10, + }) + expect(mapper).toHaveBeenCalledTimes(6) + + nodes.update((ns) => ({ ...ns, f: 50, g: 60 })) + + expect(doubledNodes.value).toEqual({ + a: 20, + b: 4, + c: 6, + e: 10, + f: 100, + g: 120, + }) + expect(mapper).toHaveBeenCalledTimes(8) + + nodes.set({ ...nodes.value }) + // no changes so no new calls to mapper + expect(doubledNodes.value).toEqual({ + a: 20, + b: 4, + c: 6, + e: 10, + f: 100, + g: 120, + }) + expect(mapper).toHaveBeenCalledTimes(8) + + // make several changes + + nodes.update((ns) => ({ ...ns, a: 1 })) + nodes.update((ns) => ({ ...ns, b: 9 })) + nodes.update((ns) => ({ ...ns, c: 17 })) + nodes.update(({ f: _f, g: _g, ...others }) => ({ ...others })) + nodes.update((ns) => ({ ...ns, d: 4 })) + nodes.update((ns) => ({ ...ns, a: 4 })) + + // nothing was called because we didn't deref yet + expect(mapper).toHaveBeenCalledTimes(8) + + expect(doubledNodes.value).toEqual({ + a: 8, + b: 18, + c: 34, + d: 8, + e: 10, + }) + + expect(mapper).toHaveBeenCalledTimes(12) + }) +}) + +describe('computed as a decorator', () => { + it('can be used to decorate a class', () => { + class Foo { + a = atom('a', 1) + @computed + get b() { + return this.a.value * 2 + } + } + + const foo = new Foo() + + expect(foo.b).toBe(2) + + foo.a.set(2) + + expect(foo.b).toBe(4) + }) + + it('can be used to decorate a class with custom properties', () => { + let numComputations = 0 + class Foo { + a = atom('a', 1) + + @computed({ isEqual: (a, b) => a.b === b.b }) + get b() { + numComputations++ + return { b: this.a.value * this.a.value } + } + } + + const foo = new Foo() + + const firstVal = foo.b + expect(firstVal).toEqual({ b: 1 }) + + foo.a.set(-1) + + const secondVal = foo.b + expect(secondVal).toEqual({ b: 1 }) + + expect(firstVal).toBe(secondVal) + expect(numComputations).toBe(2) + }) +}) + +describe(getComputedInstance, () => { + it('can retrieve the underlying computed instance', () => { + class Foo { + a = atom('a', 1) + + @computed({ isEqual: (a, b) => a.b === b.b }) + get b() { + return { b: this.a.value * this.a.value } + } + } + + const foo = new Foo() + + const bInst = getComputedInstance(foo, 'b') + + expect(bInst).toBeDefined() + expect(bInst).toBeInstanceOf(_Computed) + }) +}) + +describe('computed isEqual', () => { + it('does not get called for the initialization', () => { + const isEqual = jest.fn((a, b) => a === b) + + const a = atom('a', 1) + const b = computed('b', () => a.value * 2, { isEqual }) + + expect(b.value).toBe(2) + expect(isEqual).not.toHaveBeenCalled() + expect(b.value).toBe(2) + expect(isEqual).not.toHaveBeenCalled() + + a.set(2) + + expect(b.value).toBe(4) + expect(isEqual).toHaveBeenCalledTimes(1) + expect(b.value).toBe(4) + expect(isEqual).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts b/packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts new file mode 100644 index 000000000..5049ab1b5 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts @@ -0,0 +1,370 @@ +import { times } from 'lodash' +import { Atom, atom, isAtom } from '../Atom' +import { Computed, computed, isComputed } from '../Computed' +import { Reactor, reactor } from '../EffectScheduler' +import { transact } from '../transactions' +import { Signal } from '../types' + +class RandomSource { + private seed: number + + constructor(seed: number) { + this.seed = seed + } + + nextFloat(): number { + this.seed = (this.seed * 9301 + 49297) % 233280 + return this.seed / 233280 + } + + nextInt(max: number): number { + return Math.floor(this.nextFloat() * max) + } + + nextIntInRange(min: number, max: number): number { + return this.nextInt(max - min) + min + } + + nextId(): string { + return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36) + } + + selectOne(arr: readonly T[]): T { + return arr[this.nextInt(arr.length)] + } + + choice(probability: number): boolean { + return this.nextFloat() < probability + } + + executeOne( + _choices: Record Result) | { weight?: number; do(): Result }> + ): Result { + const choices = Object.values(_choices).map((choice) => { + if (typeof choice === 'function') { + return { weight: 1, do: choice } + } + return choice + }) + const totalWeight = Object.values(choices).reduce( + (total, choice) => total + (choice.weight ?? 1), + 0 + ) + const randomWeight = this.nextInt(totalWeight) + let weight = 0 + for (const choice of Object.values(choices)) { + weight += choice.weight ?? 1 + if (randomWeight < weight) { + return choice.do() + } + } + throw new Error('unreachable') + } +} + +const LETTERS = ['a', 'b', 'c', 'd', 'e', 'f'] as const +type Letter = (typeof LETTERS)[number] + +const unpack = (value: unknown): Letter => { + if (isComputed(value) || isAtom(value)) { + return unpack(value.value) as Letter + } + return value as Letter +} + +type FuzzSystemState = { + atoms: Record> + atomsInAtoms: Record>> + derivations: Record; sneakyGet: () => Letter }> + derivationsInDerivations: Record>> + atomsInDerivations: Record>> + reactors: Record[] }> +} + +type Op = + | { type: 'update_atom'; id: string; value: Letter } + | { type: 'update_atom_in_atom'; id: string; atomId: string } + | { type: 'deref_derivation'; id: string } + | { type: 'deref_derivation_in_derivation'; id: string } + | { type: 'deref_atom_in_derivation'; id: string } + | { type: 'run_several_ops_in_transaction'; ops: Op[] } + | { type: 'start_reactor'; id: string } + | { type: 'stop_reactor'; id: string } + +const MAX_ATOMS = 10 +const MAX_ATOMS_IN_ATOMS = 10 +const MAX_DERIVATIONS = 10 +const MAX_DERIVATIONS_IN_DERIVATIONS = 10 +const MAX_ATOMS_IN_DERIVATIONS = 10 +const MAX_REACTORS = 10 +const MAX_DEPENDENCIES_PER_ATOM = 3 +const MAX_OPS_IN_TRANSACTION = 10 + +class Test { + source: RandomSource + systemState: FuzzSystemState = { + atoms: {}, + atomsInAtoms: {}, + derivations: {}, + derivationsInDerivations: {}, + atomsInDerivations: {}, + reactors: {}, + } + + unpack_sneaky = (value: unknown): Letter => { + if (isComputed(value)) { + if (this.systemState.derivations[value.name]) { + return this.systemState.derivations[value.name].sneakyGet() + } + // @ts-expect-error + return this.unpack_sneaky(value.state) as Letter + } else if (isAtom(value)) { + // @ts-expect-error + return this.unpack_sneaky(value.current) as Letter + } + return value as Letter + } + + getResultComparisons() { + const result: { expected: Record; actual: Record } = { + expected: {}, + actual: {}, + } + for (const [reactorId, { reactor, result: actualResult, dependencies }] of Object.entries( + this.systemState.reactors + )) { + if (!reactor.scheduler.isActivelyListening) continue + result.expected[reactorId] = dependencies.map(this.unpack_sneaky).join(':') + result.actual[reactorId] = actualResult + } + + return result + } + + constructor(seed: number) { + this.source = new RandomSource(seed) + + times(this.source.nextIntInRange(1, MAX_ATOMS), () => { + const atomId = this.source.nextId() + this.systemState.atoms[atomId] = atom(atomId, this.source.selectOne(LETTERS)) + }) + + times(this.source.nextIntInRange(1, MAX_ATOMS_IN_ATOMS), () => { + const atomId = this.source.nextId() + this.systemState.atomsInAtoms[atomId] = atom( + atomId, + this.source.selectOne(Object.values(this.systemState.atoms)) + ) + }) + + times(this.source.nextIntInRange(1, MAX_ATOMS_IN_DERIVATIONS), () => { + const derivationId = this.source.nextId() + const atom = this.source.selectOne(Object.values(this.systemState.atoms)) + this.systemState.atomsInDerivations[derivationId] = computed(derivationId, () => atom) + }) + + times(this.source.nextIntInRange(1, MAX_DERIVATIONS), () => { + const derivationId = this.source.nextId() + const derivables = [ + ...Object.values(this.systemState.atoms), + ...Object.values(this.systemState.atomsInAtoms), + ...Object.values(this.systemState.atomsInDerivations), + ...Object.values(this.systemState.derivations), + ] + const inputA = this.source.selectOne(derivables) + const inputB = this.source.selectOne(derivables) + const inputC = this.source.selectOne(derivables) + const inputD = this.source.selectOne(derivables) + this.systemState.derivations[derivationId] = { + derivation: computed(derivationId, () => { + if (unpack(inputA) === unpack(inputB)) { + return unpack(inputC) + } else { + return unpack(inputD) + } + }), + sneakyGet: () => { + if (this.unpack_sneaky(inputA) === this.unpack_sneaky(inputB)) { + return this.unpack_sneaky(inputC) + } else { + return this.unpack_sneaky(inputD) + } + }, + } + }) + + times(this.source.nextIntInRange(1, MAX_DERIVATIONS_IN_DERIVATIONS), () => { + const derivationId = this.source.nextId() + this.systemState.derivationsInDerivations[derivationId] = computed(derivationId, () => + this.source.selectOne(Object.values(this.systemState.derivations).map((d) => d.derivation)) + ) + }) + + times(this.source.nextIntInRange(1, MAX_REACTORS), () => { + const reactorId = this.source.nextId() + const dependencies: Signal[] = [] + + times(this.source.nextIntInRange(1, MAX_DEPENDENCIES_PER_ATOM), () => { + this.source.executeOne({ + 'add a random atom': () => { + dependencies.push(this.source.selectOne(Object.values(this.systemState.atoms))) + }, + 'add a random atom in atom': () => { + dependencies.push(this.source.selectOne(Object.values(this.systemState.atomsInAtoms))) + }, + 'add a random derivation': () => { + dependencies.push( + this.source.selectOne( + Object.values(this.systemState.derivations).map((d) => d.derivation) + ) + ) + }, + 'add a random derivation in derivation': () => { + dependencies.push( + this.source.selectOne(Object.values(this.systemState.derivationsInDerivations)) + ) + }, + 'add a random atom in derivation': () => { + dependencies.push( + this.source.selectOne(Object.values(this.systemState.atomsInDerivations)) + ) + }, + }) + dependencies.push(this.source.selectOne(Object.values(this.systemState.atoms))) + }) + + this.systemState.reactors[reactorId] = { + reactor: reactor(reactorId, () => { + this.systemState.reactors[reactorId].result = dependencies.map(unpack).join(':') + }), + result: '', + dependencies, + } + }) + } + + readonly ops: Op[] = [] + + getNextOp(): Op { + return this.source.executeOne({ + 'update atom': () => { + return { + type: 'update_atom', + id: this.source.selectOne(Object.keys(this.systemState.atoms)), + value: this.source.selectOne(LETTERS), + } + }, + 'update atom in atom': () => { + return { + type: 'update_atom_in_atom', + id: this.source.selectOne(Object.keys(this.systemState.atomsInAtoms)), + atomId: this.source.selectOne(Object.keys(this.systemState.atoms)), + } + }, + 'deref atom in derivation': () => { + return { + type: 'deref_atom_in_derivation', + id: this.source.selectOne(Object.keys(this.systemState.atomsInDerivations)), + } + }, + 'deref derivation in derivation': () => { + return { + type: 'deref_derivation_in_derivation', + id: this.source.selectOne(Object.keys(this.systemState.derivationsInDerivations)), + } + }, + 'deref derivation': () => { + return { + type: 'deref_derivation', + id: this.source.selectOne(Object.keys(this.systemState.derivations)), + } + }, + 'run several ops in a transaction': () => { + return { + type: 'run_several_ops_in_transaction', + ops: times(this.source.nextIntInRange(2, MAX_OPS_IN_TRANSACTION), () => this.getNextOp()), + } + }, + start_reactor: () => { + return { + type: 'start_reactor', + id: this.source.selectOne(Object.keys(this.systemState.reactors)), + } + }, + stop_reactor: () => { + return { + type: 'stop_reactor', + id: this.source.selectOne(Object.keys(this.systemState.reactors)), + } + }, + }) + } + + applyOp(op: Op) { + switch (op.type) { + case 'update_atom': { + this.systemState.atoms[op.id].set(op.value) + break + } + case 'deref_atom_in_derivation': { + this.systemState.atomsInDerivations[op.id].value + break + } + case 'deref_derivation': { + this.systemState.derivations[op.id].derivation.value + break + } + case 'deref_derivation_in_derivation': { + this.systemState.derivationsInDerivations[op.id].value + break + } + case 'update_atom_in_atom': { + this.systemState.atomsInAtoms[op.id].set(this.systemState.atoms[op.atomId]) + break + } + case 'run_several_ops_in_transaction': { + transact(() => { + op.ops.forEach((op) => this.applyOp(op)) + }) + break + } + case 'start_reactor': { + this.systemState.reactors[op.id].reactor.start() + break + } + case 'stop_reactor': { + this.systemState.reactors[op.id].reactor.stop() + break + } + default: { + throw new Error(`Unknown op type: ${op}`) + } + } + } + + tick() { + const op = this.getNextOp() + this.ops.push(op) + this.applyOp(op) + } +} + +const NUM_TESTS = 100 +const NUM_OPS_PER_TEST = 1000 + +function runTest(seed: number) { + const test = new Test(seed) + // console.log(test.systemState) + for (let i = 0; i < NUM_OPS_PER_TEST; i++) { + test.tick() + const { expected, actual } = test.getResultComparisons() + expect(expected).toEqual(actual) + } +} + +for (let i = 0; i < NUM_TESTS; i++) { + const seed = Math.floor(Math.random() * 1000000) + test('fuzzzzzz ' + seed, () => { + runTest(seed) + }) +} diff --git a/packages/state/src/lib/core/__tests__/reactor.test.ts b/packages/state/src/lib/core/__tests__/reactor.test.ts new file mode 100644 index 000000000..bd6e62416 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/reactor.test.ts @@ -0,0 +1,196 @@ +import { atom } from '../Atom' +import { react, reactor } from '../EffectScheduler' +import { advanceGlobalEpoch, transact } from '../transactions' + +describe('reactors', () => { + it('can be started and stopped ', () => { + const a = atom('', 1) + const r = reactor('', () => { + a.value + }) + expect(r.scheduler.isActivelyListening).toBe(false) + r.start() + expect(r.scheduler.isActivelyListening).toBe(true) + r.stop() + expect(r.scheduler.isActivelyListening).toBe(false) + r.start() + expect(r.scheduler.isActivelyListening).toBe(true) + }) + + it('can not set atom values directly yet', () => { + const a = atom('', 1) + const r = reactor('', () => { + if (a.value < +Infinity) { + a.update((a) => a + 1) + } + }) + expect(() => r.start()).toThrowErrorMatchingInlineSnapshot( + `"cannot change atoms during reaction cycle"` + ) + }) + + it('will never be called twice after a single state update, even if that update affects multiple atoms to which the reactor is subscribed', () => { + const atomA = atom('', 1) + const atomB = atom('', 1) + + const react = jest.fn(() => { + atomA.value + atomB.value + }) + const r = reactor('', react) + + r.start() + expect(react).toHaveBeenCalledTimes(1) + + transact(() => { + atomA.set(2) + atomB.set(2) + }) + + expect(react).toHaveBeenCalledTimes(2) + }) + + it('will not react if stopped', () => { + const a = atom('', 1) + const react = jest.fn(() => { + a.value + }) + const r = reactor('', react) + + r.scheduler.maybeScheduleEffect() + + expect(react).not.toHaveBeenCalled() + }) + + it('will not react if the parents have not changed', () => { + const a = atom('', 1) + const react = jest + .fn(() => { + a.value + }) + .mockName('react') + const r = reactor('', react) + + r.start() + expect(react).toHaveBeenCalledTimes(1) + + advanceGlobalEpoch() + r.scheduler.maybeScheduleEffect() + expect(react).toHaveBeenCalledTimes(1) + }) +}) + +describe('stopping', () => { + it('works', () => { + const a = atom('', 1) + + const rfn = jest.fn(() => { + a.value + }) + const r = reactor('', rfn) + + expect(a.children.isEmpty).toBe(true) + + r.start() + + expect(a.children.isEmpty).toBe(false) + + a.set(8) + + expect(rfn).toHaveBeenCalledTimes(2) + + r.stop() + + expect(a.children.isEmpty).toBe(true) + expect(rfn).toHaveBeenCalledTimes(2) + + a.set(2) + + expect(rfn).toHaveBeenCalledTimes(2) + + a.set(3) + + expect(rfn).toHaveBeenCalledTimes(2) + + expect(a.children.isEmpty).toBe(true) + }) +}) + +test('.start() by default does not trigger a reaction if nothing has changed', () => { + const a = atom('', 1) + + const rfn = jest.fn(() => { + a.value + }) + + const r = reactor('', rfn) + r.start() + + expect(rfn).toHaveBeenCalledTimes(1) + + r.stop() + + r.start() + + expect(rfn).toHaveBeenCalledTimes(1) +}) + +test('.start({force: true}) will trigger a reaction even if nothing has changed', () => { + const a = atom('', 1) + + const rfn = jest.fn(() => { + a.value + }) + + const r = reactor('', rfn) + r.start() + + expect(rfn).toHaveBeenCalledTimes(1) + + r.stop() + + r.start({ force: true }) + + expect(rfn).toHaveBeenCalledTimes(2) +}) + +test('.start with a custom scheduler only schedules an effect, it does not execute it immediately', () => { + let numSchedules = 0 + let numExecutes = 0 + + const r = reactor( + '', + () => { + numExecutes++ + }, + { + scheduleEffect: () => { + numSchedules++ + }, + } + ) + r.start() + + expect(numSchedules).toBe(1) + expect(numExecutes).toBe(0) +}) + +test('react() with a custom scheduler only schedules an effect, it does not execute it immediately', () => { + let numSchedules = 0 + let numExecutes = 0 + + react( + '', + () => { + numExecutes++ + }, + { + scheduleEffect: () => { + numSchedules++ + }, + } + ) + + expect(numSchedules).toBe(1) + expect(numExecutes).toBe(0) +}) diff --git a/packages/state/src/lib/core/__tests__/transactions.test.ts b/packages/state/src/lib/core/__tests__/transactions.test.ts new file mode 100644 index 000000000..b3d672d58 --- /dev/null +++ b/packages/state/src/lib/core/__tests__/transactions.test.ts @@ -0,0 +1,202 @@ +import { atom } from '../Atom' +import { computed } from '../Computed' +import { react } from '../EffectScheduler' +import { transact, transaction } from '../transactions' + +describe('transactions', () => { + it('should be abortable', () => { + const firstName = atom('', 'John') + const lastName = atom('', 'Doe') + + let numTimesComputed = 0 + const fullName = computed('', () => { + numTimesComputed++ + return `${firstName.value} ${lastName.value}` + }) + + let numTimesReacted = 0 + let name = '' + + react('', () => { + name = fullName.value + numTimesReacted++ + }) + + expect(numTimesReacted).toBe(1) + expect(numTimesComputed).toBe(1) + expect(name).toBe('John Doe') + + transaction((rollback) => { + firstName.set('Wilbur') + expect(numTimesComputed).toBe(1) + expect(numTimesReacted).toBe(1) + expect(name).toBe('John Doe') + lastName.set('Jones') + expect(numTimesComputed).toBe(1) + expect(numTimesReacted).toBe(1) + expect(name).toBe('John Doe') + expect(fullName.value).toBe('Wilbur Jones') + + expect(numTimesComputed).toBe(2) + expect(numTimesReacted).toBe(1) + expect(name).toBe('John Doe') + + rollback() + }) + + // computes again + expect(numTimesComputed).toBe(3) + expect(numTimesReacted).toBe(2) + + expect(fullName.value).toBe('John Doe') + expect(name).toBe('John Doe') + }) + + it('nested rollbacks work as expected', () => { + const atomA = atom('', 0) + const atomB = atom('', 0) + + transaction((rollback) => { + atomA.set(1) + atomB.set(-1) + transaction((rollback) => { + atomA.set(2) + atomB.set(-2) + transaction((rollback) => { + atomA.set(3) + atomB.set(-3) + rollback() + }) + rollback() + }) + rollback() + }) + + expect(atomA.value).toBe(0) + expect(atomB.value).toBe(0) + + transaction((rollback) => { + atomA.set(1) + atomB.set(-1) + transaction((rollback) => { + atomA.set(2) + atomB.set(-2) + transaction(() => { + atomA.set(3) + atomB.set(-3) + }) + rollback() + }) + rollback() + }) + + expect(atomA.value).toBe(0) + expect(atomB.value).toBe(0) + + transaction((rollback) => { + atomA.set(1) + atomB.set(-1) + transaction(() => { + atomA.set(2) + atomB.set(-2) + transaction(() => { + atomA.set(3) + atomB.set(-3) + }) + }) + rollback() + }) + + expect(atomA.value).toBe(0) + expect(atomB.value).toBe(0) + + transaction(() => { + atomA.set(1) + atomB.set(-1) + transaction((rollback) => { + atomA.set(2) + atomB.set(-2) + transaction((rollback) => { + atomA.set(3) + atomB.set(-3) + rollback() + }) + rollback() + }) + }) + + expect(atomA.value).toBe(1) + expect(atomB.value).toBe(-1) + + transaction(() => { + atomA.set(1) + atomB.set(-1) + transaction(() => { + atomA.set(2) + atomB.set(-2) + transaction((rollback) => { + atomA.set(3) + atomB.set(-3) + rollback() + }) + }) + }) + + expect(atomA.value).toBe(2) + expect(atomB.value).toBe(-2) + }) + + it('should restore the original even if an inner commits', () => { + const a = atom('', 'a') + + transaction((rollback) => { + transaction(() => { + a.set('b') + }) + rollback() + }) + + expect(a.value).toBe('a') + }) +}) + +describe('transact', () => { + it('executes things in a transaction', () => { + const a = atom('', 'a') + + try { + transact(() => { + a.set('b') + throw new Error('blah') + }) + } catch (e: any) { + expect(e.message).toBe('blah') + } + + expect(a.value).toBe('a') + + expect.assertions(2) + }) + + it('does not create nested transactions', () => { + const a = atom('', 'a') + + transact(() => { + a.set('b') + + try { + transact(() => { + a.set('c') + throw new Error('blah') + }) + } catch (e: any) { + expect(e.message).toBe('blah') + } + expect(a.value).toBe('c') + }) + + expect(a.value).toBe('c') + + expect.assertions(3) + }) +}) diff --git a/packages/state/src/lib/core/capture.ts b/packages/state/src/lib/core/capture.ts new file mode 100644 index 000000000..c4fc0a60e --- /dev/null +++ b/packages/state/src/lib/core/capture.ts @@ -0,0 +1,179 @@ +import { attach, detach } from './helpers' +import { Child, Signal } from './types' + +const globalKey = Symbol.for('__@tldraw/state__') +const global = globalThis as { [globalKey]?: true } + +if (global[globalKey]) { + console.error( + 'Multiple versions of @tldraw/state detected. This will cause unexpected behavior. Please add "resolutions" (yarn/pnpm) or "overrides" (npm) in your package.json to ensure only one version of @tldraw/state is loaded.' + ) +} else { + global[globalKey] = true +} + +class CaptureStackFrame { + offset = 0 + numNewParents = 0 + + maybeRemoved?: Signal[] + + constructor(public readonly below: CaptureStackFrame | null, public readonly child: Child) {} +} + +let stack: CaptureStackFrame | null = null + +/** + * Executes the given function without capturing any parents in the current capture context. + * + * This is mainly useful if you want to run an effect only when certain signals change while also + * dereferencing other signals which should not cause the effect to rerun on their own. + * + * @example + * ```ts + * const name = atom('name', 'Sam') + * const time = atom('time', () => new Date().getTime()) + * + * setInterval(() => { + * time.set(new Date().getTime()) + * }) + * + * react('log name changes', () => { + * console.log(name.value, 'was changed at', unsafe__withoutCapture(() => time.value)) + * }) + * + * ``` + * + * @public + */ +export function unsafe__withoutCapture(fn: () => T): T { + const oldStack = stack + stack = null + try { + return fn() + } finally { + stack = oldStack + } +} + +export function startCapturingParents(child: Child) { + stack = new CaptureStackFrame(stack, child) +} + +export function stopCapturingParents() { + const frame = stack! + stack = frame.below + + const didParentsChange = frame.numNewParents > 0 || frame.offset !== frame.child.parents.length + + if (!didParentsChange) { + return + } + + for (let i = frame.offset; i < frame.child.parents.length; i++) { + const p = frame.child.parents[i] + const parentWasRemoved = frame.child.parents.indexOf(p) >= frame.offset + if (parentWasRemoved) { + detach(p, frame.child) + } + } + + frame.child.parents.length = frame.offset + frame.child.parentEpochs.length = frame.offset + + if (stack?.maybeRemoved) { + for (let i = 0; i < stack.maybeRemoved.length; i++) { + const maybeRemovedParent = stack.maybeRemoved[i] + if (frame.child.parents.indexOf(maybeRemovedParent) === -1) { + detach(maybeRemovedParent, frame.child) + } + } + } +} + +// this must be called after the parent is up to date +export function maybeCaptureParent(p: Signal) { + if (stack) { + const idx = stack.child.parents.indexOf(p) + // if the child didn't deref this parent last time it executed, then idx will be -1 + // if the child did deref this parent last time but in a different order relative to other parents, then idx will be greater than stack.offset + // if the child did deref this parent last time in the same order, then idx will be the same as stack.offset + // if the child did deref this parent already during this capture session then 0 <= idx < stack.offset + + if (idx < 0) { + stack.numNewParents++ + if (stack.child.isActivelyListening) { + attach(p, stack.child) + } + } + + if (idx < 0 || idx >= stack.offset) { + if (idx !== stack.offset && idx > 0) { + const maybeRemovedParent = stack.child.parents[stack.offset] + + if (!stack.maybeRemoved) { + stack.maybeRemoved = [maybeRemovedParent] + } else if (stack.maybeRemoved.indexOf(maybeRemovedParent) === -1) { + stack.maybeRemoved.push(maybeRemovedParent) + } + } + + stack.child.parents[stack.offset] = p + stack.child.parentEpochs[stack.offset] = p.lastChangedEpoch + stack.offset++ + } + } +} + +/** + * A debugging tool that tells you why a computed signal or effect is running. + * Call in the body of a computed signal or effect function. + * + * @example + * ```ts + * const name = atom('name', 'Bob') + * react('greeting', () => { + * whyAmIRunning() + * console.log('Hello', name.value) + * }) + * + * name.set('Alice') + * + * // 'greeting' is running because: + * // 'name' changed => 'Alice' + * ``` + * + * @public + */ +export function whyAmIRunning() { + const child = stack?.child + if (!child) { + throw new Error('whyAmIRunning() called outside of a reactive context') + } + + const changedParents = [] + for (let i = 0; i < child.parents.length; i++) { + const parent = child.parents[i] + + if (parent.lastChangedEpoch > child.parentEpochs[i]) { + changedParents.push(parent) + } + } + + if (changedParents.length === 0) { + // eslint-disable-next-line no-console + console.log((child as any).name, 'is running but none of the parents changed') + } else { + // eslint-disable-next-line no-console + console.log((child as any).name, 'is running because:') + for (const changedParent of changedParents) { + // eslint-disable-next-line no-console + console.log( + '\t', + (changedParent as any).name, + 'changed =>', + changedParent.__unsafe__getWithoutCapture() + ) + } + } +} diff --git a/packages/state/src/lib/core/constants.ts b/packages/state/src/lib/core/constants.ts new file mode 100644 index 000000000..5491e190e --- /dev/null +++ b/packages/state/src/lib/core/constants.ts @@ -0,0 +1,2 @@ +// Derivations start on GLOBAL_START_EPOCH so they are dirty before having been computed +export const GLOBAL_START_EPOCH = -1 diff --git a/packages/state/src/lib/core/helpers.ts b/packages/state/src/lib/core/helpers.ts new file mode 100644 index 000000000..7c0f3f6f4 --- /dev/null +++ b/packages/state/src/lib/core/helpers.ts @@ -0,0 +1,88 @@ +import { Child, Signal } from './types' + +/** + * Get whether the given value is a child. + * + * @param x The value to check. + * @returns True if the value is a child, false otherwise. + */ +function isChild(x: any): x is Child { + return x && typeof x === 'object' && 'parents' in x +} + +/** + * Get whether a child's parents have changed. + * + * @param child The child to check. + * @returns True if the child's parents have changed, false otherwise. + */ +export function haveParentsChanged(child: Child) { + for (let i = 0, n = child.parents.length; i < n; i++) { + // Get the parent's value without capturing it. + child.parents[i].__unsafe__getWithoutCapture() + + // If the parent's epoch does not match the child's view of the parent's epoch, then the parent has changed. + if (child.parents[i].lastChangedEpoch !== child.parentEpochs[i]) { + return true + } + } + + return false +} + +/** + * Detach a child from a parent. + * + * @param parent The parent to detach from. + * @param child The child to detach. + */ +export const detach = (parent: Signal, child: Child) => { + // If the child is not attached to the parent, do nothing. + if (!parent.children.remove(child)) { + return + } + + // If the parent has no more children, then detach the parent from its parents. + if (parent.children.isEmpty && isChild(parent)) { + for (let i = 0, n = parent.parents.length; i < n; i++) { + detach(parent.parents[i], parent) + } + } +} + +/** + * Attach a child to a parent. + * + * @param parent The parent to attach to. + * @param child The child to attach. + */ +export const attach = (parent: Signal, child: Child) => { + // If the child is already attached to the parent, do nothing. + if (!parent.children.add(child)) { + return + } + + // If the parent itself is a child, add the parent to the parent's parents. + if (isChild(parent)) { + for (let i = 0, n = parent.parents.length; i < n; i++) { + attach(parent.parents[i], parent) + } + } +} + +/** + * Get whether two values are equal (insofar as @tldraw/state is concerned). + * + * @param a The first value. + * @param b The second value. + */ +export function equals(a: any, b: any): boolean { + const shallowEquals = + a === b || Object.is(a, b) || Boolean(a && b && typeof a.equals === 'function' && a.equals(b)) + return shallowEquals +} + +export declare function assertNever(x: never): never + +/** @public */ +export const EMPTY_ARRAY: [] = Object.freeze([]) as any diff --git a/packages/state/src/lib/core/index.ts b/packages/state/src/lib/core/index.ts new file mode 100644 index 000000000..6bc513659 --- /dev/null +++ b/packages/state/src/lib/core/index.ts @@ -0,0 +1,12 @@ +export { atom, isAtom } from './Atom' +export type { Atom, AtomOptions } from './Atom' +export { computed, getComputedInstance, isUninitialized, withDiff } from './Computed' +export type { Computed, ComputedOptions } from './Computed' +export { EffectScheduler, react, reactor } from './EffectScheduler' +export type { Reactor } from './EffectScheduler' +export { unsafe__withoutCapture, whyAmIRunning } from './capture' +export { EMPTY_ARRAY } from './helpers' +export { isSignal } from './isSignal' +export { transact, transaction } from './transactions' +export { RESET_VALUE } from './types' +export type { Signal } from './types' diff --git a/packages/state/src/lib/core/isSignal.ts b/packages/state/src/lib/core/isSignal.ts new file mode 100644 index 000000000..5263c5f4d --- /dev/null +++ b/packages/state/src/lib/core/isSignal.ts @@ -0,0 +1,11 @@ +import { _Atom } from './Atom' +import { _Computed } from './Computed' +import { Signal } from './types' + +/** + * Returns true if the given value is a signal (either an Atom or a Computed). + * @public + */ +export function isSignal(value: any): value is Signal { + return value instanceof _Atom || value instanceof _Computed +} diff --git a/packages/state/src/lib/core/transactions.ts b/packages/state/src/lib/core/transactions.ts new file mode 100644 index 000000000..d7298ed92 --- /dev/null +++ b/packages/state/src/lib/core/transactions.ts @@ -0,0 +1,251 @@ +import { _Atom } from './Atom' +import { GLOBAL_START_EPOCH } from './constants' +import { EffectScheduler } from './EffectScheduler' +import { Child, Signal } from './types' + +// The current epoch (global to all atoms). +export let globalEpoch = GLOBAL_START_EPOCH + 1 + +// Whether any transaction is reacting. +let globalIsReacting = false + +export function advanceGlobalEpoch() { + globalEpoch++ +} + +class Transaction { + constructor(public readonly parent: Transaction | null) {} + initialAtomValues = new Map<_Atom, any>() + + /** + * Get whether this transaction is a root (no parents). + * + * @public + */ + get isRoot() { + return this.parent === null + } + + /** + * Commit the transaction's changes. + * + * @public + */ + commit() { + if (this.isRoot) { + // For root transactions, flush changes to each of the atom's initial values. + const atoms = this.initialAtomValues + this.initialAtomValues = new Map() + flushChanges(atoms.keys()) + } else { + // For transaction's with parents, add the transaction's initial values to the parent's. + this.initialAtomValues.forEach((value, atom) => { + if (!this.parent!.initialAtomValues.has(atom)) { + this.parent!.initialAtomValues.set(atom, value) + } + }) + } + } + + /** + * Abort the transaction. + * + * @public + */ + abort() { + globalEpoch++ + + // Reset each of the transaction's atoms to its initial value. + this.initialAtomValues.forEach((value, atom) => { + atom.set(value) + atom.historyBuffer?.clear() + }) + + // Commit the changes. + this.commit() + } +} + +/** + * Collect all of the reactors that need to run for an atom and run them. + * + * @param atom The atom to flush changes for. + */ +function flushChanges(atoms: Iterable<_Atom>) { + if (globalIsReacting) { + throw new Error('cannot change atoms during reaction cycle') + } + + try { + globalIsReacting = true + + // Collect all of the visited reactors. + const reactors = new Set>() + + // Visit each descendant of the atom, collecting reactors. + const traverse = (node: Child) => { + if (node.lastTraversedEpoch === globalEpoch) { + return + } + + node.lastTraversedEpoch = globalEpoch + + if ('maybeScheduleEffect' in node) { + reactors.add(node) + } else { + ;(node as any as Signal).children.visit(traverse) + } + } + + for (const atom of atoms) { + atom.children.visit(traverse) + } + + // Run each reactor. + for (const r of reactors) { + r.maybeScheduleEffect() + } + } finally { + globalIsReacting = false + } +} + +/** + * Handle a change to an atom. + * + * @param atom The atom that changed. + * @param previousValue The atom's previous value. + * + * @internal + */ +export function atomDidChange(atom: _Atom, previousValue: any) { + if (!currentTransaction) { + flushChanges([atom]) + } else if (!currentTransaction.initialAtomValues.has(atom)) { + currentTransaction.initialAtomValues.set(atom, previousValue) + } +} + +/** + * The current transaction, if there is one. + * + * @global + * @public + */ +export let currentTransaction = null as Transaction | null + +/** + * Batches state updates, deferring side effects until after the transaction completes. + * + * @example + * ```ts + * const firstName = atom('John') + * const lastName = atom('Doe') + * + * react('greet', () => { + * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * }) + * + * // Logs "Hello, John Doe!" + * + * transaction(() => { + * firstName.set('Jane') + * lastName.set('Smith') + * }) + * + * // Logs "Hello, Jane Smith!" + * ``` + * + * If the function throws, the transaction is aborted and any signals that were updated during the transaction revert to their state before the transaction began. + * + * @example + * ```ts + * const firstName = atom('John') + * const lastName = atom('Doe') + * + * react('greet', () => { + * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * }) + * + * // Logs "Hello, John Doe!" + * + * transaction(() => { + * firstName.set('Jane') + * throw new Error('oops') + * }) + * + * // Does not log + * // firstName.value === 'John' + * ``` + * + * A `rollback` callback is passed into the function. + * Calling this will prevent the transaction from committing and will revert any signals that were updated during the transaction to their state before the transaction began. + * + * * @example + * ```ts + * const firstName = atom('John') + * const lastName = atom('Doe') + * + * react('greet', () => { + * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * }) + * + * // Logs "Hello, John Doe!" + * + * transaction((rollback) => { + * firstName.set('Jane') + * lastName.set('Smith') + * rollback() + * }) + * + * // Does not log + * // firstName.value === 'John' + * // lastName.value === 'Doe' + * ``` + * + * @param fn - The function to run in a transaction, called with a function to roll back the change. + * @public + */ +export function transaction(fn: (rollback: () => void) => T) { + const txn = new Transaction(currentTransaction) + + // Set the current transaction to the transaction + currentTransaction = txn + + try { + let rollback = false + + // Run the function. + const result = fn(() => (rollback = true)) + + if (rollback) { + // If the rollback was triggered, abort the transaction. + txn.abort() + } else { + // Otherwise, commit the transaction. + txn.commit() + } + + return result + } catch (e) { + // Abort the transaction if the function throws. + txn.abort() + throw e + } finally { + // Set the current transaction to the transaction's parent. + currentTransaction = currentTransaction.parent + } +} + +/** + * Like [transaction](#transaction), but does not create a new transaction if there is already one in progress. + * + * @param fn - The function to run in a transaction. + * @public + */ +export function transact(fn: () => T): T { + if (currentTransaction) { + return fn() + } + return transaction(fn) +} diff --git a/packages/state/src/lib/core/types.ts b/packages/state/src/lib/core/types.ts new file mode 100644 index 000000000..2b0ef7605 --- /dev/null +++ b/packages/state/src/lib/core/types.ts @@ -0,0 +1,67 @@ +import { ArraySet } from './ArraySet' +import { _Computed } from './Computed' +import { EffectScheduler } from './EffectScheduler' + +/** @public */ +export const RESET_VALUE: unique symbol = Symbol('RESET_VALUE') + +/** @public */ +export type RESET_VALUE = typeof RESET_VALUE + +/** + * A Signal is a reactive value container. The value may change over time, and it may keep track of the diffs between sequential values. + * + * There are two types of signal: + * + * - Atomic signals, created using [[atom]]. These are mutable references to values that can be changed using [[Atom.set]]. + * - Computed signals, created using [[computed]]. These are values that are computed from other signals. They are recomputed lazily if their dependencies change. + * + * @public + */ +export interface Signal { + /** + * The name of the signal. This is used at runtime for debugging and perf profiling only. It does not need to be globally unique. + */ + name: string + /** + * The current value of the signal. This is a reactive value, and will update when the signal changes. + * Any computed signal that depends on this signal will be lazily recomputed if this signal changes. + * Any effect that depends on this signal will be rescheduled if this signal changes. + */ + readonly value: Value + /** + * The epoch when this signal's value last changed. Note tha this is not the same as when the value was last computed. + * A signal may recopmute it's value without changing it. + */ + lastChangedEpoch: number + /** + * Returns the sequence of diffs between the the value at the given epoch and the current value. + * Returns the [[RESET_VALUE]] constant if there is not enough information to compute the diff sequence. + * @param epoch - The epoch to get diffs since. + */ + getDiffSince(epoch: number): RESET_VALUE | Diff[] + /** + * Returns the current value of the signal without capturing it as a dependency. + * Use this if you need to retrieve the signal's value in a hot loop where the performance overhead of dependency tracking is too high. + */ + __unsafe__getWithoutCapture(): Value + /** @internal */ + children: ArraySet +} + +/** @internal */ +export type Child = EffectScheduler | _Computed + +/** + * Computes the diff between the previous and current value. + * + * If the diff cannot be computed for whatever reason, it should return [[RESET_VALUE]]. + * + * @public + */ +export type ComputeDiff = ( + previousValue: Value, + currentValue: Value, + lastComputedEpoch: number, + currentEpoch: number +) => Diff | RESET_VALUE diff --git a/packages/state/src/lib/react/index.ts b/packages/state/src/lib/react/index.ts new file mode 100644 index 000000000..c56b51d82 --- /dev/null +++ b/packages/state/src/lib/react/index.ts @@ -0,0 +1,7 @@ +export { track } from './track' +export { useAtom } from './useAtom' +export { useComputed } from './useComputed' +export { useQuickReactor } from './useQuickReactor' +export { useReactor } from './useReactor' +export { useStateTracking } from './useStateTracking' +export { useValue } from './useValue' diff --git a/packages/state/src/lib/react/track.test.tsx b/packages/state/src/lib/react/track.test.tsx new file mode 100644 index 000000000..f6011656a --- /dev/null +++ b/packages/state/src/lib/react/track.test.tsx @@ -0,0 +1,227 @@ +import { createRef, forwardRef, memo, useEffect, useImperativeHandle } from 'react' +import { ReactTestRenderer, act, create } from 'react-test-renderer' +import { atom } from '../core/Atom' +import { track } from './track' + +test("tracked components are memo'd", async () => { + let numRenders = 0 + const Component = track(function Component({ a, b, c }: { a: string; b: string; c: string }) { + numRenders++ + return ( + <> + {a} + {b} + {c} + + ) + }) + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "a", + "b", + "c", + ] + `) + + expect(numRenders).toBe(1) + + await act(() => { + view!.update() + }) + + expect(numRenders).toBe(1) + + await act(() => { + view!.update() + }) + + expect(numRenders).toBe(2) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "a", + "b", + "d", + ] + `) +}) + +test("it's fine to call track on components that are already memo'd", async () => { + let numRenders = 0 + const Component = track( + memo(function Component({ a, b, c }: { a: string; b: string; c: string }) { + numRenders++ + return ( + <> + {a} + {b} + {c} + + ) + }) + ) + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "a", + "b", + "c", + ] + `) + + expect(numRenders).toBe(1) + + await act(() => { + view!.update() + }) + + expect(numRenders).toBe(1) + + await act(() => { + view!.update() + }) + + expect(numRenders).toBe(2) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "a", + "b", + "d", + ] + `) +}) + +test('tracked components can use refs', async () => { + const Component = track( + forwardRef<{ handle: string }, { prop: string }>(function Component({ prop }, ref) { + useImperativeHandle(ref, () => ({ handle: prop }), [prop]) + return <>output + }) + ) + + const ref = createRef<{ handle: string }>() + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot('"output"') + + expect(ref.current?.handle).toBe('hello') + + await act(() => { + view.update() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot('"output"') + + expect(ref.current?.handle).toBe('world') +}) + +test('tracked components update when the state they refernce updates', async () => { + const a = atom('a', 1) + + const C = track(function Component() { + return <>{a.value} + }) + + let view: ReactTestRenderer + + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(`"1"`) + + await act(() => { + a.set(2) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) +}) + +test('things referenced in effects do not trigger updates', async () => { + const a = atom('a', 1) + let numRenders = 0 + + const Component = track(function Component() { + numRenders++ + useEffect(() => { + a.value + }, []) + return <>hi + }) + + let view: ReactTestRenderer + + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(`"hi"`) + expect(numRenders).toBe(1) + + await act(() => { + a.set(2) + }) + + expect(numRenders).toBe(1) + expect(view!.toJSON()).toMatchInlineSnapshot(`"hi"`) +}) + +test("tracked zombie-children don't throw", async () => { + const theAtom = atom>('map', { a: 1, b: 2, c: 3 }) + const Parent = track(function Parent() { + const ids = Object.keys(theAtom.value) + return ( + <> + {ids.map((id) => ( + + ))} + + ) + }) + const Child = track(function Child({ id }: { id: string }) { + if (!(id in theAtom.value)) throw new Error('id not found!') + const value = theAtom.value[id] + return <>{value} + }) + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "1", + "2", + "3", + ] + `) + + // remove id 'b' creating a zombie-child + await act(() => { + theAtom?.update(({ b: _, ...rest }) => rest) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "1", + "3", + ] + `) +}) diff --git a/packages/state/src/lib/react/track.ts b/packages/state/src/lib/react/track.ts new file mode 100644 index 000000000..a2360c7be --- /dev/null +++ b/packages/state/src/lib/react/track.ts @@ -0,0 +1,60 @@ +import React, { forwardRef, FunctionComponent, memo } from 'react' +import { useStateTracking } from './useStateTracking' + +export const ProxyHandlers = { + /** + * This is a function call trap for functional components. When this is called, we know it means + * React did run 'Component()', that means we can use any hooks here to setup our effect and + * store. + * + * With the native Proxy, all other calls such as access/setting to/of properties will be + * forwarded to the target Component, so we don't need to copy the Component's own or inherited + * properties. + * + * @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460 + */ + apply(Component: FunctionComponent, thisArg: any, argumentsList: any) { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useStateTracking(Component.displayName ?? Component.name ?? 'tracked(???)', () => + Component.apply(thisArg, argumentsList) + ) + }, +} + +export const ReactMemoSymbol = Symbol.for('react.memo') +export const ReactForwardRefSymbol = Symbol.for('react.forward_ref') + +/** + * Returns a tracked version of the given component. + * Any signals whose values are read while the component renders will be tracked. + * If any of the tracked signals change later it will cause the component to re-render. + * + * This also wraps the component in a React.memo() call, so it will only re-render if the props change. + * + * @example + * ```ts + * const Counter = track(function Counter(props: CounterProps) { + * const count = useAtom('count', 0) + * const increment = useCallback(() => count.set(count.value + 1), [count]) + * return + * }) + * ``` + * + * @param baseComponent - The base component to track. + * @public + */ +export function track>( + baseComponent: T +): T extends React.MemoExoticComponent ? T : React.MemoExoticComponent { + let compare = null + const $$typeof = baseComponent['$$typeof' as keyof typeof baseComponent] + if ($$typeof === ReactMemoSymbol) { + baseComponent = (baseComponent as any).type + compare = (baseComponent as any).compare + } + if ($$typeof === ReactForwardRefSymbol) { + return memo(forwardRef(new Proxy((baseComponent as any).render, ProxyHandlers) as any)) as any + } + + return memo(new Proxy(baseComponent, ProxyHandlers) as any, compare) as any +} diff --git a/packages/state/src/lib/react/useAtom.test.tsx b/packages/state/src/lib/react/useAtom.test.tsx new file mode 100644 index 000000000..795df8122 --- /dev/null +++ b/packages/state/src/lib/react/useAtom.test.tsx @@ -0,0 +1,51 @@ +import ReactTestRenderer from 'react-test-renderer' +import { Atom } from '../core/Atom' +import { useAtom } from './useAtom' +import { useValue } from './useValue' + +test('useAtom returns an atom', async () => { + let theAtom: null | Atom = null as any + function Component() { + const a = useAtom('myAtom', 'a') + theAtom = a + return <>{useValue(a)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(theAtom).not.toBeNull() + expect(theAtom?.value).toBe('a') + expect(theAtom?.name).toBe('useAtom(myAtom)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"a"`) + + // it doesn't create a new atom on re-render + const a = theAtom! + await ReactTestRenderer.act(() => { + theAtom?.set('b') + }) + expect(a).toBe(theAtom) + expect(view!.toJSON()).toMatchInlineSnapshot(`"b"`) +}) + +test('useAtom supports taking an initializer', async () => { + let theAtom: null | Atom = null as any + function Component() { + const a = useAtom('myAtom', () => 'a') + theAtom = a + return <>{useValue(a)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(theAtom).not.toBeNull() + expect(theAtom?.value).toBe('a') + + expect(theAtom?.name).toBe('useAtom(myAtom)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"a"`) +}) diff --git a/packages/state/src/lib/react/useAtom.ts b/packages/state/src/lib/react/useAtom.ts new file mode 100644 index 000000000..b3383f23e --- /dev/null +++ b/packages/state/src/lib/react/useAtom.ts @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { Atom, AtomOptions, atom } from '../core' + +/** + * Creates a new atom and returns it. The atom will be created only once. + * + * See [[atom]] + * + * @example + * ```ts + * const Counter = track(function Counter () { + * const count = useAtom('count', 0) + * const increment = useCallback(() => count.set(count.value + 1), [count]) + * return + * }) + * ``` + * + * @public + */ +export function useAtom( + /** + * The name of the atom. This does not need to be globally unique. It is used for debugging and performance profiling. + */ + name: string, + /** + * The initial value of the atom. If this is a function, it will be called to get the initial value. + */ + valueOrInitialiser: Value | (() => Value), + /** + * Options for the atom. + */ + options?: AtomOptions +): Atom { + return useState(() => { + const initialValue = + typeof valueOrInitialiser === 'function' ? (valueOrInitialiser as any)() : valueOrInitialiser + + return atom(`useAtom(${name})`, initialValue, options) + })[0] +} diff --git a/packages/state/src/lib/react/useComputed.test.tsx b/packages/state/src/lib/react/useComputed.test.tsx new file mode 100644 index 000000000..4c72fcbcf --- /dev/null +++ b/packages/state/src/lib/react/useComputed.test.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import ReactTestRenderer from 'react-test-renderer' +import { Atom } from '../core/Atom' +import { Computed } from '../core/Computed' +import { useAtom } from './useAtom' +import { useComputed } from './useComputed' +import { useValue } from './useValue' + +test('useComputed returns a computed value', async () => { + let theComputed = null as null | Computed + let theAtom = null as null | Atom + function Component() { + const a = useAtom('a', 1) + theAtom = a + const b = useComputed('a+1', () => a.value + 1, []) + theComputed = b + return <>{useValue(b)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(theComputed).not.toBeNull() + expect(theComputed?.value).toBe(2) + expect(theComputed?.name).toBe('useComputed(a+1)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"6"`) +}) + +test('useComputed has a dependencies array that allows creating a new computed', async () => { + let theComputed = null as null | Computed + let theAtom = null as null | Atom + let setCount = null as null | ((count: number) => void) + function Component() { + const [count, _setCount] = useState(0) + setCount = _setCount + const a = useAtom('a', 1) + theAtom = a + const b = useComputed('a+1', () => a.value + 1, [count]) + theComputed = b + return <>{useValue(b)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + const initialComputed = theComputed + + expect(theComputed).not.toBeNull() + expect(theComputed?.value).toBe(2) + expect(theComputed?.name).toBe('useComputed(a+1)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"6"`) + + expect(initialComputed).toBe(theComputed) + + await ReactTestRenderer.act(() => { + setCount?.(2) + }) + + expect(initialComputed).not.toBe(theComputed) +}) + +test('useComputed allows optionally passing options', async () => { + let theComputed = null as null | Computed + let theAtom = null as null | Atom + let setCount = null as null | ((count: number) => void) + const isEqual = jest.fn((a, b) => a === b) + function Component() { + const [count, _setCount] = useState(0) + setCount = _setCount + const a = useAtom('a', 1) + theAtom = a + const b = useComputed('a+1', () => a.value + 1, { isEqual }, [count]) + theComputed = b + return <>{useValue(b)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + const initialComputed = theComputed + + expect(theComputed).not.toBeNull() + expect(theComputed?.value).toBe(2) + expect(theComputed?.name).toBe('useComputed(a+1)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"6"`) + + expect(initialComputed).toBe(theComputed) + + await ReactTestRenderer.act(() => { + setCount?.(2) + }) + + expect(initialComputed).not.toBe(theComputed) + + expect(isEqual).toHaveBeenCalled() +}) diff --git a/packages/state/src/lib/react/useComputed.ts b/packages/state/src/lib/react/useComputed.ts new file mode 100644 index 000000000..de2a2b982 --- /dev/null +++ b/packages/state/src/lib/react/useComputed.ts @@ -0,0 +1,41 @@ +/* eslint-disable prefer-rest-params */ +import { useMemo } from 'react' +import { Computed, ComputedOptions, computed } from '../core' + +/** + * Creates a new computed signal and returns it. The computed signal will be created only once. + * + * See [[computed]] + * + * @example + * ```ts + * type GreeterProps = { + * firstName: Signal + * lastName: Signal + * } + * + * const Greeter = track(function Greeter ({firstName, lastName}: GreeterProps) { + * const fullName = useComputed('fullName', () => `${firstName.value} ${lastName.value}`) + * return
Hello {fullName.value}!
+ * }) + * ``` + * + * @public + */ +export function useComputed(name: string, compute: () => Value, deps: any[]): Computed +/** @public */ +export function useComputed( + name: string, + compute: () => Value, + opts: ComputedOptions, + deps: any[] +): Computed +/** @public */ +export function useComputed() { + const name = arguments[0] + const compute = arguments[1] + const opts = arguments.length === 3 ? undefined : arguments[2] + const deps = arguments.length === 3 ? arguments[2] : arguments[3] + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => computed(`useComputed(${name})`, compute, opts), deps) +} diff --git a/packages/editor/src/lib/hooks/useQuickReactor.ts b/packages/state/src/lib/react/useQuickReactor.ts similarity index 87% rename from packages/editor/src/lib/hooks/useQuickReactor.ts rename to packages/state/src/lib/react/useQuickReactor.ts index 10c8d5eef..fac95bba1 100644 --- a/packages/editor/src/lib/hooks/useQuickReactor.ts +++ b/packages/state/src/lib/react/useQuickReactor.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { EffectScheduler, EMPTY_ARRAY } from 'signia' +import { EMPTY_ARRAY, EffectScheduler } from '../core' /** @public */ export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = EMPTY_ARRAY) { diff --git a/packages/editor/src/lib/hooks/useReactor.ts b/packages/state/src/lib/react/useReactor.ts similarity index 91% rename from packages/editor/src/lib/hooks/useReactor.ts rename to packages/state/src/lib/react/useReactor.ts index 48a6462f9..11dbd03a8 100644 --- a/packages/editor/src/lib/hooks/useReactor.ts +++ b/packages/state/src/lib/react/useReactor.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo } from 'react' -import { EffectScheduler } from 'signia' +import { EffectScheduler } from '../core' /** @public */ export function useReactor(name: string, reactFn: () => void, deps: undefined | any[] = []) { diff --git a/packages/state/src/lib/react/useStateTracking.test.tsx b/packages/state/src/lib/react/useStateTracking.test.tsx new file mode 100644 index 000000000..359735de4 --- /dev/null +++ b/packages/state/src/lib/react/useStateTracking.test.tsx @@ -0,0 +1,203 @@ +import * as React from 'react' +import { act, create, ReactTestRenderer } from 'react-test-renderer' +import { atom } from '../core/Atom' +import { useStateTracking } from './useStateTracking' + +describe('useStateTracking', () => { + it('causes a rerender when a dependency changes', async () => { + const a = atom('', 0) + + const Component = () => { + const val = useStateTracking('', () => { + return a.value + }) + return <>You are {val} years old + } + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "0", + " years old", + ] + `) + + act(() => { + a.set(1) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "1", + " years old", + ] + `) + }) + + it('allows using hooks inside the callback', async () => { + const _age = atom('', 0) + let setHeight: (height: number) => void + + const Component = () => { + let height + const age = useStateTracking('', () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + ;[height, setHeight] = React.useState(20) + return _age.value + }) + return ( + <> + You are {age} years old and {height} meters tall + + ) + } + + let view: ReactTestRenderer + await act(() => { + view = create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "0", + " years old and ", + "20", + " meters tall", + ] + `) + + act(() => { + _age.set(1) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "1", + " years old and ", + "20", + " meters tall", + ] + `) + + act(() => { + setHeight(21) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "1", + " years old and ", + "21", + " meters tall", + ] + `) + }) + + it('allows throwing promises to trigger suspense boundaries', async () => { + const a = atom('age', null) + + let resolve = (_val: string) => { + // noop + } + + const Component = () => { + const val = useStateTracking('', () => { + if (a.value === null) { + throw new Promise((r) => { + resolve = r + }) + } + return a.value + }) + return <>You are {val} years old + } + + let view: ReactTestRenderer = null as any + await act(() => { + view = create( + fallback}> + + + ) + }) + + expect(view.toJSON()).toMatchInlineSnapshot(`"fallback"`) + + await act(() => { + a.set(1) + }) + // merely setting the value won't trigger a rerender, the promise must resolve + expect(view.toJSON()).toMatchInlineSnapshot(`"fallback"`) + + await act(() => { + resolve('resolved') + }) + + expect(view.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "1", + " years old", + ] + `) + }) + + it('stops reacting when the component unmounts', async () => { + const a = atom('', 0) + let numRenders = 0 + const Component = () => { + const val = useStateTracking('', () => { + numRenders++ + return a.value + }) + return <>You are {val} years old + } + + let view: ReactTestRenderer + await act(() => { + view = create(React.createElement(Component)) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "0", + " years old", + ] + `) + + expect(numRenders).toBe(1) + + await act(() => { + a.set(1) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "You are ", + "1", + " years old", + ] + `) + + expect(numRenders).toBe(2) + + await act(() => { + view!.unmount() + }) + + await act(() => { + a.set(2) + }) + + expect(numRenders).toBe(2) + }) +}) diff --git a/packages/state/src/lib/react/useStateTracking.ts b/packages/state/src/lib/react/useStateTracking.ts new file mode 100644 index 000000000..f46786b34 --- /dev/null +++ b/packages/state/src/lib/react/useStateTracking.ts @@ -0,0 +1,58 @@ +import React from 'react' +import { EffectScheduler } from '../core' + +/** @internal */ +export function useStateTracking(name: string, render: () => T): T { + // user render is only called at the bottom of this function, indirectly via scheduler.execute() + // we need it to always be up-to-date when calling scheduler.execute() but it'd be wasteful to + // instantiate a new EffectScheduler on every render, so we use an immediately-updated ref + // to wrap it + const renderRef = React.useRef(render) + renderRef.current = render + + const [scheduler, subscribe, getSnapshot] = React.useMemo(() => { + let scheduleUpdate = null as null | (() => void) + // useSyncExternalStore requires a subscribe function that returns an unsubscribe function + const subscribe = (cb: () => void) => { + scheduleUpdate = cb + return () => { + scheduleUpdate = null + } + } + + const scheduler = new EffectScheduler( + `useStateTracking(${name})`, + // this is what `scheduler.execute()` will call + () => renderRef.current?.(), + // this is what will be invoked when @tldraw/state detects a change in an upstream reactive value + { + scheduleEffect() { + scheduleUpdate?.() + }, + } + ) + + // we use an incrementing number based on when this + const getSnapshot = () => scheduler.scheduleCount + + return [scheduler, subscribe, getSnapshot] + }, [name]) + + React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + // reactive dependencies are captured when `scheduler.execute()` is called + // and then to make it reactive we wait for a `useEffect` to 'attach' + // this allows us to avoid rendering outside of React's render phase + // and avoid 'zombie' components that try to render with bad/deleted data before + // react has a chance to umount them. + React.useEffect(() => { + scheduler.attach() + // do not execute, we only do that in render + scheduler.maybeScheduleEffect() + return () => { + scheduler.detach() + } + }, [scheduler]) + + return scheduler.execute() +} diff --git a/packages/state/src/lib/react/useValue.test.tsx b/packages/state/src/lib/react/useValue.test.tsx new file mode 100644 index 000000000..d5d7d71df --- /dev/null +++ b/packages/state/src/lib/react/useValue.test.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import ReactTestRenderer from 'react-test-renderer' +import { Atom, atom } from '../core/Atom' +import { Computed } from '../core/Computed' +import { useAtom } from './useAtom' +import { useComputed } from './useComputed' +import { useValue } from './useValue' + +test('useValue returns a value from a computed', async () => { + let theComputed = null as null | Computed + let theAtom = null as null | Atom + function Component() { + const a = useAtom('a', 1) + theAtom = a + const b = useComputed('a+1', () => a.value + 1, []) + theComputed = b + return <>{useValue(b)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(theComputed).not.toBeNull() + expect(theComputed?.value).toBe(2) + expect(theComputed?.name).toBe('useComputed(a+1)') + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"6"`) +}) + +test('useValue returns a value from an atom', async () => { + let theAtom = null as null | Atom + function Component() { + const a = useAtom('a', 1) + theAtom = a + return <>{useValue(a)} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(`"1"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"5"`) +}) + +test('useValue returns a value from a compute function', async () => { + let theAtom = null as null | Atom + let setB = null as null | ((b: number) => void) + function Component() { + const a = useAtom('a', 1) + const [b, _setB] = useState(1) + setB = _setB + theAtom = a + const c = useValue('a+b', () => a.value + b, [b]) + return <>{c} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(`"2"`) + + await ReactTestRenderer.act(() => { + theAtom?.set(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"6"`) + + await ReactTestRenderer.act(() => { + setB!(5) + }) + expect(view!.toJSON()).toMatchInlineSnapshot(`"10"`) +}) + +test("useValue doesn't throw when used in a zombie-child component", async () => { + const theAtom = atom>('map', { a: 1, b: 2, c: 3 }) + function Parent() { + const ids = useValue('ids', () => Object.keys(theAtom.value), []) + return ( + <> + {ids.map((id) => ( + + ))} + + ) + } + function Child({ id }: { id: string }) { + const value = useValue( + 'value', + () => { + if (!(id in theAtom.value)) throw new Error('id not found!') + return theAtom.value[id] + }, + [id] + ) + return <>{value} + } + + let view: ReactTestRenderer.ReactTestRenderer + await ReactTestRenderer.act(() => { + view = ReactTestRenderer.create() + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "1", + "2", + "3", + ] + `) + + // remove id 'b' creating a zombie-child + await ReactTestRenderer.act(() => { + theAtom?.update(({ b: _, ...rest }) => rest) + }) + + expect(view!.toJSON()).toMatchInlineSnapshot(` + Array [ + "1", + "3", + ] + `) +}) diff --git a/packages/state/src/lib/react/useValue.ts b/packages/state/src/lib/react/useValue.ts new file mode 100644 index 000000000..d2018958e --- /dev/null +++ b/packages/state/src/lib/react/useValue.ts @@ -0,0 +1,98 @@ +/* eslint-disable prefer-rest-params */ +import { useMemo, useRef, useSyncExternalStore } from 'react' +import { Signal, computed, react } from '../core' + +/** + * Extracts the value from a signal and subscribes to it. + * + * Note that you do not need to use this hook if you are wrapping the component with [[track]] + * + * @example + * ```ts + * const Counter: React.FC = () => { + * const $count = useAtom('count', 0) + * const increment = useCallback(() => $count.set($count.value + 1), [count]) + * const currentCount = useValue($count) + * return + * } + * ``` + * + * You can also pass a function to compute the value and it will be memoized as in [[useComputed]]: + * + * @example + * ```ts + * type GreeterProps = { + * firstName: Signal + * lastName: Signal + * } + * + * const Greeter = track(function Greeter({ firstName, lastName }: GreeterProps) { + * const fullName = useValue('fullName', () => `${firstName.value} ${lastName.value}`, [ + * firstName, + * lastName, + * ]) + * return
Hello {fullName}!
+ * }) + * ``` + * + * @public + */ +export function useValue(value: Signal): Value +/** @public */ +export function useValue(name: string, fn: () => Value, deps: unknown[]): Value +/** @public */ +export function useValue() { + const args = arguments + // deps will be either the computed or the deps array + const deps = args.length === 3 ? args[2] : [args[0]] + const name = args.length === 3 ? args[0] : `useValue(${args[0].name})` + + const isInRender = useRef(true) + isInRender.current = true + + const $val = useMemo(() => { + if (args.length === 1) { + return args[0] + } + return computed(name, () => { + if (isInRender.current) { + return args[1]() + } else { + try { + return args[1]() + } catch { + // when getSnapshot is called outside of the render phase & + // subsequently throws an error, it might be because we're + // in a zombie-child state. in that case, we suppress the + // error and instead return a new dummy value to trigger a + // react re-render. if we were in a zombie child, react will + // unmount us instead of re-rendering so the error is + // irrelevant. if we're not in a zombie-child, react will + // call `getSnapshot` again in the render phase, and the + // error will be thrown as expected.å + return {} + } + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) + + try { + const { subscribe, getSnapshot } = useMemo(() => { + return { + subscribe: (listen: () => void) => { + return react(`useValue(${name})`, () => { + $val.value + listen() + }) + }, + getSnapshot: () => $val.value, + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [$val]) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + } finally { + isInRender.current = false + } +} diff --git a/packages/state/tsconfig.json b/packages/state/tsconfig.json new file mode 100644 index 000000000..113ca5c0c --- /dev/null +++ b/packages/state/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../config/tsconfig.base.json", + "include": ["src"], + "exclude": ["node_modules", "dist", ".tsbuild*"], + "compilerOptions": { + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "references": [{ "path": "../utils" }] +} diff --git a/packages/store/api-report.md b/packages/store/api-report.md index 37411c79e..a446f58f5 100644 --- a/packages/store/api-report.md +++ b/packages/store/api-report.md @@ -4,8 +4,8 @@ ```ts -import { Atom } from 'signia'; -import { Computed } from 'signia'; +import { Atom } from '@tldraw/state'; +import { Computed } from '@tldraw/state'; // @public export type AllRecords> = ExtractR>; diff --git a/packages/store/package.json b/packages/store/package.json index 1ca4e9c59..6e93ed037 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -41,13 +41,11 @@ "lint": "yarn run -T tsx ../../scripts/lint.ts" }, "dependencies": { + "@tldraw/state": "workspace:*", "@tldraw/utils": "workspace:*", "lodash.isequal": "^4.5.0", "nanoid": "4.0.2" }, - "peerDependencies": { - "signia": "*" - }, "devDependencies": { "@peculiar/webcrypto": "^1.4.0", "@types/lodash.isequal": "^4.5.6", diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index 7f1c15ceb..21a61d024 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -1,3 +1,4 @@ +import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state' import { filterEntries, objectMapEntries, @@ -7,7 +8,6 @@ import { throttledRaf, } from '@tldraw/utils' import { nanoid } from 'nanoid' -import { Atom, Computed, Reactor, atom, computed, reactor, transact } from 'signia' import { IdOf, RecordId, UnknownRecord } from './BaseRecord' import { Cache } from './Cache' import { RecordScope } from './RecordType' diff --git a/packages/store/src/lib/StoreQueries.ts b/packages/store/src/lib/StoreQueries.ts index dd3f6290e..1196ae6ac 100644 --- a/packages/store/src/lib/StoreQueries.ts +++ b/packages/store/src/lib/StoreQueries.ts @@ -1,5 +1,3 @@ -import { objectMapValues } from '@tldraw/utils' -import isEqual from 'lodash.isequal' import { Atom, computed, @@ -8,7 +6,9 @@ import { isUninitialized, RESET_VALUE, withDiff, -} from 'signia' +} from '@tldraw/state' +import { objectMapValues } from '@tldraw/utils' +import isEqual from 'lodash.isequal' import { IdOf, UnknownRecord } from './BaseRecord' import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery' import { IncrementalSetConstructor } from './IncrementalSetConstructor' diff --git a/packages/store/src/lib/test/recordStore.test.ts b/packages/store/src/lib/test/recordStore.test.ts index 69ee86a52..95a96dcd7 100644 --- a/packages/store/src/lib/test/recordStore.test.ts +++ b/packages/store/src/lib/test/recordStore.test.ts @@ -1,4 +1,4 @@ -import { Computed, react, RESET_VALUE, transact } from 'signia' +import { Computed, react, RESET_VALUE, transact } from '@tldraw/state' import { BaseRecord, RecordId } from '../BaseRecord' import { createRecordType } from '../RecordType' import { CollectionDiff, RecordsDiff, Store } from '../Store' diff --git a/packages/store/src/lib/test/recordStoreFuzzing.test.ts b/packages/store/src/lib/test/recordStoreFuzzing.test.ts index c83107643..922e18ab8 100644 --- a/packages/store/src/lib/test/recordStoreFuzzing.test.ts +++ b/packages/store/src/lib/test/recordStoreFuzzing.test.ts @@ -1,4 +1,4 @@ -import { atom, EffectScheduler, RESET_VALUE } from 'signia' +import { atom, EffectScheduler, RESET_VALUE } from '@tldraw/state' import { BaseRecord, IdOf, RecordId, UnknownRecord } from '../BaseRecord' import { executeQuery } from '../executeQuery' import { createRecordType } from '../RecordType' diff --git a/packages/store/src/lib/test/recordStoreQueries.test.ts b/packages/store/src/lib/test/recordStoreQueries.test.ts index 40e448541..59839dfaf 100644 --- a/packages/store/src/lib/test/recordStoreQueries.test.ts +++ b/packages/store/src/lib/test/recordStoreQueries.test.ts @@ -1,4 +1,4 @@ -import { atom, RESET_VALUE } from 'signia' +import { atom, RESET_VALUE } from '@tldraw/state' import { BaseRecord, RecordId } from '../BaseRecord' import { createRecordType } from '../RecordType' import { Store } from '../Store' diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json index 113ca5c0c..8693c77b1 100644 --- a/packages/store/tsconfig.json +++ b/packages/store/tsconfig.json @@ -6,5 +6,5 @@ "outDir": "./.tsbuild", "rootDir": "src" }, - "references": [{ "path": "../utils" }] + "references": [{ "path": "../utils" }, { "path": "../state" }] } diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index a2b278b18..b3193d646 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -9,7 +9,7 @@ import { Expand } from '@tldraw/utils'; import { Migrations } from '@tldraw/store'; import { RecordId } from '@tldraw/store'; import { RecordType } from '@tldraw/store'; -import { Signal } from 'signia'; +import { Signal } from '@tldraw/state'; import { Store } from '@tldraw/store'; import { StoreSchema } from '@tldraw/store'; import { StoreSnapshot } from '@tldraw/store'; diff --git a/packages/tlschema/package.json b/packages/tlschema/package.json index bdb107825..b69945d40 100644 --- a/packages/tlschema/package.json +++ b/packages/tlschema/package.json @@ -56,12 +56,10 @@ ] }, "dependencies": { + "@tldraw/state": "workspace:*", "@tldraw/store": "workspace:*", "@tldraw/utils": "workspace:*", "@tldraw/validate": "workspace:*", "nanoid": "4.0.2" - }, - "peerDependencies": { - "signia": "*" } } diff --git a/packages/tlschema/src/createPresenceStateDerivation.ts b/packages/tlschema/src/createPresenceStateDerivation.ts index 102f0f31e..6c174456a 100644 --- a/packages/tlschema/src/createPresenceStateDerivation.ts +++ b/packages/tlschema/src/createPresenceStateDerivation.ts @@ -1,4 +1,4 @@ -import { Signal, computed } from 'signia' +import { Signal, computed } from '@tldraw/state' import { TLStore } from './TLStore' import { CameraRecordType } from './records/TLCamera' import { TLINSTANCE_ID } from './records/TLInstance' diff --git a/packages/ui/package.json b/packages/ui/package.json index 7f58cc3be..4cf139bd9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -54,6 +54,7 @@ "@radix-ui/react-toast": "^1.1.1", "@tldraw/editor": "workspace:*", "@tldraw/primitives": "workspace:*", + "@tldraw/state": "workspace:*", "@tldraw/tlschema": "workspace:*", "@tldraw/utils": "workspace:*", "browser-fs-access": "^0.31.0", @@ -64,9 +65,7 @@ }, "peerDependencies": { "react": "^18", - "react-dom": "^18", - "signia": "*", - "signia-react": "*" + "react-dom": "^18" }, "devDependencies": { "@peculiar/webcrypto": "^1.4.0", diff --git a/packages/ui/src/lib/TldrawUi.tsx b/packages/ui/src/lib/TldrawUi.tsx index 2e5f5fa7d..b64a15864 100644 --- a/packages/ui/src/lib/TldrawUi.tsx +++ b/packages/ui/src/lib/TldrawUi.tsx @@ -1,8 +1,8 @@ import { ToastProvider } from '@radix-ui/react-toast' import { useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import classNames from 'classnames' import React, { ReactNode } from 'react' -import { useValue } from 'signia-react' import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider' import { BackToContent } from './components/BackToContent' import { DebugPanel } from './components/DebugPanel' diff --git a/packages/ui/src/lib/components/ContextMenu.tsx b/packages/ui/src/lib/components/ContextMenu.tsx index fbc510ca2..7cfac42c3 100644 --- a/packages/ui/src/lib/components/ContextMenu.tsx +++ b/packages/ui/src/lib/components/ContextMenu.tsx @@ -1,8 +1,8 @@ import * as _ContextMenu from '@radix-ui/react-context-menu' import { Editor, preventDefault, useContainer, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import classNames from 'classnames' import { useCallback, useState } from 'react' -import { useValue } from 'signia-react' import { TLUiMenuChild } from '../hooks/menuHelpers' import { useBreakpoint } from '../hooks/useBreakpoint' import { useContextMenuSchema } from '../hooks/useContextMenuSchema' diff --git a/packages/ui/src/lib/components/DebugPanel.tsx b/packages/ui/src/lib/components/DebugPanel.tsx index 24b571f30..cbbb8d1dd 100644 --- a/packages/ui/src/lib/components/DebugPanel.tsx +++ b/packages/ui/src/lib/components/DebugPanel.tsx @@ -9,8 +9,8 @@ import { uniqueId, useEditor, } from '@tldraw/editor' +import { track, useValue } from '@tldraw/state' import * as React from 'react' -import { track, useValue } from 'signia-react' import { useDialogs } from '../hooks/useDialogsProvider' import { useToasts } from '../hooks/useToastsProvider' import { useTranslation } from '../hooks/useTranslation/useTranslation' diff --git a/packages/ui/src/lib/components/DuplicateButton.tsx b/packages/ui/src/lib/components/DuplicateButton.tsx index 3f9c58d05..5bb43a0f1 100644 --- a/packages/ui/src/lib/components/DuplicateButton.tsx +++ b/packages/ui/src/lib/components/DuplicateButton.tsx @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useActions } from '../hooks/useActions' import { useTranslation } from '../hooks/useTranslation/useTranslation' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/EditLinkDialog.tsx b/packages/ui/src/lib/components/EditLinkDialog.tsx index 95e360679..5362550c0 100644 --- a/packages/ui/src/lib/components/EditLinkDialog.tsx +++ b/packages/ui/src/lib/components/EditLinkDialog.tsx @@ -1,6 +1,6 @@ import { TLBaseShape, isValidUrl, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import { useCallback, useEffect, useRef, useState } from 'react' -import { track } from 'signia-react' import { TLUiDialogProps } from '../hooks/useDialogsProvider' import { useTranslation } from '../hooks/useTranslation/useTranslation' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/EmbedDialog.tsx b/packages/ui/src/lib/components/EmbedDialog.tsx index e8f1b9ade..7635b9f80 100644 --- a/packages/ui/src/lib/components/EmbedDialog.tsx +++ b/packages/ui/src/lib/components/EmbedDialog.tsx @@ -1,7 +1,7 @@ import { TLEmbedResult, getEmbedInfo, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import { EMBED_DEFINITIONS, EmbedDefinition } from '@tldraw/tlschema' import { useRef, useState } from 'react' -import { track } from 'signia-react' import { useAssetUrls } from '../hooks/useAssetUrls' import { TLUiDialogProps } from '../hooks/useDialogsProvider' import { useTranslation } from '../hooks/useTranslation/useTranslation' diff --git a/packages/ui/src/lib/components/FollowingIndicator.tsx b/packages/ui/src/lib/components/FollowingIndicator.tsx index 3d90192ce..777511bb7 100644 --- a/packages/ui/src/lib/components/FollowingIndicator.tsx +++ b/packages/ui/src/lib/components/FollowingIndicator.tsx @@ -1,5 +1,5 @@ import { useEditor, usePresence } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' export function FollowingIndicator() { const editor = useEditor() diff --git a/packages/ui/src/lib/components/HTMLCanvas.tsx b/packages/ui/src/lib/components/HTMLCanvas.tsx index b355b0e8d..f369b9283 100644 --- a/packages/ui/src/lib/components/HTMLCanvas.tsx +++ b/packages/ui/src/lib/components/HTMLCanvas.tsx @@ -1,6 +1,6 @@ import { useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import * as React from 'react' -import { track } from 'signia-react' /** @internal */ export const HTMLCanvas = track(function HTMLCanvas() { diff --git a/packages/ui/src/lib/components/MenuZone.tsx b/packages/ui/src/lib/components/MenuZone.tsx index 2237e6600..1d6b6b328 100644 --- a/packages/ui/src/lib/components/MenuZone.tsx +++ b/packages/ui/src/lib/components/MenuZone.tsx @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useBreakpoint } from '../hooks/useBreakpoint' import { useReadonly } from '../hooks/useReadonly' import { ActionsMenu } from './ActionsMenu' diff --git a/packages/ui/src/lib/components/MobileStylePanel.tsx b/packages/ui/src/lib/components/MobileStylePanel.tsx index 3b6fc8b96..86cad8bf4 100644 --- a/packages/ui/src/lib/components/MobileStylePanel.tsx +++ b/packages/ui/src/lib/components/MobileStylePanel.tsx @@ -1,6 +1,6 @@ import { DefaultColorStyle, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { useCallback } from 'react' -import { useValue } from 'signia-react' import { useTranslation } from '../hooks/useTranslation/useTranslation' import { StylePanel } from './StylePanel/StylePanel' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/MoveToPageMenu.tsx b/packages/ui/src/lib/components/MoveToPageMenu.tsx index 21a54c26a..da2a0f21b 100644 --- a/packages/ui/src/lib/components/MoveToPageMenu.tsx +++ b/packages/ui/src/lib/components/MoveToPageMenu.tsx @@ -1,6 +1,6 @@ import * as _ContextMenu from '@radix-ui/react-context-menu' import { PageRecordType, TLPageId, useContainer, useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useToasts } from '../hooks/useToastsProvider' import { useTranslation } from '../hooks/useTranslation/useTranslation' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/NavigationZone/Minimap.tsx b/packages/ui/src/lib/components/NavigationZone/Minimap.tsx index 98b5fa181..38de2506a 100644 --- a/packages/ui/src/lib/components/NavigationZone/Minimap.tsx +++ b/packages/ui/src/lib/components/NavigationZone/Minimap.tsx @@ -8,8 +8,8 @@ import { useQuickReactor, } from '@tldraw/editor' import { Box2d, Vec2d, intersectPolygonPolygon } from '@tldraw/primitives' +import { track } from '@tldraw/state' import * as React from 'react' -import { track } from 'signia-react' import { MinimapManager } from './MinimapManager' export interface MinimapProps { diff --git a/packages/ui/src/lib/components/NavigationZone/ZoomMenu.tsx b/packages/ui/src/lib/components/NavigationZone/ZoomMenu.tsx index 194f7b617..cb84b3e2b 100644 --- a/packages/ui/src/lib/components/NavigationZone/ZoomMenu.tsx +++ b/packages/ui/src/lib/components/NavigationZone/ZoomMenu.tsx @@ -1,6 +1,6 @@ import { ANIMATION_MEDIUM_MS, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import * as React from 'react' -import { track } from 'signia-react' import { useActions } from '../../hooks/useActions' import { useBreakpoint } from '../../hooks/useBreakpoint' import { useTranslation } from '../../hooks/useTranslation/useTranslation' diff --git a/packages/ui/src/lib/components/PageMenu/PageItemSubmenu.tsx b/packages/ui/src/lib/components/PageMenu/PageItemSubmenu.tsx index a31e3bbb3..e15ef03b6 100644 --- a/packages/ui/src/lib/components/PageMenu/PageItemSubmenu.tsx +++ b/packages/ui/src/lib/components/PageMenu/PageItemSubmenu.tsx @@ -1,7 +1,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { MAX_PAGES, PageRecordType, TLPageId, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import { useCallback } from 'react' -import { track } from 'signia-react' import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { Button } from '../primitives/Button' import * as M from '../primitives/DropdownMenu' diff --git a/packages/ui/src/lib/components/PageMenu/PageMenu.tsx b/packages/ui/src/lib/components/PageMenu/PageMenu.tsx index b326731c0..74b8dc906 100644 --- a/packages/ui/src/lib/components/PageMenu/PageMenu.tsx +++ b/packages/ui/src/lib/components/PageMenu/PageMenu.tsx @@ -1,7 +1,7 @@ import { MAX_PAGES, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { PageRecordType, TLPageId } from '@tldraw/tlschema' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useValue } from 'signia-react' import { useBreakpoint } from '../../hooks/useBreakpoint' import { useMenuIsOpen } from '../../hooks/useMenuIsOpen' import { useTranslation } from '../../hooks/useTranslation/useTranslation' diff --git a/packages/ui/src/lib/components/PenModeToggle.tsx b/packages/ui/src/lib/components/PenModeToggle.tsx index b17b9e3e5..1c2bec711 100644 --- a/packages/ui/src/lib/components/PenModeToggle.tsx +++ b/packages/ui/src/lib/components/PenModeToggle.tsx @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useActions } from '../hooks/useActions' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/StopFollowing.tsx b/packages/ui/src/lib/components/StopFollowing.tsx index cdd50b7cf..51cbf54ef 100644 --- a/packages/ui/src/lib/components/StopFollowing.tsx +++ b/packages/ui/src/lib/components/StopFollowing.tsx @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useActions } from '../hooks/useActions' import { Button } from './primitives/Button' diff --git a/packages/ui/src/lib/components/StylePanel/StylePanel.tsx b/packages/ui/src/lib/components/StylePanel/StylePanel.tsx index afafc5daf..4a7de4830 100644 --- a/packages/ui/src/lib/components/StylePanel/StylePanel.tsx +++ b/packages/ui/src/lib/components/StylePanel/StylePanel.tsx @@ -17,9 +17,9 @@ import { StyleProp, useEditor, } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { minBy } from '@tldraw/utils' import React, { useCallback } from 'react' -import { useValue } from 'signia-react' import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { Button } from '../primitives/Button' import { ButtonPicker } from '../primitives/ButtonPicker' diff --git a/packages/ui/src/lib/components/Toolbar/ToggleToolLockedButton.tsx b/packages/ui/src/lib/components/Toolbar/ToggleToolLockedButton.tsx index bb5d34fc8..c1b85d74e 100644 --- a/packages/ui/src/lib/components/Toolbar/ToggleToolLockedButton.tsx +++ b/packages/ui/src/lib/components/Toolbar/ToggleToolLockedButton.tsx @@ -1,6 +1,6 @@ import { useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import classNames from 'classnames' -import { useValue } from 'signia-react' import { useBreakpoint } from '../../hooks/useBreakpoint' import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { Button } from '../primitives/Button' diff --git a/packages/ui/src/lib/components/Toolbar/Toolbar.tsx b/packages/ui/src/lib/components/Toolbar/Toolbar.tsx index b781f7a27..19b90503d 100644 --- a/packages/ui/src/lib/components/Toolbar/Toolbar.tsx +++ b/packages/ui/src/lib/components/Toolbar/Toolbar.tsx @@ -1,7 +1,7 @@ import { GeoShapeGeoStyle, preventDefault, useEditor } from '@tldraw/editor' +import { track, useValue } from '@tldraw/state' import classNames from 'classnames' import React from 'react' -import { track, useValue } from 'signia-react' import { useBreakpoint } from '../../hooks/useBreakpoint' import { useReadonly } from '../../hooks/useReadonly' import { TLUiToolbarItem, useToolbarSchema } from '../../hooks/useToolbarSchema' diff --git a/packages/ui/src/lib/components/TrashButton.tsx b/packages/ui/src/lib/components/TrashButton.tsx index eeba150a2..d07d21778 100644 --- a/packages/ui/src/lib/components/TrashButton.tsx +++ b/packages/ui/src/lib/components/TrashButton.tsx @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { track } from 'signia-react' +import { track } from '@tldraw/state' import { useActions } from '../hooks/useActions' import { useReadonly } from '../hooks/useReadonly' import { useTranslation } from '../hooks/useTranslation/useTranslation' diff --git a/packages/ui/src/lib/hooks/menuHelpers.ts b/packages/ui/src/lib/hooks/menuHelpers.ts index 945898fe4..270621a89 100644 --- a/packages/ui/src/lib/hooks/menuHelpers.ts +++ b/packages/ui/src/lib/hooks/menuHelpers.ts @@ -1,6 +1,6 @@ import { ArrowShapeUtil, Editor, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { assert, exhaustiveSwitchError } from '@tldraw/utils' -import { useValue } from 'signia-react' import { TLUiActionItem } from './useActions' import { TLUiToolItem } from './useTools' import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey' diff --git a/packages/ui/src/lib/hooks/useActionsMenuSchema.tsx b/packages/ui/src/lib/hooks/useActionsMenuSchema.tsx index bc32e22ba..e4d0b730c 100644 --- a/packages/ui/src/lib/hooks/useActionsMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useActionsMenuSchema.tsx @@ -1,6 +1,6 @@ import { Editor, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import React, { useMemo } from 'react' -import { track } from 'signia-react' import { TLUiMenuSchema, menuItem, diff --git a/packages/ui/src/lib/hooks/useBreakpoint.tsx b/packages/ui/src/lib/hooks/useBreakpoint.tsx index e462af57b..bd0462002 100644 --- a/packages/ui/src/lib/hooks/useBreakpoint.tsx +++ b/packages/ui/src/lib/hooks/useBreakpoint.tsx @@ -1,6 +1,6 @@ import { useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import React, { useContext } from 'react' -import { useValue } from 'signia-react' import { PORTRAIT_BREAKPOINTS } from '../constants' const BreakpointContext = React.createContext(0) diff --git a/packages/ui/src/lib/hooks/useCanRedo.ts b/packages/ui/src/lib/hooks/useCanRedo.ts index 09b7feddb..44d138e0a 100644 --- a/packages/ui/src/lib/hooks/useCanRedo.ts +++ b/packages/ui/src/lib/hooks/useCanRedo.ts @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' /** @public */ export function useCanRedo() { diff --git a/packages/ui/src/lib/hooks/useCanUndo.ts b/packages/ui/src/lib/hooks/useCanUndo.ts index 3d3f3f4ab..3143f2e65 100644 --- a/packages/ui/src/lib/hooks/useCanUndo.ts +++ b/packages/ui/src/lib/hooks/useCanUndo.ts @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' /** @public */ export function useCanUndo() { diff --git a/packages/ui/src/lib/hooks/useContextMenuSchema.tsx b/packages/ui/src/lib/hooks/useContextMenuSchema.tsx index 24c03b10d..15322ee0c 100644 --- a/packages/ui/src/lib/hooks/useContextMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useContextMenuSchema.tsx @@ -1,6 +1,6 @@ import { BookmarkShapeUtil, Editor, EmbedShapeUtil, getEmbedInfo, useEditor } from '@tldraw/editor' +import { track, useValue } from '@tldraw/state' import React, { useMemo } from 'react' -import { track, useValue } from 'signia-react' import { TLUiMenuSchema, compactMenuItems, diff --git a/packages/ui/src/lib/hooks/useEditorIsFocused.ts b/packages/ui/src/lib/hooks/useEditorIsFocused.ts index 908d154f5..75db3451b 100644 --- a/packages/ui/src/lib/hooks/useEditorIsFocused.ts +++ b/packages/ui/src/lib/hooks/useEditorIsFocused.ts @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' export function useEditorIsFocused() { const editor = useEditor() diff --git a/packages/ui/src/lib/hooks/useHasLinkShapeSelected.ts b/packages/ui/src/lib/hooks/useHasLinkShapeSelected.ts index f9f351dea..535a5766e 100644 --- a/packages/ui/src/lib/hooks/useHasLinkShapeSelected.ts +++ b/packages/ui/src/lib/hooks/useHasLinkShapeSelected.ts @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' export function useHasLinkShapeSelected() { const editor = useEditor() diff --git a/packages/ui/src/lib/hooks/useHelpMenuSchema.tsx b/packages/ui/src/lib/hooks/useHelpMenuSchema.tsx index 7a229427f..10b7dd61c 100644 --- a/packages/ui/src/lib/hooks/useHelpMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useHelpMenuSchema.tsx @@ -1,7 +1,7 @@ import { Editor, TLLanguage, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import { compact } from '@tldraw/utils' import React, { useMemo } from 'react' -import { track } from 'signia-react' import { KeyboardShortcutsDialog } from '../components/KeyboardShortcutsDialog' import { TLUiMenuSchema, menuCustom, menuGroup, menuItem } from './menuHelpers' import { useActions } from './useActions' diff --git a/packages/ui/src/lib/hooks/useKeyboardShortcutsSchema.tsx b/packages/ui/src/lib/hooks/useKeyboardShortcutsSchema.tsx index cda0f0c37..5da9e049c 100644 --- a/packages/ui/src/lib/hooks/useKeyboardShortcutsSchema.tsx +++ b/packages/ui/src/lib/hooks/useKeyboardShortcutsSchema.tsx @@ -1,7 +1,7 @@ import { Editor, useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import { compact } from '@tldraw/utils' import React, { useMemo } from 'react' -import { track } from 'signia-react' import { TLUiMenuSchema, menuGroup, menuItem } from './menuHelpers' import { TLUiActionsContextType, useActions } from './useActions' import { TLUiToolsContextType, useTools } from './useTools' diff --git a/packages/ui/src/lib/hooks/useMenuIsOpen.ts b/packages/ui/src/lib/hooks/useMenuIsOpen.ts index 1ca3710cb..126732247 100644 --- a/packages/ui/src/lib/hooks/useMenuIsOpen.ts +++ b/packages/ui/src/lib/hooks/useMenuIsOpen.ts @@ -1,6 +1,6 @@ import { useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { useCallback, useEffect, useRef } from 'react' -import { useValue } from 'signia-react' import { useEvents } from './useEventsProvider' /** @public */ diff --git a/packages/ui/src/lib/hooks/useMenuSchema.tsx b/packages/ui/src/lib/hooks/useMenuSchema.tsx index 487026b5b..a5cb63ff2 100644 --- a/packages/ui/src/lib/hooks/useMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useMenuSchema.tsx @@ -1,7 +1,7 @@ import { Editor, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { compact } from '@tldraw/utils' import React, { useMemo } from 'react' -import { useValue } from 'signia-react' import { TLUiMenuSchema, menuCustom, diff --git a/packages/ui/src/lib/hooks/useOnlyFlippableShape.ts b/packages/ui/src/lib/hooks/useOnlyFlippableShape.ts index 0e990798c..8a841d0b6 100644 --- a/packages/ui/src/lib/hooks/useOnlyFlippableShape.ts +++ b/packages/ui/src/lib/hooks/useOnlyFlippableShape.ts @@ -5,7 +5,7 @@ import { LineShapeUtil, useEditor, } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' export function useOnlyFlippableShape() { const editor = useEditor() diff --git a/packages/ui/src/lib/hooks/useReadonly.ts b/packages/ui/src/lib/hooks/useReadonly.ts index e64d231e2..8714a4336 100644 --- a/packages/ui/src/lib/hooks/useReadonly.ts +++ b/packages/ui/src/lib/hooks/useReadonly.ts @@ -1,5 +1,5 @@ import { useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' /** @public */ export function useReadonly() { diff --git a/packages/ui/src/lib/hooks/useShowAutoSizeToggle.ts b/packages/ui/src/lib/hooks/useShowAutoSizeToggle.ts index 40430c180..4351c8ad1 100644 --- a/packages/ui/src/lib/hooks/useShowAutoSizeToggle.ts +++ b/packages/ui/src/lib/hooks/useShowAutoSizeToggle.ts @@ -1,5 +1,5 @@ import { TextShapeUtil, useEditor } from '@tldraw/editor' -import { useValue } from 'signia-react' +import { useValue } from '@tldraw/state' export function useShowAutoSizeToggle() { const editor = useEditor() diff --git a/packages/ui/src/lib/hooks/useToolbarSchema.tsx b/packages/ui/src/lib/hooks/useToolbarSchema.tsx index b6b0662e3..0a1787c2e 100644 --- a/packages/ui/src/lib/hooks/useToolbarSchema.tsx +++ b/packages/ui/src/lib/hooks/useToolbarSchema.tsx @@ -1,7 +1,7 @@ import { Editor, featureFlags, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import { compact } from '@tldraw/utils' import React from 'react' -import { useValue } from 'signia-react' import { TLUiToolItem, TLUiToolsContextType, useTools } from './useTools' /** @public */ diff --git a/packages/ui/src/lib/hooks/useTools.tsx b/packages/ui/src/lib/hooks/useTools.tsx index 222922cae..6108316f2 100644 --- a/packages/ui/src/lib/hooks/useTools.tsx +++ b/packages/ui/src/lib/hooks/useTools.tsx @@ -1,6 +1,6 @@ import { Editor, GeoShapeGeoStyle, featureFlags, useEditor } from '@tldraw/editor' +import { useValue } from '@tldraw/state' import * as React from 'react' -import { useValue } from 'signia-react' import { EmbedDialog } from '../components/EmbedDialog' import { TLUiIconType } from '../icon-types' import { useDialogs } from './useDialogsProvider' diff --git a/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx b/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx index 15e87c7b8..d1768f5fb 100644 --- a/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx +++ b/packages/ui/src/lib/hooks/useTranslation/useTranslation.tsx @@ -1,6 +1,6 @@ import { useEditor } from '@tldraw/editor' +import { track } from '@tldraw/state' import * as React from 'react' -import { track } from 'signia-react' import { useAssetUrls } from '../useAssetUrls' import { TLUiTranslationKey } from './TLUiTranslationKey' import { DEFAULT_TRANSLATION } from './defaultTranslation' diff --git a/public-yarn.lock b/public-yarn.lock index cfd9b3a35..79e3fa0ab 100644 --- a/public-yarn.lock +++ b/public-yarn.lock @@ -4461,6 +4461,7 @@ __metadata: "@testing-library/react": ^14.0.0 "@tldraw/indices": "workspace:*" "@tldraw/primitives": "workspace:*" + "@tldraw/state": "workspace:*" "@tldraw/store": "workspace:*" "@tldraw/tlschema": "workspace:*" "@tldraw/utils": "workspace:*" @@ -4493,8 +4494,6 @@ __metadata: peerDependencies: react: ^18 react-dom: ^18 - signia: "*" - signia-react: "*" languageName: unknown linkType: soft @@ -4601,19 +4600,32 @@ __metadata: languageName: unknown linkType: soft +"@tldraw/state@workspace:*, @tldraw/state@workspace:packages/state": + version: 0.0.0-use.local + resolution: "@tldraw/state@workspace:packages/state" + dependencies: + "@types/lodash": ^4.14.188 + "@types/react": ^18.0.24 + "@types/react-test-renderer": ^18.0.0 + lodash: ^4.17.21 + react-test-renderer: ^18.2.0 + peerDependencies: + react: ^18 + languageName: unknown + linkType: soft + "@tldraw/store@workspace:*, @tldraw/store@workspace:packages/store": version: 0.0.0-use.local resolution: "@tldraw/store@workspace:packages/store" dependencies: "@peculiar/webcrypto": ^1.4.0 + "@tldraw/state": "workspace:*" "@tldraw/utils": "workspace:*" "@types/lodash.isequal": ^4.5.6 lazyrepo: 0.0.0-alpha.27 lodash.isequal: ^4.5.0 nanoid: 4.0.2 raf: ^3.4.1 - peerDependencies: - signia: "*" languageName: unknown linkType: soft @@ -4644,14 +4656,13 @@ __metadata: version: 0.0.0-use.local resolution: "@tldraw/tlschema@workspace:packages/tlschema" dependencies: + "@tldraw/state": "workspace:*" "@tldraw/store": "workspace:*" "@tldraw/utils": "workspace:*" "@tldraw/validate": "workspace:*" kleur: ^4.1.5 lazyrepo: 0.0.0-alpha.27 nanoid: 4.0.2 - peerDependencies: - signia: "*" languageName: unknown linkType: soft @@ -4672,6 +4683,7 @@ __metadata: "@testing-library/react": ^12.0.0 "@tldraw/editor": "workspace:*" "@tldraw/primitives": "workspace:*" + "@tldraw/state": "workspace:*" "@tldraw/tlschema": "workspace:*" "@tldraw/utils": "workspace:*" "@types/lz-string": ^1.3.34 @@ -4687,8 +4699,6 @@ __metadata: peerDependencies: react: ^18 react-dom: ^18 - signia: "*" - signia-react: "*" languageName: unknown linkType: soft @@ -5163,6 +5173,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.14.188": + version: 4.14.195 + resolution: "@types/lodash@npm:4.14.195" + checksum: 39b75ca635b3fa943d17d3d3aabc750babe4c8212485a4df166fe0516e39288e14b0c60afc6e21913cc0e5a84734633c71e617e2bd14eaa1cf51b8d7799c432e + languageName: node + linkType: hard + "@types/lz-string@npm:^1.3.34": version: 1.3.34 resolution: "@types/lz-string@npm:1.3.34" @@ -9583,6 +9600,7 @@ __metadata: "@babel/plugin-proposal-decorators": ^7.21.0 "@playwright/test": ^1.34.3 "@tldraw/assets": "workspace:*" + "@tldraw/state": "workspace:*" "@tldraw/tldraw": "workspace:*" "@tldraw/utils": "workspace:*" "@tldraw/validate": "workspace:*" @@ -9593,8 +9611,6 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 react-router-dom: ^6.9.0 - signia: 0.1.4 - signia-react: 0.1.4 vite: ^4.3.4 y-websocket: ^1.5.0 yjs: ^13.6.2 @@ -16906,24 +16922,6 @@ __metadata: languageName: node linkType: hard -"signia-react@npm:0.1.4": - version: 0.1.4 - resolution: "signia-react@npm:0.1.4" - dependencies: - signia: 0.1.4 - peerDependencies: - react: ^18 - checksum: 7e443c9b94e60cd1eeb3726d048d66ab4ff6b26c14b4ca0fc9542ce2cd5997071b1d7a870481e5d88e7d0442332d65a14239a831b1f987463f4a9ca196d7de45 - languageName: node - linkType: hard - -"signia@npm:0.1.4": - version: 0.1.4 - resolution: "signia@npm:0.1.4" - checksum: 0daab872c3e335c74a464a3b592cfa0fc5e1e0b65e61eecdf4e0476830791543add8a661b9b33ce9e0f6c3f0f8093ef1965955a64ec510cfcaaf15066bd25e94 - languageName: node - linkType: hard - "simple-concat@npm:^1.0.0": version: 1.0.1 resolution: "simple-concat@npm:1.0.1" diff --git a/scripts/api-check.ts b/scripts/api-check.ts index 9075ace58..75f4dfc59 100755 --- a/scripts/api-check.ts +++ b/scripts/api-check.ts @@ -11,7 +11,6 @@ const packagesOurTypesCanDependOn = [ 'eventemitter3', // todo: external types shouldn't depend on this '@types/ws', - 'signia', ] main()