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:
parent
a4b5bcb8f7
commit
5cb08711c1
143 changed files with 5419 additions and 168 deletions
|
@ -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.
|
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
|
```tsx
|
||||||
import { track } from "@tldraw/signia"
|
import { track, useEditor } from "@tldraw/tldraw"
|
||||||
import { useEditor } from "@tldraw/tldraw"
|
|
||||||
|
|
||||||
export const SelectedIdsCount = track(() => {
|
export const SelectedIdsCount = track(() => {
|
||||||
const editor = useEditor()
|
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.
|
> 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
|
```tsx
|
||||||
import { track } from "@tldraw/signia"
|
import { track, useEditor } from "@tldraw/tldraw"
|
||||||
import { useEditor } from "@tldraw/tldraw"
|
|
||||||
|
|
||||||
export const CreatingBubbleToolUi = track(() => {
|
export const CreatingBubbleToolUi = track(() => {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
|
@ -10,12 +10,12 @@ At the moment the `@tldraw/tldraw` package is in alpha. We also ship a canary ve
|
||||||
|
|
||||||
## Alpha
|
## 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
|
```bash
|
||||||
yarn add @tldraw/tldraw@alpha signia signia-react
|
yarn add @tldraw/tldraw@alpha
|
||||||
# or
|
# or
|
||||||
npm install @tldraw/tldraw@alpha signia signia-react
|
npm install @tldraw/tldraw@alpha
|
||||||
```
|
```
|
||||||
|
|
||||||
## Canary
|
## 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).
|
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
|
```bash
|
||||||
yarn add @tldraw/tldraw@canary signia signia-react
|
yarn add @tldraw/tldraw@canary
|
||||||
# or
|
# or
|
||||||
npm install @tldraw/tldraw@canary signia signia-react
|
npm install @tldraw/tldraw@canary
|
||||||
```
|
```
|
|
@ -37,6 +37,7 @@
|
||||||
"@babel/plugin-proposal-decorators": "^7.21.0",
|
"@babel/plugin-proposal-decorators": "^7.21.0",
|
||||||
"@playwright/test": "^1.34.3",
|
"@playwright/test": "^1.34.3",
|
||||||
"@tldraw/assets": "workspace:*",
|
"@tldraw/assets": "workspace:*",
|
||||||
|
"@tldraw/state": "workspace:*",
|
||||||
"@tldraw/tldraw": "workspace:*",
|
"@tldraw/tldraw": "workspace:*",
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
"@tldraw/validate": "workspace:*",
|
"@tldraw/validate": "workspace:*",
|
||||||
|
@ -45,8 +46,6 @@
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.9.0",
|
"react-router-dom": "^6.9.0",
|
||||||
"signia": "0.1.4",
|
|
||||||
"signia-react": "0.1.4",
|
|
||||||
"vite": "^4.3.4",
|
"vite": "^4.3.4",
|
||||||
"y-websocket": "^1.5.0",
|
"y-websocket": "^1.5.0",
|
||||||
"yjs": "^13.6.2"
|
"yjs": "^13.6.2"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import { useEditor } from '@tldraw/tldraw'
|
import { useEditor } from '@tldraw/tldraw'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { MyFilterStyle } from './CardShape'
|
import { MyFilterStyle } from './CardShape'
|
||||||
|
|
||||||
export const FilterStyleUi = track(function FilterStyleUi() {
|
export const FilterStyleUi = track(function FilterStyleUi() {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import { Canvas, TldrawEditor, defaultShapes, defaultTools, useEditor } from '@tldraw/tldraw'
|
import { Canvas, TldrawEditor, defaultShapes, defaultTools, useEditor } from '@tldraw/tldraw'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import './custom-ui.css'
|
import './custom-ui.css'
|
||||||
|
|
||||||
export default function CustomUiExample() {
|
export default function CustomUiExample() {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import { Tldraw, useEditor } from '@tldraw/tldraw'
|
import { Tldraw, useEditor } from '@tldraw/tldraw'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { useYjsStore } from './useYjsStore'
|
import { useYjsStore } from './useYjsStore'
|
||||||
|
|
||||||
const HOST_URL =
|
const HOST_URL =
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { computed, react, transact } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
DocumentRecordType,
|
DocumentRecordType,
|
||||||
InstancePresenceRecordType,
|
InstancePresenceRecordType,
|
||||||
|
@ -13,7 +14,6 @@ import {
|
||||||
getUserPreferences,
|
getUserPreferences,
|
||||||
} from '@tldraw/tldraw'
|
} from '@tldraw/tldraw'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { computed, react, transact } from 'signia'
|
|
||||||
import { WebsocketProvider } from 'y-websocket'
|
import { WebsocketProvider } from 'y-websocket'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
|
|
|
@ -6,10 +6,12 @@
|
||||||
|
|
||||||
/// <reference types="react" />
|
/// <reference types="react" />
|
||||||
|
|
||||||
import { Atom } from 'signia';
|
import { Atom } from '@tldraw/state';
|
||||||
|
import { atom } from '@tldraw/state';
|
||||||
import { Box2d } from '@tldraw/primitives';
|
import { Box2d } from '@tldraw/primitives';
|
||||||
import { Box2dModel } from '@tldraw/tlschema';
|
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 { ComputedCache } from '@tldraw/store';
|
||||||
import { CubicSpline2d } from '@tldraw/primitives';
|
import { CubicSpline2d } from '@tldraw/primitives';
|
||||||
import { defineMigrations } from '@tldraw/store';
|
import { defineMigrations } from '@tldraw/store';
|
||||||
|
@ -23,6 +25,7 @@ import { Matrix2d } from '@tldraw/primitives';
|
||||||
import { Matrix2dModel } from '@tldraw/primitives';
|
import { Matrix2dModel } from '@tldraw/primitives';
|
||||||
import { Migrations } from '@tldraw/store';
|
import { Migrations } from '@tldraw/store';
|
||||||
import { Polyline2d } from '@tldraw/primitives';
|
import { Polyline2d } from '@tldraw/primitives';
|
||||||
|
import { react } from '@tldraw/state';
|
||||||
import { default as React_2 } from 'react';
|
import { default as React_2 } from 'react';
|
||||||
import * as React_3 from 'react';
|
import * as React_3 from 'react';
|
||||||
import { RecursivePartial } from '@tldraw/utils';
|
import { RecursivePartial } from '@tldraw/utils';
|
||||||
|
@ -32,7 +35,7 @@ import { SelectionEdge } from '@tldraw/primitives';
|
||||||
import { SelectionHandle } from '@tldraw/primitives';
|
import { SelectionHandle } from '@tldraw/primitives';
|
||||||
import { SerializedSchema } from '@tldraw/store';
|
import { SerializedSchema } from '@tldraw/store';
|
||||||
import { ShapeProps } from '@tldraw/tlschema';
|
import { ShapeProps } from '@tldraw/tlschema';
|
||||||
import { Signal } from 'signia';
|
import { Signal } from '@tldraw/state';
|
||||||
import { StoreSchema } from '@tldraw/store';
|
import { StoreSchema } from '@tldraw/store';
|
||||||
import { StoreSnapshot } from '@tldraw/store';
|
import { StoreSnapshot } from '@tldraw/store';
|
||||||
import { StrokePoint } from '@tldraw/primitives';
|
import { StrokePoint } from '@tldraw/primitives';
|
||||||
|
@ -78,10 +81,16 @@ import { TLTextShape } from '@tldraw/tlschema';
|
||||||
import { TLUnknownShape } from '@tldraw/tlschema';
|
import { TLUnknownShape } from '@tldraw/tlschema';
|
||||||
import { TLVideoAsset } from '@tldraw/tlschema';
|
import { TLVideoAsset } from '@tldraw/tlschema';
|
||||||
import { TLVideoShape } from '@tldraw/tlschema';
|
import { TLVideoShape } from '@tldraw/tlschema';
|
||||||
|
import { track } from '@tldraw/state';
|
||||||
import { UnknownRecord } from '@tldraw/store';
|
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 { Vec2d } from '@tldraw/primitives';
|
||||||
import { Vec2dModel } from '@tldraw/tlschema';
|
import { Vec2dModel } from '@tldraw/tlschema';
|
||||||
import { VecLike } from '@tldraw/primitives';
|
import { VecLike } from '@tldraw/primitives';
|
||||||
|
import { whyAmIRunning } from '@tldraw/state';
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const ACCEPTED_ASSET_TYPE: string;
|
export const ACCEPTED_ASSET_TYPE: string;
|
||||||
|
@ -163,6 +172,8 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
||||||
static type: "arrow";
|
static type: "arrow";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { atom }
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export abstract class BaseBoxShapeTool extends StateNode {
|
export abstract class BaseBoxShapeTool extends StateNode {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
@ -225,6 +236,8 @@ export const Canvas: React_2.MemoExoticComponent<() => JSX.Element>;
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const checkFlag: (flag: (() => boolean) | boolean | undefined) => boolean | undefined;
|
export const checkFlag: (flag: (() => boolean) | boolean | undefined) => boolean | undefined;
|
||||||
|
|
||||||
|
export { computed }
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export function containBoxSize(originalSize: BoxWidthHeight, containBoxSize: BoxWidthHeight): BoxWidthHeight;
|
export function containBoxSize(originalSize: BoxWidthHeight, containBoxSize: BoxWidthHeight): BoxWidthHeight;
|
||||||
|
|
||||||
|
@ -1764,6 +1777,8 @@ export class PlopManager {
|
||||||
// @public
|
// @public
|
||||||
export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void;
|
export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void;
|
||||||
|
|
||||||
|
export { react }
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export class ReadonlySharedStyleMap {
|
export class ReadonlySharedStyleMap {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
@ -2705,6 +2720,8 @@ export type TLWheelEventInfo = TLBaseEventInfo & {
|
||||||
delta: Vec2dModel;
|
delta: Vec2dModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { track }
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const truncateStringWithEllipsis: (str: string, maxLength: number) => string;
|
export const truncateStringWithEllipsis: (str: string, maxLength: number) => string;
|
||||||
|
|
||||||
|
@ -2717,6 +2734,8 @@ export type UiEventType = 'click' | 'keyboard' | 'pinch' | 'pointer' | 'wheel' |
|
||||||
// @public
|
// @public
|
||||||
export function uniqueId(): string;
|
export function uniqueId(): string;
|
||||||
|
|
||||||
|
export { useComputed }
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function useContainer(): HTMLDivElement;
|
export function useContainer(): HTMLDivElement;
|
||||||
|
|
||||||
|
@ -2738,18 +2757,18 @@ export function usePrefersReducedMotion(): boolean;
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
export function usePresence(userId: string): null | TLInstancePresence;
|
export function usePresence(userId: string): null | TLInstancePresence;
|
||||||
|
|
||||||
// @public (undocumented)
|
export { useQuickReactor }
|
||||||
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;
|
|
||||||
|
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
export const USER_COLORS: readonly ["#FF802B", "#EC5E41", "#F2555A", "#F04F88", "#E34BA9", "#BD54C6", "#9D5BD2", "#7B66DC", "#02B1CC", "#11B3A3", "#39B178", "#55B467"];
|
export const USER_COLORS: readonly ["#FF802B", "#EC5E41", "#F2555A", "#F04F88", "#E34BA9", "#BD54C6", "#9D5BD2", "#7B66DC", "#02B1CC", "#11B3A3", "#39B178", "#55B467"];
|
||||||
|
|
||||||
// @public (undocumented)
|
export { useReactor }
|
||||||
export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void;
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function useTLStore(opts: TLStoreOptions): TLStore;
|
export function useTLStore(opts: TLStoreOptions): TLStore;
|
||||||
|
|
||||||
|
export { useValue }
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const VideoShape: TLShapeInfo<TLVideoShape>;
|
export const VideoShape: TLShapeInfo<TLVideoShape>;
|
||||||
|
|
||||||
|
@ -2789,6 +2808,8 @@ export class WeakMapCache<T extends object, K> {
|
||||||
set(item: T, value: K): void;
|
set(item: T, value: K): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { whyAmIRunning }
|
||||||
|
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
export const ZOOMS: number[];
|
export const ZOOMS: number[];
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tldraw/indices": "workspace:*",
|
"@tldraw/indices": "workspace:*",
|
||||||
"@tldraw/primitives": "workspace:*",
|
"@tldraw/primitives": "workspace:*",
|
||||||
|
"@tldraw/state": "workspace:*",
|
||||||
"@tldraw/store": "workspace:*",
|
"@tldraw/store": "workspace:*",
|
||||||
"@tldraw/tlschema": "workspace:*",
|
"@tldraw/tlschema": "workspace:*",
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
|
@ -66,9 +67,7 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18"
|
||||||
"signia": "*",
|
|
||||||
"signia-react": "*"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@peculiar/webcrypto": "^1.4.0",
|
"@peculiar/webcrypto": "^1.4.0",
|
||||||
|
|
|
@ -3,6 +3,17 @@
|
||||||
|
|
||||||
// eslint-disable-next-line local/no-export-star
|
// eslint-disable-next-line local/no-export-star
|
||||||
export * from '@tldraw/indices'
|
export * from '@tldraw/indices'
|
||||||
|
export {
|
||||||
|
atom,
|
||||||
|
computed,
|
||||||
|
react,
|
||||||
|
track,
|
||||||
|
useComputed,
|
||||||
|
useQuickReactor,
|
||||||
|
useReactor,
|
||||||
|
useValue,
|
||||||
|
whyAmIRunning,
|
||||||
|
} from '@tldraw/state'
|
||||||
export { defineMigrations } from '@tldraw/store'
|
export { defineMigrations } from '@tldraw/store'
|
||||||
// eslint-disable-next-line local/no-export-star
|
// eslint-disable-next-line local/no-export-star
|
||||||
export * from '@tldraw/tlschema'
|
export * from '@tldraw/tlschema'
|
||||||
|
@ -184,8 +195,6 @@ export type { TLEditorComponents } from './lib/hooks/useEditorComponents'
|
||||||
export { useLocalStore } from './lib/hooks/useLocalStore'
|
export { useLocalStore } from './lib/hooks/useLocalStore'
|
||||||
export { usePeerIds } from './lib/hooks/usePeerIds'
|
export { usePeerIds } from './lib/hooks/usePeerIds'
|
||||||
export { usePresence } from './lib/hooks/usePresence'
|
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 { useTLStore } from './lib/hooks/useTLStore'
|
||||||
export {
|
export {
|
||||||
ReadonlySharedStyleMap,
|
ReadonlySharedStyleMap,
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { Matrix2d, toDomPrecision } from '@tldraw/primitives'
|
import { Matrix2d, toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { react, track, useQuickReactor, useValue } from '@tldraw/state'
|
||||||
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
|
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
|
||||||
import { dedupe, modulate } from '@tldraw/utils'
|
import { dedupe, modulate } from '@tldraw/utils'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { react } from 'signia'
|
|
||||||
import { track, useValue } from 'signia-react'
|
|
||||||
import { useCanvasEvents } from '../hooks/useCanvasEvents'
|
import { useCanvasEvents } from '../hooks/useCanvasEvents'
|
||||||
import { useCoarsePointer } from '../hooks/useCoarsePointer'
|
import { useCoarsePointer } from '../hooks/useCoarsePointer'
|
||||||
import { useDocumentEvents } from '../hooks/useDocumentEvents'
|
import { useDocumentEvents } from '../hooks/useDocumentEvents'
|
||||||
|
@ -13,7 +12,6 @@ import { useFixSafariDoubleTapZoomPencilEvents } from '../hooks/useFixSafariDoub
|
||||||
import { useGestureEvents } from '../hooks/useGestureEvents'
|
import { useGestureEvents } from '../hooks/useGestureEvents'
|
||||||
import { useHandleEvents } from '../hooks/useHandleEvents'
|
import { useHandleEvents } from '../hooks/useHandleEvents'
|
||||||
import { usePattern } from '../hooks/usePattern'
|
import { usePattern } from '../hooks/usePattern'
|
||||||
import { useQuickReactor } from '../hooks/useQuickReactor'
|
|
||||||
import { useScreenBounds } from '../hooks/useScreenBounds'
|
import { useScreenBounds } from '../hooks/useScreenBounds'
|
||||||
import { debugFlags } from '../utils/debug-flags'
|
import { debugFlags } from '../utils/debug-flags'
|
||||||
import { LiveCollaborators } from './LiveCollaborators'
|
import { LiveCollaborators } from './LiveCollaborators'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { Editor } from '../editor/Editor'
|
import { Editor } from '../editor/Editor'
|
||||||
import { EditorContext } from '../hooks/useEditor'
|
import { EditorContext } from '../hooks/useEditor'
|
||||||
import { hardResetEditor } from '../utils/hard-reset'
|
import { hardResetEditor } from '../utils/hard-reset'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants'
|
import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants'
|
||||||
import { useEditor } from '../hooks/useEditor'
|
import { useEditor } from '../hooks/useEditor'
|
||||||
import { useEditorComponents } from '../hooks/useEditorComponents'
|
import { useEditorComponents } from '../hooks/useEditorComponents'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Matrix2d, toDomPrecision } from '@tldraw/primitives'
|
import { Matrix2d, toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { TLPointerEventInfo } from '../editor/types/event-types'
|
import { TLPointerEventInfo } from '../editor/types/event-types'
|
||||||
import { useEditor } from '../hooks/useEditor'
|
import { useEditor } from '../hooks/useEditor'
|
||||||
import { releasePointerCapture, setPointerCapture } from '../utils/dom'
|
import { releasePointerCapture, setPointerCapture } from '../utils/dom'
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { RotateCorner, toDomPrecision } from '@tldraw/primitives'
|
import { RotateCorner, toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { EmbedShapeUtil } from '../editor/shapes/embed/EmbedShapeUtil'
|
import { EmbedShapeUtil } from '../editor/shapes/embed/EmbedShapeUtil'
|
||||||
import { TextShapeUtil } from '../editor/shapes/text/TextShapeUtil'
|
import { TextShapeUtil } from '../editor/shapes/text/TextShapeUtil'
|
||||||
import { getCursor } from '../hooks/useCursor'
|
import { getCursor } from '../hooks/useCursor'
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import { Matrix2d } from '@tldraw/primitives'
|
import { Matrix2d } from '@tldraw/primitives'
|
||||||
|
import { track, useQuickReactor, useStateTracking } from '@tldraw/state'
|
||||||
import { TLShape, TLShapeId } from '@tldraw/tlschema'
|
import { TLShape, TLShapeId } from '@tldraw/tlschema'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {
|
|
||||||
track,
|
|
||||||
// @ts-expect-error 'private' export
|
|
||||||
useStateTracking,
|
|
||||||
} from 'signia-react'
|
|
||||||
import { useEditor } from '../..'
|
import { useEditor } from '../..'
|
||||||
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
||||||
import { useEditorComponents } from '../hooks/useEditorComponents'
|
import { useEditorComponents } from '../hooks/useEditorComponents'
|
||||||
import { useQuickReactor } from '../hooks/useQuickReactor'
|
|
||||||
import { useShapeEvents } from '../hooks/useShapeEvents'
|
import { useShapeEvents } from '../hooks/useShapeEvents'
|
||||||
import { OptionalErrorBoundary } from './ErrorBoundary'
|
import { OptionalErrorBoundary } from './ErrorBoundary'
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
|
import { useStateTracking, useValue } from '@tldraw/state'
|
||||||
import { TLShape, TLShapeId } from '@tldraw/tlschema'
|
import { TLShape, TLShapeId } from '@tldraw/tlschema'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {
|
|
||||||
// @ts-expect-error 'private' export
|
|
||||||
useStateTracking,
|
|
||||||
useValue,
|
|
||||||
} from 'signia-react'
|
|
||||||
import { useEditor } from '../..'
|
import { useEditor } from '../..'
|
||||||
import type { Editor } from '../editor/Editor'
|
import type { Editor } from '../editor/Editor'
|
||||||
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { react } from 'signia'
|
import { react } from '@tldraw/state'
|
||||||
import { TestEditor } from '../test/TestEditor'
|
import { TestEditor } from '../test/TestEditor'
|
||||||
import {
|
import {
|
||||||
TLSessionStateSnapshot,
|
TLSessionStateSnapshot,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Signal, computed, transact } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
RecordsDiff,
|
RecordsDiff,
|
||||||
UnknownRecord,
|
UnknownRecord,
|
||||||
|
@ -18,7 +19,6 @@ import {
|
||||||
} from '@tldraw/tlschema'
|
} from '@tldraw/tlschema'
|
||||||
import { objectMapFromEntries } from '@tldraw/utils'
|
import { objectMapFromEntries } from '@tldraw/utils'
|
||||||
import { T } from '@tldraw/validate'
|
import { T } from '@tldraw/validate'
|
||||||
import { Signal, computed, transact } from 'signia'
|
|
||||||
import { uniqueId } from '../utils/data'
|
import { uniqueId } from '../utils/data'
|
||||||
|
|
||||||
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
|
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { atom } from 'signia'
|
import { atom } from '@tldraw/state'
|
||||||
import { TestEditor } from '../test/TestEditor'
|
import { TestEditor } from '../test/TestEditor'
|
||||||
import { TLUserPreferences } from './TLUserPreferences'
|
import { TLUserPreferences } from './TLUserPreferences'
|
||||||
import { createTLUser } from './createTLUser'
|
import { createTLUser } from './createTLUser'
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { atom } from '@tldraw/state'
|
||||||
import { defineMigrations, migrate } from '@tldraw/store'
|
import { defineMigrations, migrate } from '@tldraw/store'
|
||||||
import { getDefaultTranslationLocale } from '@tldraw/tlschema'
|
import { getDefaultTranslationLocale } from '@tldraw/tlschema'
|
||||||
import { T } from '@tldraw/validate'
|
import { T } from '@tldraw/validate'
|
||||||
import { atom } from 'signia'
|
|
||||||
import { uniqueId } from '../utils/data'
|
import { uniqueId } from '../utils/data'
|
||||||
|
|
||||||
const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3'
|
const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { Signal, computed } from '@tldraw/state'
|
||||||
import { TLInstancePresence, TLStore } from '@tldraw/tlschema'
|
import { TLInstancePresence, TLStore } from '@tldraw/tlschema'
|
||||||
import { Signal, computed } from 'signia'
|
|
||||||
import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'
|
import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
intersectPolygonPolygon,
|
intersectPolygonPolygon,
|
||||||
pointInPolygon,
|
pointInPolygon,
|
||||||
} from '@tldraw/primitives'
|
} from '@tldraw/primitives'
|
||||||
|
import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state'
|
||||||
import { ComputedCache, RecordType } from '@tldraw/store'
|
import { ComputedCache, RecordType } from '@tldraw/store'
|
||||||
import {
|
import {
|
||||||
Box2dModel,
|
Box2dModel,
|
||||||
|
@ -79,7 +80,6 @@ import {
|
||||||
} from '@tldraw/utils'
|
} from '@tldraw/utils'
|
||||||
import { EventEmitter } from 'eventemitter3'
|
import { EventEmitter } from 'eventemitter3'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { EMPTY_ARRAY, atom, computed, transact } from 'signia'
|
|
||||||
import { TLUser, createTLUser } from '../config/createTLUser'
|
import { TLUser, createTLUser } from '../config/createTLUser'
|
||||||
import { checkShapesAndAddCore } from '../config/defaultShapes'
|
import { checkShapesAndAddCore } from '../config/defaultShapes'
|
||||||
import { AnyTLShapeInfo } from '../config/defineShape'
|
import { AnyTLShapeInfo } from '../config/defineShape'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { Computed, RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
|
||||||
import { TLArrowShape, TLShape, TLShapeId } from '@tldraw/tlschema'
|
import { TLArrowShape, TLShape, TLShapeId } from '@tldraw/tlschema'
|
||||||
import { Computed, RESET_VALUE, computed, isUninitialized } from 'signia'
|
|
||||||
import { Editor } from '../Editor'
|
import { Editor } from '../Editor'
|
||||||
import { ArrowShapeUtil } from '../shapes/arrow/ArrowShapeUtil'
|
import { ArrowShapeUtil } from '../shapes/arrow/ArrowShapeUtil'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { computed, isUninitialized, RESET_VALUE } from '@tldraw/state'
|
||||||
import { RecordsDiff } from '@tldraw/store'
|
import { RecordsDiff } from '@tldraw/store'
|
||||||
import { isShape, TLParentId, TLRecord, TLShapeId, TLStore } from '@tldraw/tlschema'
|
import { isShape, TLParentId, TLRecord, TLShapeId, TLStore } from '@tldraw/tlschema'
|
||||||
import { computed, isUninitialized, RESET_VALUE } from 'signia'
|
|
||||||
|
|
||||||
type Parents2Children = Record<TLParentId, [id: TLShapeId, index: string][]>
|
type Parents2Children = Record<TLParentId, [id: TLShapeId, index: string][]>
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { computed, isUninitialized, RESET_VALUE, withDiff } from '@tldraw/state'
|
||||||
import { IncrementalSetConstructor } from '@tldraw/store'
|
import { IncrementalSetConstructor } from '@tldraw/store'
|
||||||
import {
|
import {
|
||||||
isPageId,
|
isPageId,
|
||||||
|
@ -8,7 +9,6 @@ import {
|
||||||
TLShapeId,
|
TLShapeId,
|
||||||
TLStore,
|
TLStore,
|
||||||
} from '@tldraw/tlschema'
|
} from '@tldraw/tlschema'
|
||||||
import { computed, isUninitialized, RESET_VALUE, withDiff } from 'signia'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get whether a shape is in the current page.
|
* Get whether a shape is in the current page.
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { atom, transact } from '@tldraw/state'
|
||||||
import { devFreeze } from '@tldraw/store'
|
import { devFreeze } from '@tldraw/store'
|
||||||
import { atom, transact } from 'signia'
|
|
||||||
import { uniqueId } from '../../utils/data'
|
import { uniqueId } from '../../utils/data'
|
||||||
import { TLCommandHandler, TLHistoryEntry } from '../types/history-types'
|
import { TLCommandHandler, TLHistoryEntry } from '../types/history-types'
|
||||||
import { Stack, stack } from './Stack'
|
import { Stack, stack } from './Stack'
|
||||||
|
|
|
@ -11,9 +11,9 @@ import {
|
||||||
Vec2d,
|
Vec2d,
|
||||||
VecLike,
|
VecLike,
|
||||||
} from '@tldraw/primitives'
|
} from '@tldraw/primitives'
|
||||||
|
import { atom, computed, EMPTY_ARRAY } from '@tldraw/state'
|
||||||
import { TLParentId, TLShape, TLShapeId, Vec2dModel } from '@tldraw/tlschema'
|
import { TLParentId, TLShape, TLShapeId, Vec2dModel } from '@tldraw/tlschema'
|
||||||
import { dedupe, deepCopy } from '@tldraw/utils'
|
import { dedupe, deepCopy } from '@tldraw/utils'
|
||||||
import { atom, computed, EMPTY_ARRAY } from 'signia'
|
|
||||||
import { uniqueId } from '../../utils/data'
|
import { uniqueId } from '../../utils/data'
|
||||||
import type { Editor } from '../Editor'
|
import type { Editor } from '../Editor'
|
||||||
import { GroupShapeUtil } from '../shapes/group/GroupShapeUtil'
|
import { GroupShapeUtil } from '../shapes/group/GroupShapeUtil'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { EMPTY_ARRAY } from 'signia'
|
import { EMPTY_ARRAY } from '@tldraw/state'
|
||||||
|
|
||||||
export type Stack<T> = StackItem<T> | EmptyStackItem<T>
|
export type Stack<T> = StackItem<T> | EmptyStackItem<T>
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Vec2d,
|
Vec2d,
|
||||||
VecLike,
|
VecLike,
|
||||||
} from '@tldraw/primitives'
|
} from '@tldraw/primitives'
|
||||||
|
import { computed, EMPTY_ARRAY } from '@tldraw/state'
|
||||||
import { ComputedCache } from '@tldraw/store'
|
import { ComputedCache } from '@tldraw/store'
|
||||||
import {
|
import {
|
||||||
TLArrowShape,
|
TLArrowShape,
|
||||||
|
@ -23,7 +24,6 @@ import {
|
||||||
} from '@tldraw/tlschema'
|
} from '@tldraw/tlschema'
|
||||||
import { deepCopy, last, minBy } from '@tldraw/utils'
|
import { deepCopy, last, minBy } from '@tldraw/utils'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { computed, EMPTY_ARRAY } from 'signia'
|
|
||||||
import { SVGContainer } from '../../../components/SVGContainer'
|
import { SVGContainer } from '../../../components/SVGContainer'
|
||||||
import {
|
import {
|
||||||
ShapeUtil,
|
ShapeUtil,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { toDomPrecision } from '@tldraw/primitives'
|
import { toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
TLEmbedShape,
|
TLEmbedShape,
|
||||||
TLEmbedShapePermissions,
|
TLEmbedShapePermissions,
|
||||||
|
@ -7,7 +8,6 @@ import {
|
||||||
} from '@tldraw/tlschema'
|
} from '@tldraw/tlschema'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
||||||
import { HTMLContainer } from '../../../components/HTMLContainer'
|
import { HTMLContainer } from '../../../components/HTMLContainer'
|
||||||
import { useIsEditing } from '../../../hooks/useIsEditing'
|
import { useIsEditing } from '../../../hooks/useIsEditing'
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { Vec2d, toDomPrecision } from '@tldraw/primitives'
|
import { Vec2d, toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLImageShape, TLShapePartial } from '@tldraw/tlschema'
|
import { TLImageShape, TLShapePartial } from '@tldraw/tlschema'
|
||||||
import { deepCopy } from '@tldraw/utils'
|
import { deepCopy } from '@tldraw/utils'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
||||||
import { HTMLContainer } from '../../../components/HTMLContainer'
|
import { HTMLContainer } from '../../../components/HTMLContainer'
|
||||||
import { useIsCropping } from '../../../hooks/useIsCropping'
|
import { useIsCropping } from '../../../hooks/useIsCropping'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLDefaultColorStyle, TLDefaultFillStyle } from '@tldraw/tlschema'
|
import { TLDefaultColorStyle, TLDefaultFillStyle } from '@tldraw/tlschema'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { HASH_PATERN_ZOOM_NAMES } from '../../../constants'
|
import { HASH_PATERN_ZOOM_NAMES } from '../../../constants'
|
||||||
import { useEditor } from '../../../hooks/useEditor'
|
import { useEditor } from '../../../hooks/useEditor'
|
||||||
import { TLExportColors } from './TLExportColors'
|
import { TLExportColors } from './TLExportColors'
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable no-inner-declarations */
|
/* eslint-disable no-inner-declarations */
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLShape, TLUnknownShape } from '@tldraw/tlschema'
|
import { TLShape, TLUnknownShape } from '@tldraw/tlschema'
|
||||||
import React, { useCallback, useEffect, useRef } from 'react'
|
import React, { useCallback, useEffect, useRef } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { useEditor } from '../../../hooks/useEditor'
|
import { useEditor } from '../../../hooks/useEditor'
|
||||||
import { preventDefault, stopEventPropagation } from '../../../utils/dom'
|
import { preventDefault, stopEventPropagation } from '../../../utils/dom'
|
||||||
import { INDENT, TextHelpers } from '../text/TextHelpers'
|
import { INDENT, TextHelpers } from '../text/TextHelpers'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useValue } from 'signia-react'
|
import { useValue } from '@tldraw/state'
|
||||||
import { useEditor } from '../../../hooks/useEditor'
|
import { useEditor } from '../../../hooks/useEditor'
|
||||||
|
|
||||||
export function useForceSolid() {
|
export function useForceSolid() {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { toDomPrecision } from '@tldraw/primitives'
|
import { toDomPrecision } from '@tldraw/primitives'
|
||||||
|
import { track } from '@tldraw/state'
|
||||||
import { TLVideoShape } from '@tldraw/tlschema'
|
import { TLVideoShape } from '@tldraw/tlschema'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { track } from 'signia-react'
|
|
||||||
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
import { DefaultSpinner } from '../../../components/DefaultSpinner'
|
||||||
import { HTMLContainer } from '../../../components/HTMLContainer'
|
import { HTMLContainer } from '../../../components/HTMLContainer'
|
||||||
import { useIsEditing } from '../../../hooks/useIsEditing'
|
import { useIsEditing } from '../../../hooks/useIsEditing'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { Atom, Computed, atom, computed } from '@tldraw/state'
|
||||||
import { TLBaseShape } from '@tldraw/tlschema'
|
import { TLBaseShape } from '@tldraw/tlschema'
|
||||||
import { Atom, Computed, atom, computed } from 'signia'
|
|
||||||
import type { Editor } from '../Editor'
|
import type { Editor } from '../Editor'
|
||||||
import { TLShapeUtilConstructor } from '../shapes/ShapeUtil'
|
import { TLShapeUtilConstructor } from '../shapes/ShapeUtil'
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { PI, radiansToDegrees } from '@tldraw/primitives'
|
import { PI, radiansToDegrees } from '@tldraw/primitives'
|
||||||
|
import { useQuickReactor } from '@tldraw/state'
|
||||||
import { TLCursorType } from '@tldraw/tlschema'
|
import { TLCursorType } from '@tldraw/tlschema'
|
||||||
import { useContainer } from './useContainer'
|
import { useContainer } from './useContainer'
|
||||||
import { useEditor } from './useEditor'
|
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 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>`
|
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>`
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { debugFlags } from '../utils/debug-flags'
|
import { debugFlags } from '../utils/debug-flags'
|
||||||
import { useContainer } from './useContainer'
|
import { useContainer } from './useContainer'
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { TLKeyboardEventInfo, TLPointerEventInfo } from '../editor/types/event-types'
|
import { TLKeyboardEventInfo, TLPointerEventInfo } from '../editor/types/event-types'
|
||||||
import { preventDefault } from '../utils/dom'
|
import { preventDefault } from '../utils/dom'
|
||||||
import { useContainer } from './useContainer'
|
import { useContainer } from './useContainer'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLShapeId } from '@tldraw/tlschema'
|
import { TLShapeId } from '@tldraw/tlschema'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
export function useIsCropping(shapeId: TLShapeId) {
|
export function useIsCropping(shapeId: TLShapeId) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLShapeId } from '@tldraw/tlschema'
|
import { TLShapeId } from '@tldraw/tlschema'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
export function useIsEditing(shapeId: TLShapeId) {
|
export function useIsEditing(shapeId: TLShapeId) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { useComputed, useValue } from '@tldraw/state'
|
||||||
import uniq from 'lodash.uniq'
|
import uniq from 'lodash.uniq'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useComputed, useValue } from 'signia-react'
|
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
// TODO: maybe move this to a computed property on the App class?
|
// TODO: maybe move this to a computed property on the App class?
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLInstancePresence } from '@tldraw/tlschema'
|
import { TLInstancePresence } from '@tldraw/tlschema'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useValue } from 'signia-react'
|
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
// TODO: maybe move this to a computed property on the App class?
|
// TODO: maybe move this to a computed property on the App class?
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { EffectScheduler } from '@tldraw/state'
|
||||||
import { debounce } from '@tldraw/utils'
|
import { debounce } from '@tldraw/utils'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { EffectScheduler } from 'signia'
|
|
||||||
import { useContainer } from './useContainer'
|
import { useContainer } from './useContainer'
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Atom, atom, react } from 'signia'
|
import { Atom, atom, react } from '@tldraw/state'
|
||||||
|
|
||||||
// --- 1. DEFINE ---
|
// --- 1. DEFINE ---
|
||||||
//
|
//
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Signal, transact } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
RecordsDiff,
|
RecordsDiff,
|
||||||
SerializedSchema,
|
SerializedSchema,
|
||||||
|
@ -7,7 +8,6 @@ import {
|
||||||
} from '@tldraw/store'
|
} from '@tldraw/store'
|
||||||
import { TLStore } from '@tldraw/tlschema'
|
import { TLStore } from '@tldraw/tlschema'
|
||||||
import { assert } from '@tldraw/utils'
|
import { assert } from '@tldraw/utils'
|
||||||
import { Signal, transact } from 'signia'
|
|
||||||
import {
|
import {
|
||||||
TAB_ID,
|
TAB_ID,
|
||||||
TLSessionStateSnapshot,
|
TLSessionStateSnapshot,
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
{ "path": "../store" },
|
{ "path": "../store" },
|
||||||
{ "path": "../validate" },
|
{ "path": "../validate" },
|
||||||
{ "path": "../utils" },
|
{ "path": "../utils" },
|
||||||
{ "path": "../indices" }
|
{ "path": "../indices" },
|
||||||
|
{ "path": "../state" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
167
packages/state/CHANGELOG.md
Normal file
167
packages/state/CHANGELOG.md
Normal 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
3
packages/state/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# @tldraw/state
|
||||||
|
|
||||||
|
...
|
4
packages/state/api-extractor.json
Normal file
4
packages/state/api-extractor.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||||
|
"extends": "../../config/api-extractor.json"
|
||||||
|
}
|
170
packages/state/api-report.md
Normal file
170
packages/state/api-report.md
Normal 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)
|
||||||
|
|
||||||
|
```
|
5
packages/state/docs-ordering.json
Normal file
5
packages/state/docs-ordering.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
[
|
||||||
|
["functions", ["atom-1", "computed-1", "react", "reactor-1", "transact", "transaction"]],
|
||||||
|
["classes"],
|
||||||
|
["interfaces", ["Signal"]]
|
||||||
|
]
|
71
packages/state/package.json
Normal file
71
packages/state/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
3
packages/state/src/index.ts
Normal file
3
packages/state/src/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable local/no-export-star */
|
||||||
|
export * from './lib/core'
|
||||||
|
export * from './lib/react'
|
146
packages/state/src/lib/core/ArraySet.ts
Normal file
146
packages/state/src/lib/core/ArraySet.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
196
packages/state/src/lib/core/Atom.ts
Normal file
196
packages/state/src/lib/core/Atom.ts
Normal 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
|
||||||
|
}
|
378
packages/state/src/lib/core/Computed.ts
Normal file
378
packages/state/src/lib/core/Computed.ts
Normal 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
|
||||||
|
}
|
268
packages/state/src/lib/core/EffectScheduler.ts
Normal file
268
packages/state/src/lib/core/EffectScheduler.ts
Normal 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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
95
packages/state/src/lib/core/HistoryBuffer.ts
Normal file
95
packages/state/src/lib/core/HistoryBuffer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
56
packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts
Normal file
56
packages/state/src/lib/core/__tests__/HistoryBuffer.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
127
packages/state/src/lib/core/__tests__/arraySet.test.ts
Normal file
127
packages/state/src/lib/core/__tests__/arraySet.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
199
packages/state/src/lib/core/__tests__/atom.test.ts
Normal file
199
packages/state/src/lib/core/__tests__/atom.test.ts
Normal 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)
|
||||||
|
})
|
238
packages/state/src/lib/core/__tests__/capture.test.ts
Normal file
238
packages/state/src/lib/core/__tests__/capture.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
610
packages/state/src/lib/core/__tests__/computed.test.ts
Normal file
610
packages/state/src/lib/core/__tests__/computed.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
370
packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts
Normal file
370
packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
196
packages/state/src/lib/core/__tests__/reactor.test.ts
Normal file
196
packages/state/src/lib/core/__tests__/reactor.test.ts
Normal 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)
|
||||||
|
})
|
202
packages/state/src/lib/core/__tests__/transactions.test.ts
Normal file
202
packages/state/src/lib/core/__tests__/transactions.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
179
packages/state/src/lib/core/capture.ts
Normal file
179
packages/state/src/lib/core/capture.ts
Normal 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
packages/state/src/lib/core/constants.ts
Normal file
2
packages/state/src/lib/core/constants.ts
Normal 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
|
88
packages/state/src/lib/core/helpers.ts
Normal file
88
packages/state/src/lib/core/helpers.ts
Normal 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
|
12
packages/state/src/lib/core/index.ts
Normal file
12
packages/state/src/lib/core/index.ts
Normal 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'
|
11
packages/state/src/lib/core/isSignal.ts
Normal file
11
packages/state/src/lib/core/isSignal.ts
Normal 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
|
||||||
|
}
|
251
packages/state/src/lib/core/transactions.ts
Normal file
251
packages/state/src/lib/core/transactions.ts
Normal 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)
|
||||||
|
}
|
67
packages/state/src/lib/core/types.ts
Normal file
67
packages/state/src/lib/core/types.ts
Normal 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
|
7
packages/state/src/lib/react/index.ts
Normal file
7
packages/state/src/lib/react/index.ts
Normal 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'
|
227
packages/state/src/lib/react/track.test.tsx
Normal file
227
packages/state/src/lib/react/track.test.tsx
Normal 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",
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
})
|
60
packages/state/src/lib/react/track.ts
Normal file
60
packages/state/src/lib/react/track.ts
Normal 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
|
||||||
|
}
|
51
packages/state/src/lib/react/useAtom.test.tsx
Normal file
51
packages/state/src/lib/react/useAtom.test.tsx
Normal 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"`)
|
||||||
|
})
|
40
packages/state/src/lib/react/useAtom.ts
Normal file
40
packages/state/src/lib/react/useAtom.ts
Normal 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]
|
||||||
|
}
|
117
packages/state/src/lib/react/useComputed.test.tsx
Normal file
117
packages/state/src/lib/react/useComputed.test.tsx
Normal 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()
|
||||||
|
})
|
41
packages/state/src/lib/react/useComputed.ts
Normal file
41
packages/state/src/lib/react/useComputed.ts
Normal 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)
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { EffectScheduler, EMPTY_ARRAY } from 'signia'
|
import { EMPTY_ARRAY, EffectScheduler } from '../core'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = EMPTY_ARRAY) {
|
export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = EMPTY_ARRAY) {
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { EffectScheduler } from 'signia'
|
import { EffectScheduler } from '../core'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export function useReactor(name: string, reactFn: () => void, deps: undefined | any[] = []) {
|
export function useReactor(name: string, reactFn: () => void, deps: undefined | any[] = []) {
|
203
packages/state/src/lib/react/useStateTracking.test.tsx
Normal file
203
packages/state/src/lib/react/useStateTracking.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
58
packages/state/src/lib/react/useStateTracking.ts
Normal file
58
packages/state/src/lib/react/useStateTracking.ts
Normal 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()
|
||||||
|
}
|
135
packages/state/src/lib/react/useValue.test.tsx
Normal file
135
packages/state/src/lib/react/useValue.test.tsx
Normal 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",
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
})
|
98
packages/state/src/lib/react/useValue.ts
Normal file
98
packages/state/src/lib/react/useValue.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
10
packages/state/tsconfig.json
Normal file
10
packages/state/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
||||||
import { Atom } from 'signia';
|
import { Atom } from '@tldraw/state';
|
||||||
import { Computed } from 'signia';
|
import { Computed } from '@tldraw/state';
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;
|
export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;
|
||||||
|
|
|
@ -41,13 +41,11 @@
|
||||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tldraw/state": "workspace:*",
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"nanoid": "4.0.2"
|
"nanoid": "4.0.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"signia": "*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@peculiar/webcrypto": "^1.4.0",
|
"@peculiar/webcrypto": "^1.4.0",
|
||||||
"@types/lodash.isequal": "^4.5.6",
|
"@types/lodash.isequal": "^4.5.6",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
filterEntries,
|
filterEntries,
|
||||||
objectMapEntries,
|
objectMapEntries,
|
||||||
|
@ -7,7 +8,6 @@ import {
|
||||||
throttledRaf,
|
throttledRaf,
|
||||||
} from '@tldraw/utils'
|
} from '@tldraw/utils'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from 'signia'
|
|
||||||
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
||||||
import { Cache } from './Cache'
|
import { Cache } from './Cache'
|
||||||
import { RecordScope } from './RecordType'
|
import { RecordScope } from './RecordType'
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { objectMapValues } from '@tldraw/utils'
|
|
||||||
import isEqual from 'lodash.isequal'
|
|
||||||
import {
|
import {
|
||||||
Atom,
|
Atom,
|
||||||
computed,
|
computed,
|
||||||
|
@ -8,7 +6,9 @@ import {
|
||||||
isUninitialized,
|
isUninitialized,
|
||||||
RESET_VALUE,
|
RESET_VALUE,
|
||||||
withDiff,
|
withDiff,
|
||||||
} from 'signia'
|
} from '@tldraw/state'
|
||||||
|
import { objectMapValues } from '@tldraw/utils'
|
||||||
|
import isEqual from 'lodash.isequal'
|
||||||
import { IdOf, UnknownRecord } from './BaseRecord'
|
import { IdOf, UnknownRecord } from './BaseRecord'
|
||||||
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
|
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
|
||||||
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
||||||
|
|
|
@ -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 { BaseRecord, RecordId } from '../BaseRecord'
|
||||||
import { createRecordType } from '../RecordType'
|
import { createRecordType } from '../RecordType'
|
||||||
import { CollectionDiff, RecordsDiff, Store } from '../Store'
|
import { CollectionDiff, RecordsDiff, Store } from '../Store'
|
||||||
|
|
|
@ -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 { BaseRecord, IdOf, RecordId, UnknownRecord } from '../BaseRecord'
|
||||||
import { executeQuery } from '../executeQuery'
|
import { executeQuery } from '../executeQuery'
|
||||||
import { createRecordType } from '../RecordType'
|
import { createRecordType } from '../RecordType'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { atom, RESET_VALUE } from 'signia'
|
import { atom, RESET_VALUE } from '@tldraw/state'
|
||||||
import { BaseRecord, RecordId } from '../BaseRecord'
|
import { BaseRecord, RecordId } from '../BaseRecord'
|
||||||
import { createRecordType } from '../RecordType'
|
import { createRecordType } from '../RecordType'
|
||||||
import { Store } from '../Store'
|
import { Store } from '../Store'
|
||||||
|
|
|
@ -6,5 +6,5 @@
|
||||||
"outDir": "./.tsbuild",
|
"outDir": "./.tsbuild",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../utils" }]
|
"references": [{ "path": "../utils" }, { "path": "../state" }]
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { Expand } from '@tldraw/utils';
|
||||||
import { Migrations } from '@tldraw/store';
|
import { Migrations } from '@tldraw/store';
|
||||||
import { RecordId } from '@tldraw/store';
|
import { RecordId } from '@tldraw/store';
|
||||||
import { RecordType } from '@tldraw/store';
|
import { RecordType } from '@tldraw/store';
|
||||||
import { Signal } from 'signia';
|
import { Signal } from '@tldraw/state';
|
||||||
import { Store } from '@tldraw/store';
|
import { Store } from '@tldraw/store';
|
||||||
import { StoreSchema } from '@tldraw/store';
|
import { StoreSchema } from '@tldraw/store';
|
||||||
import { StoreSnapshot } from '@tldraw/store';
|
import { StoreSnapshot } from '@tldraw/store';
|
||||||
|
|
|
@ -56,12 +56,10 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tldraw/state": "workspace:*",
|
||||||
"@tldraw/store": "workspace:*",
|
"@tldraw/store": "workspace:*",
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
"@tldraw/validate": "workspace:*",
|
"@tldraw/validate": "workspace:*",
|
||||||
"nanoid": "4.0.2"
|
"nanoid": "4.0.2"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"signia": "*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue