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 <d.j.sheldrick@gmail.com>
This commit is contained in:
Steve Ruiz 2023-06-20 14:31:26 +01:00 committed by GitHub
parent a4b5bcb8f7
commit 5cb08711c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
143 changed files with 5419 additions and 168 deletions

View file

@ -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()

View file

@ -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
```

View file

@ -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"

View file

@ -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() {

View file

@ -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() {

View file

@ -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 =

View file

@ -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'

View file

@ -6,10 +6,12 @@
/// <reference types="react" />
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<TLArrowShape> {
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<TLVideoShape>;
@ -2789,6 +2808,8 @@ export class WeakMapCache<T extends object, K> {
set(item: T, value: K): void;
}
export { whyAmIRunning }
// @internal (undocumented)
export const ZOOMS: number[];

View file

@ -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",

View file

@ -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,

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -1,4 +1,4 @@
import { react } from 'signia'
import { react } from '@tldraw/state'
import { TestEditor } from '../test/TestEditor'
import {
TLSessionStateSnapshot,

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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 */

View file

@ -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'

View file

@ -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'

View file

@ -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<TLParentId, [id: TLShapeId, index: string][]>

View file

@ -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.

View file

@ -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'

View file

@ -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'

View file

@ -1,4 +1,4 @@
import { EMPTY_ARRAY } from 'signia'
import { EMPTY_ARRAY } from '@tldraw/state'
export type Stack<T> = StackItem<T> | EmptyStackItem<T>

View file

@ -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,

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -1,4 +1,4 @@
import { useValue } from 'signia-react'
import { useValue } from '@tldraw/state'
import { useEditor } from '../../../hooks/useEditor'
export function useForceSolid() {

View file

@ -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'

View file

@ -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 {

View file

@ -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 = `<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" fill="white"/><path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" fill="white"/><path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" fill="black"/><path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" fill="black"/>`
const POINTER_SVG = `<path d="m13.3315 21.3799c-.284-.359-.629-1.093-1.243-1.984-.348-.504-1.211-1.453-1.468-1.935-.223-.426-.199-.617-.146-.97.094-.628.738-1.117 1.425-1.051.519.049.959.392 1.355.716.239.195.533.574.71.788.163.196.203.277.377.509.23.307.302.459.214.121-.071-.496-.187-1.343-.355-2.092-.128-.568-.159-.657-.281-1.093-.129-.464-.195-.789-.316-1.281-.084-.348-.235-1.059-.276-1.459-.057-.547-.087-1.439.264-1.849.275-.321.906-.418 1.297-.22.512.259.803 1.003.936 1.3.239.534.387 1.151.516 1.961.164 1.031.466 2.462.476 2.763.024-.369-.068-1.146-.004-1.5.058-.321.328-.694.666-.795.286-.085.621-.116.916-.055.313.064.643.288.766.499.362.624.369 1.899.384 1.831.086-.376.071-1.229.284-1.584.14-.234.497-.445.687-.479.294-.052.655-.068.964-.008.249.049.586.345.677.487.218.344.342 1.317.379 1.658.015.141.074-.392.293-.736.406-.639 1.843-.763 1.898.639.025.654.02.624.02 1.064 0 .517-.012.828-.04 1.202-.031.4-.117 1.304-.242 1.742-.086.301-.371.978-.652 1.384 0 0-1.074 1.25-1.191 1.813-.118.562-.079.566-.102.965-.023.398.121.922.121.922s-.802.104-1.234.035c-.391-.063-.875-.841-1-1.079-.172-.328-.539-.265-.682-.023-.225.383-.709 1.07-1.051 1.113-.668.084-2.054.031-3.139.02 0 0 .185-1.011-.227-1.358-.305-.259-.83-.784-1.144-1.06z" fill="white"/><g stroke="black" stroke-linecap="round" stroke-width=".75"><path d="m13.3315 21.3799c-.284-.359-.629-1.093-1.243-1.984-.348-.504-1.211-1.453-1.468-1.935-.223-.426-.199-.617-.146-.97.094-.628.738-1.117 1.425-1.051.519.049.959.392 1.355.716.239.195.533.574.71.788.163.196.203.277.377.509.23.307.302.459.214.121-.071-.496-.187-1.343-.355-2.092-.128-.568-.159-.657-.281-1.093-.129-.464-.195-.789-.316-1.281-.084-.348-.235-1.059-.276-1.459-.057-.547-.087-1.439.264-1.849.275-.321.906-.418 1.297-.22.512.259.803 1.003.936 1.3.239.534.387 1.151.516 1.961.164 1.031.466 2.462.476 2.763.024-.369-.068-1.146-.004-1.5.058-.321.328-.694.666-.795.286-.085.621-.116.916-.055.313.064.643.288.766.499.362.624.369 1.899.384 1.831.086-.376.071-1.229.284-1.584.14-.234.497-.445.687-.479.294-.052.655-.068.964-.008.249.049.586.345.677.487.218.344.342 1.317.379 1.658.015.141.074-.392.293-.736.406-.639 1.843-.763 1.898.639.025.654.02.624.02 1.064 0 .517-.012.828-.04 1.202-.031.4-.117 1.304-.242 1.742-.086.301-.371.978-.652 1.384 0 0-1.074 1.25-1.191 1.813-.118.562-.079.566-.102.965-.023.398.121.922.121.922s-.802.104-1.234.035c-.391-.063-.875-.841-1-1.079-.172-.328-.539-.265-.682-.023-.225.383-.709 1.07-1.051 1.113-.668.084-2.054.031-3.139.02 0 0 .185-1.011-.227-1.358-.305-.259-.83-.784-1.144-1.06z" stroke-linejoin="round"/><path d="m21.5664 21.7344v-3.459"/><path d="m19.5508 21.7461-.016-3.473"/><path d="m17.5547 18.3047.021 3.426"/></g>`

View file

@ -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'

View file

@ -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'

View file

@ -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) {

View file

@ -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) {

View file

@ -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?

View file

@ -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?

View file

@ -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'

View file

@ -1,4 +1,4 @@
import { Atom, atom, react } from 'signia'
import { Atom, atom, react } from '@tldraw/state'
// --- 1. DEFINE ---
//

View file

@ -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,

View file

@ -16,6 +16,7 @@
{ "path": "../store" },
{ "path": "../validate" },
{ "path": "../utils" },
{ "path": "../indices" }
{ "path": "../indices" },
{ "path": "../state" }
]
}

167
packages/state/CHANGELOG.md Normal file
View file

@ -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

3
packages/state/README.md Normal file
View file

@ -0,0 +1,3 @@
# @tldraw/state
...

View file

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../config/api-extractor.json"
}

View file

@ -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<Value, Diff = unknown> extends Signal<Value, Diff> {
set(value: Value, diff?: Diff): Value;
update(updater: (value: Value) => Value): Value;
}
// @public
export function atom<Value, Diff = unknown>(
name: string,
initialValue: Value,
options?: AtomOptions<Value, Diff>): Atom<Value, Diff>;
// @public
export interface AtomOptions<Value, Diff> {
computeDiff?: ComputeDiff<Value, Diff>;
historyLength?: number;
isEqual?: (a: any, b: any) => boolean;
}
// @public
export interface Computed<Value, Diff = unknown> extends Signal<Value, Diff> {
readonly isActivelyListening: boolean;
// @internal (undocumented)
readonly parentEpochs: number[];
// @internal (undocumented)
readonly parents: Signal<any, any>[];
}
// @public
export function computed<Value, Diff = unknown>(name: string, compute: (previousValue: typeof UNINITIALIZED | Value, lastComputedEpoch: number) => Value | WithDiff<Value, Diff>, options?: ComputedOptions<Value, Diff>): Computed<Value, Diff>;
// @public (undocumented)
export function computed(target: any, key: string, descriptor: PropertyDescriptor): PropertyDescriptor;
// @public (undocumented)
export function computed<Value, Diff = unknown>(options?: ComputedOptions<Value, Diff>): (target: any, key: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
// @public
export interface ComputedOptions<Value, Diff> {
computeDiff?: ComputeDiff<Value, Diff>;
historyLength?: number;
isEqual?: (a: any, b: any) => boolean;
}
// @public
export class EffectScheduler<Result> {
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<any, any>[];
get scheduleCount(): number;
// @internal (undocumented)
scheduleEffect(): void;
}
// @public (undocumented)
export const EMPTY_ARRAY: [];
// @public
export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(obj: Obj, propertyName: Prop): Computed<Obj[Prop]>;
// @public
export function isAtom(value: unknown): value is Atom<unknown>;
// @public
export function isSignal(value: any): value is Signal<any>;
// @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<T = unknown> {
scheduler: EffectScheduler<T>;
start(options?: {
force?: boolean;
}): void;
stop(): void;
}
// @public
export function reactor<Result>(name: string, fn: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions): Reactor<Result>;
// @public (undocumented)
export const RESET_VALUE: unique symbol;
// @public (undocumented)
export type RESET_VALUE = typeof RESET_VALUE;
// @public
export interface Signal<Value, Diff = unknown> {
__unsafe__getWithoutCapture(): Value;
// @internal (undocumented)
children: ArraySet<Child>;
getDiffSince(epoch: number): Diff[] | RESET_VALUE;
lastChangedEpoch: number;
name: string;
readonly value: Value;
}
// @public
export function track<T extends FunctionComponent<any>>(baseComponent: T): T extends React_2.MemoExoticComponent<any> ? T : React_2.MemoExoticComponent<T>;
// @public
export function transact<T>(fn: () => T): T;
// @public
export function transaction<T>(fn: (rollback: () => void) => T): T;
// @public
export function unsafe__withoutCapture<T>(fn: () => T): T;
// @public
export function useAtom<Value, Diff = unknown>(
name: string,
valueOrInitialiser: (() => Value) | Value,
options?: AtomOptions<Value, Diff>): Atom<Value, Diff>;
// @public
export function useComputed<Value>(name: string, compute: () => Value, deps: any[]): Computed<Value>;
// @public (undocumented)
export function useComputed<Value, Diff = unknown>(name: string, compute: () => Value, opts: ComputedOptions<Value, Diff>, deps: any[]): Computed<Value>;
// @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<T>(name: string, render: () => T): T;
// @public
export function useValue<Value>(value: Signal<Value>): Value;
// @public (undocumented)
export function useValue<Value>(name: string, fn: () => Value, deps: unknown[]): Value;
// @public
export function whyAmIRunning(): void;
// @public
export function withDiff<Value, Diff>(value: Value, diff: Diff): WithDiff<Value, Diff>;
// (No @packageDocumentation comment for this package)
```

View file

@ -0,0 +1,5 @@
[
["functions", ["atom-1", "computed-1", "react", "reactor-1", "transact", "transaction"]],
["classes"],
["interfaces", ["Signal"]]
]

View file

@ -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": {
"^~(.*)": "<rootDir>/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"
}
}

View file

@ -0,0 +1,3 @@
/* eslint-disable local/no-export-star */
export * from './lib/core'
export * from './lib/react'

View file

@ -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<T> {
private arraySize = 0
private array: (T | undefined)[] | null = Array(ARRAY_SIZE_THRESHOLD)
private set: Set<T> | 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')
}
}

View file

@ -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<Value, Diff> {
/**
* 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<Value, Diff>
/**
* 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<Value, Diff = unknown> extends Signal<Value, Diff> {
/**
* 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<Value, Diff = unknown> implements Atom<Value, Diff> {
constructor(
public readonly name: string,
private current: Value,
options?: AtomOptions<Value, Diff>
) {
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<Value, Diff>
lastChangedEpoch = globalEpoch
children = new ArraySet<Child>()
historyBuffer?: HistoryBuffer<Diff>
__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<Value, Diff = unknown>(
/**
* 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<Value, Diff>
): Atom<Value, Diff> {
return new _Atom(name, initialValue, options)
}
/**
* Returns true if the given value is an [[Atom]].
* @public
*/
export function isAtom(value: unknown): value is Atom<unknown> {
return value instanceof _Atom
}

View file

@ -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<Value, Diff> {
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, Diff>(value: Value, diff: Diff): WithDiff<Value, Diff> {
return new WithDiff(value, diff)
}
/**
* Options for creating computed signals. Used when calling [[computed]].
* @public
*/
export interface ComputedOptions<Value, Diff> {
/**
* 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<Value, Diff>
/**
* 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<Value, Diff = unknown> extends Signal<Value, Diff> {
/**
* Whether this computed child is involved in an actively-running effect graph.
* @public
*/
readonly isActivelyListening: boolean
/** @internal */
readonly parents: Signal<any, any>[]
/** @internal */
readonly parentEpochs: number[]
}
/**
* @internal
*/
export class _Computed<Value, Diff = unknown> implements Computed<Value, Diff> {
lastChangedEpoch = GLOBAL_START_EPOCH
lastTraversedEpoch = GLOBAL_START_EPOCH
/**
* The epoch when the reactor was last checked.
*/
private lastCheckedEpoch = GLOBAL_START_EPOCH
parents: Signal<any, any>[] = []
parentEpochs: number[] = []
children = new ArraySet<Child>()
get isActivelyListening(): boolean {
return !this.children.isEmpty
}
historyBuffer?: HistoryBuffer<Diff>
// The last-computed value of this signal.
private state: Value = UNINITIALIZED as unknown as Value
private computeDiff?: ComputeDiff<Value, Diff>
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<Value, Diff>,
options?: ComputedOptions<Value, Diff>
) {
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<any, any> = {},
_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<any> | 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 extends object, Prop extends keyof Obj>(
obj: Obj,
propertyName: Prop
): Computed<Obj[Prop]> {
// 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<Obj[Prop]> | undefined
if (!inst) {
// deref to make sure it exists first
obj[propertyName]
inst = obj[key as keyof typeof obj] as _Computed<Obj[Prop]> | 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<number>(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<number>(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<Value, Diff = unknown>(
name: string,
compute: (
previousValue: Value | typeof UNINITIALIZED,
lastComputedEpoch: number
) => Value | WithDiff<Value, Diff>,
options?: ComputedOptions<Value, Diff>
): Computed<Value, Diff>
/** @public */
export function computed(
target: any,
key: string,
descriptor: PropertyDescriptor
): PropertyDescriptor
/** @public */
export function computed<Value, Diff = unknown>(
options?: ComputedOptions<Value, Diff>
): (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<any>}
* @public
*/
export function isComputed(value: any): value is Computed<any> {
return value && value instanceof _Computed
}

View file

@ -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<Result> {
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<any, any>[] = []
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<T = unknown> {
/**
* The underlying effect scheduler.
* @public
*/
scheduler: EffectScheduler<T>
/**
* 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<Result>(
name: string,
fn: (lastReactedEpoch: number) => Result,
options?: EffectSchedulerOptions
): Reactor<Result> {
const scheduler = new EffectScheduler<Result>(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()
},
}
}

View file

@ -0,0 +1,95 @@
import { RESET_VALUE } from './types'
type RangeTuple<Diff> = [fromEpoch: number, toEpoch: number, diff: Diff]
/**
* A structure that stores diffs between values of an atom.
*
* @internal
*/
export class HistoryBuffer<Diff> {
private index = 0
// use a wrap around buffer to store the last N values
buffer: Array<RangeTuple<Diff> | 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
}
}

View file

@ -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)
})
})

View file

@ -0,0 +1,56 @@
import { HistoryBuffer } from '../HistoryBuffer'
import { RESET_VALUE } from '../types'
describe('HistoryBuffer', () => {
it('should wrap around', () => {
const buf = new HistoryBuffer<string>(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<string>(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)
})
})

View file

@ -0,0 +1,127 @@
import { ARRAY_SIZE_THRESHOLD, ArraySet } from '../ArraySet'
const get = <T>(set: ArraySet<T>) => {
const s = new Set<T>()
set.visit((i) => s.add(i))
return s
}
describe(ArraySet, () => {
it('works with small numbers of things', () => {
const as = new ArraySet<number>()
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<number>()
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<number>()
const s = new Set<number>()
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)
})
})

View file

@ -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)
})

View file

@ -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<Child> = {}) =>
({
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)
})
})

View file

@ -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<any>): 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<In, Out>(
obj: Signal<Record<string, In>, Difference[]>,
mapper: (t: In, k: string) => Out
): Computed<Record<string, Out>> {
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<string, Out>
const changedKeys = new Set<string>()
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<string, number>
const nodes = atom<NumberMap, Difference[]>(
'',
{
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)
})
})

View file

@ -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<T>(arr: readonly T[]): T {
return arr[this.nextInt(arr.length)]
}
choice(probability: number): boolean {
return this.nextFloat() < probability
}
executeOne<Result>(
_choices: Record<string, (() => 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<string, Atom<Letter>>
atomsInAtoms: Record<string, Atom<Atom<Letter>>>
derivations: Record<string, { derivation: Computed<Letter>; sneakyGet: () => Letter }>
derivationsInDerivations: Record<string, Computed<Computed<Letter>>>
atomsInDerivations: Record<string, Computed<Atom<Letter>>>
reactors: Record<string, { reactor: Reactor; result: string | null; dependencies: Signal<any>[] }>
}
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<string, string>; actual: Record<string, string | null> } = {
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<any>[] = []
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<Op>({
'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)
})
}

View file

@ -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)
})

View file

@ -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)
})
})

View file

@ -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<any>[]
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<T>(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<any, any>) {
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()
)
}
}
}

View file

@ -0,0 +1,2 @@
// Derivations start on GLOBAL_START_EPOCH so they are dirty before having been computed
export const GLOBAL_START_EPOCH = -1

View file

@ -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<any>, 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<any>, 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

View file

@ -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'

View file

@ -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<any> {
return value instanceof _Atom || value instanceof _Computed
}

View file

@ -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>, 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<any>>) {
if (globalIsReacting) {
throw new Error('cannot change atoms during reaction cycle')
}
try {
globalIsReacting = true
// Collect all of the visited reactors.
const reactors = new Set<EffectScheduler<unknown>>()
// 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<any>).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<any>, 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<T>(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<T>(fn: () => T): T {
if (currentTransaction) {
return fn()
}
return transaction(fn)
}

View file

@ -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<Value, Diff = unknown> {
/**
* 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<Child>
}
/** @internal */
export type Child = EffectScheduler<any> | _Computed<any>
/**
* 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<Value, Diff> = (
previousValue: Value,
currentValue: Value,
lastComputedEpoch: number,
currentEpoch: number
) => Diff | RESET_VALUE

View file

@ -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'

View file

@ -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(<Component a="a" b="b" c="c" />)
})
expect(view!.toJSON()).toMatchInlineSnapshot(`
Array [
"a",
"b",
"c",
]
`)
expect(numRenders).toBe(1)
await act(() => {
view!.update(<Component a="a" b="b" c="c" />)
})
expect(numRenders).toBe(1)
await act(() => {
view!.update(<Component a="a" b="b" c="d" />)
})
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(<Component a="a" b="b" c="c" />)
})
expect(view!.toJSON()).toMatchInlineSnapshot(`
Array [
"a",
"b",
"c",
]
`)
expect(numRenders).toBe(1)
await act(() => {
view!.update(<Component a="a" b="b" c="c" />)
})
expect(numRenders).toBe(1)
await act(() => {
view!.update(<Component a="a" b="b" c="d" />)
})
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(<Component prop="hello" ref={ref} />)
})
expect(view!.toJSON()).toMatchInlineSnapshot('"output"')
expect(ref.current?.handle).toBe('hello')
await act(() => {
view.update(<Component prop="world" ref={ref} />)
})
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(<C />)
})
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(<Component />)
})
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<Record<string, number>>('map', { a: 1, b: 2, c: 3 })
const Parent = track(function Parent() {
const ids = Object.keys(theAtom.value)
return (
<>
{ids.map((id) => (
<Child key={id} id={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(<Parent />)
})
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",
]
`)
})

View file

@ -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 <button onClick={increment}>{count.value}</button>
* })
* ```
*
* @param baseComponent - The base component to track.
* @public
*/
export function track<T extends FunctionComponent<any>>(
baseComponent: T
): T extends React.MemoExoticComponent<any> ? T : React.MemoExoticComponent<T> {
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
}

View file

@ -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<any> = 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(<Component />)
})
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<any> = 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(<Component />)
})
expect(theAtom).not.toBeNull()
expect(theAtom?.value).toBe('a')
expect(theAtom?.name).toBe('useAtom(myAtom)')
expect(view!.toJSON()).toMatchInlineSnapshot(`"a"`)
})

View file

@ -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 <button onClick={increment}>{count.value}</button>
* })
* ```
*
* @public
*/
export function useAtom<Value, Diff = unknown>(
/**
* 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<Value, Diff>
): Atom<Value, Diff> {
return useState(() => {
const initialValue =
typeof valueOrInitialiser === 'function' ? (valueOrInitialiser as any)() : valueOrInitialiser
return atom(`useAtom(${name})`, initialValue, options)
})[0]
}

View file

@ -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<number>
let theAtom = null as null | Atom<number>
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(<Component />)
})
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<number>
let theAtom = null as null | Atom<number>
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(<Component />)
})
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<number>
let theAtom = null as null | Atom<number>
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(<Component />)
})
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()
})

View file

@ -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<string>
* lastName: Signal<string>
* }
*
* const Greeter = track(function Greeter ({firstName, lastName}: GreeterProps) {
* const fullName = useComputed('fullName', () => `${firstName.value} ${lastName.value}`)
* return <div>Hello {fullName.value}!</div>
* })
* ```
*
* @public
*/
export function useComputed<Value>(name: string, compute: () => Value, deps: any[]): Computed<Value>
/** @public */
export function useComputed<Value, Diff = unknown>(
name: string,
compute: () => Value,
opts: ComputedOptions<Value, Diff>,
deps: any[]
): Computed<Value>
/** @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)
}

View file

@ -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) {

View file

@ -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[] = []) {

View file

@ -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(<Component />)
})
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(<Component />)
})
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<null | number>('age', null)
let resolve = (_val: string) => {
// noop
}
const Component = () => {
const val = useStateTracking('', () => {
if (a.value === null) {
throw new Promise<string>((r) => {
resolve = r
})
}
return a.value
})
return <>You are {val} years old</>
}
let view: ReactTestRenderer = null as any
await act(() => {
view = create(
<React.Suspense fallback={<>fallback</>}>
<Component />
</React.Suspense>
)
})
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)
})
})

View file

@ -0,0 +1,58 @@
import React from 'react'
import { EffectScheduler } from '../core'
/** @internal */
export function useStateTracking<T>(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()
}

View file

@ -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<number>
let theAtom = null as null | Atom<number>
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(<Component />)
})
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<number>
function Component() {
const a = useAtom('a', 1)
theAtom = a
return <>{useValue(a)}</>
}
let view: ReactTestRenderer.ReactTestRenderer
await ReactTestRenderer.act(() => {
view = ReactTestRenderer.create(<Component />)
})
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<number>
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(<Component />)
})
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<Record<string, number>>('map', { a: 1, b: 2, c: 3 })
function Parent() {
const ids = useValue('ids', () => Object.keys(theAtom.value), [])
return (
<>
{ids.map((id) => (
<Child key={id} id={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(<Parent />)
})
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",
]
`)
})

View file

@ -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 <button onClick={increment}>{currentCount}</button>
* }
* ```
*
* You can also pass a function to compute the value and it will be memoized as in [[useComputed]]:
*
* @example
* ```ts
* type GreeterProps = {
* firstName: Signal<string>
* lastName: Signal<string>
* }
*
* const Greeter = track(function Greeter({ firstName, lastName }: GreeterProps) {
* const fullName = useValue('fullName', () => `${firstName.value} ${lastName.value}`, [
* firstName,
* lastName,
* ])
* return <div>Hello {fullName}!</div>
* })
* ```
*
* @public
*/
export function useValue<Value>(value: Signal<Value>): Value
/** @public */
export function useValue<Value>(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
}
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": {
"outDir": "./.tsbuild",
"rootDir": "src"
},
"references": [{ "path": "../utils" }]
}

View file

@ -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<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;

View file

@ -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",

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -6,5 +6,5 @@
"outDir": "./.tsbuild",
"rootDir": "src"
},
"references": [{ "path": "../utils" }]
"references": [{ "path": "../utils" }, { "path": "../state" }]
}

View file

@ -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';

View file

@ -56,12 +56,10 @@
]
},
"dependencies": {
"@tldraw/state": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"nanoid": "4.0.2"
},
"peerDependencies": {
"signia": "*"
}
}

Some files were not shown because too many files have changed in this diff Show more