snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
298
packages/state/ARCHITECTURE.md
Normal file
298
packages/state/ARCHITECTURE.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# @tldraw/state architecture
|
||||
|
||||
This document provides a machine-readable guide to the @tldraw/state package structure, optimized for AI agents and automated tools.
|
||||
|
||||
## Package overview
|
||||
|
||||
**Package Name**: @tldraw/state
|
||||
**Purpose**: Reactive state management library using signals
|
||||
**Entry Point**: `src/index.ts`
|
||||
**Main Export**: Complete reactive signals API
|
||||
**Dependencies**: @tldraw/utils
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
packages/state/
|
||||
├── src/
|
||||
│ ├── index.ts # Main entry point, all exports
|
||||
│ └── lib/ # Core implementation modules
|
||||
│ ├── Atom.ts # Mutable reactive state containers
|
||||
│ ├── Computed.ts # Derived reactive values
|
||||
│ ├── EffectScheduler.ts # Side effects and reactions
|
||||
│ ├── ArraySet.ts # Optimized set data structure
|
||||
│ ├── HistoryBuffer.ts # Change tracking and history
|
||||
│ ├── capture.ts # Dependency tracking system
|
||||
│ ├── transactions.ts # Batched updates and rollbacks
|
||||
│ ├── types.ts # Core type definitions
|
||||
│ ├── constants.ts # System constants
|
||||
│ ├── helpers.ts # Utility functions
|
||||
│ ├── isSignal.ts # Type guards
|
||||
│ ├── isComputed.ts # Computed brand check (no import of Computed.ts)
|
||||
│ ├── localStorageAtom.ts # Atom persisted to localStorage
|
||||
│ ├── warnings.ts # Development warnings
|
||||
│ └── __tests__/ # Test files
|
||||
├── DOCS.md # Human-readable documentation
|
||||
├── README.md # Package introduction
|
||||
├── SPEC.md # Behavior specification (rule IDs referenced by tests)
|
||||
└── ARCHITECTURE.md # This file
|
||||
```
|
||||
|
||||
## Core modules
|
||||
|
||||
### Primary API modules
|
||||
|
||||
#### `Atom.ts`
|
||||
|
||||
- **Exports**: `atom()`, `isAtom()`, `Atom` interface, `AtomOptions`
|
||||
- **Purpose**: Mutable reactive state containers
|
||||
- **Key Classes**: `_Atom`
|
||||
- **Dependencies**: ArraySet, HistoryBuffer, capture, helpers, transactions, types
|
||||
|
||||
#### `Computed.ts`
|
||||
|
||||
- **Exports**: `computed()`, `@computed`, `getComputedInstance()`, `isUninitialized()`, `UNINITIALIZED`, `withDiff()`
|
||||
- **Purpose**: Derived reactive values with lazy evaluation
|
||||
- **Key Classes**: `_Computed`
|
||||
- **Dependencies**: ArraySet, HistoryBuffer, capture, helpers, transactions, types
|
||||
|
||||
#### `EffectScheduler.ts`
|
||||
|
||||
- **Exports**: `EffectScheduler`, `react()`, `reactor()`, `EffectSchedulerOptions`, `Reactor`
|
||||
- **Purpose**: Side effects and reaction management
|
||||
- **Key Classes**: `__EffectScheduler__` (`reactor()` returns a plain object wrapping one)
|
||||
- **Dependencies**: ArraySet, capture, helpers, transactions, types
|
||||
|
||||
### Supporting infrastructure
|
||||
|
||||
#### `types.ts`
|
||||
|
||||
- **Core Types**: `Signal<Value, Diff>`, `Child`, `ComputeDiff`, `RESET_VALUE`
|
||||
- **Purpose**: Foundational type definitions for the entire system
|
||||
- **Role**: Defines interfaces implemented by Atom and Computed
|
||||
|
||||
#### `capture.ts`
|
||||
|
||||
- **Exports**: `unsafe__withoutCapture()`, `whyAmIRunning()`
|
||||
- **Purpose**: Dependency tracking and debugging
|
||||
- **Key Classes**: `CaptureStackFrame`
|
||||
- **Role**: Manages parent-child relationships between signals
|
||||
- **Dependencies**: helpers, isComputed, types (deliberately not Computed.ts, to avoid a cycle)
|
||||
|
||||
#### `transactions.ts`
|
||||
|
||||
- **Exports**: `transact()`, `transaction()`, `deferAsyncEffects()`
|
||||
- **Purpose**: Batched updates with rollback capability, the global epoch, and the reaction phase (`flushChanges`)
|
||||
- **Key Classes**: `Transaction`
|
||||
- **Role**: Ensures atomic state updates
|
||||
- **Dependencies**: Atom, constants, helpers, types
|
||||
|
||||
#### `helpers.ts`
|
||||
|
||||
- **Exports**: `EMPTY_ARRAY`, utility functions
|
||||
- **Purpose**: Common utilities and performance optimizations
|
||||
- **Key Functions**: `equals()`, `singleton()`, `attach()`, `detach()`, `haveParentsChanged()`
|
||||
|
||||
#### `ArraySet.ts`
|
||||
|
||||
- **Exports**: `ArraySet`
|
||||
- **Purpose**: Performance-optimized set implementation
|
||||
- **Role**: Manages parent-child relationships efficiently
|
||||
|
||||
#### `HistoryBuffer.ts`
|
||||
|
||||
- **Purpose**: Ring buffer of recent diffs
|
||||
- **Role**: Stores diffs for `getDiffSince`, so incremental computeds and effects can update from diffs
|
||||
|
||||
## Export mapping
|
||||
|
||||
```typescript
|
||||
// Core reactive primitives
|
||||
atom(name, value, options?) -> Atom<T>
|
||||
computed(name, fn, options?) -> Computed<T>
|
||||
react(name, fn, options?) -> () => void
|
||||
reactor(name, fn) -> Reactor<T>
|
||||
|
||||
// Transaction management
|
||||
transact(fn) -> T
|
||||
transaction(fn) -> T
|
||||
deferAsyncEffects(fn) -> Promise<T>
|
||||
|
||||
// Debugging and utilities
|
||||
unsafe__withoutCapture(fn) -> T
|
||||
whyAmIRunning() -> void
|
||||
isSignal(value) -> boolean
|
||||
isAtom(value) -> boolean
|
||||
isUninitialized(value) -> boolean
|
||||
|
||||
// Advanced features
|
||||
@computed -> PropertyDecorator
|
||||
getComputedInstance(obj, prop) -> Computed
|
||||
withDiff(value, diff) -> WithDiff
|
||||
UNINITIALIZED -> symbol
|
||||
RESET_VALUE -> symbol
|
||||
EMPTY_ARRAY -> readonly array
|
||||
localStorageAtom(name, initialValue, options?) -> [Atom<T>, cleanup]
|
||||
EffectScheduler -> class (low-level effect runner)
|
||||
ArraySet -> class (internal)
|
||||
```
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
index.ts
|
||||
├── Atom.ts
|
||||
│ ├── ArraySet.ts
|
||||
│ ├── HistoryBuffer.ts
|
||||
│ ├── capture.ts
|
||||
│ ├── helpers.ts
|
||||
│ ├── transactions.ts
|
||||
│ └── types.ts
|
||||
├── Computed.ts
|
||||
│ ├── ArraySet.ts
|
||||
│ ├── HistoryBuffer.ts
|
||||
│ ├── capture.ts
|
||||
│ ├── constants.ts
|
||||
│ ├── helpers.ts
|
||||
│ ├── isComputed.ts
|
||||
│ ├── transactions.ts
|
||||
│ ├── types.ts
|
||||
│ └── warnings.ts
|
||||
├── EffectScheduler.ts
|
||||
│ ├── ArraySet.ts
|
||||
│ ├── capture.ts
|
||||
│ ├── constants.ts
|
||||
│ ├── helpers.ts
|
||||
│ ├── transactions.ts
|
||||
│ └── types.ts
|
||||
├── capture.ts
|
||||
│ ├── helpers.ts
|
||||
│ ├── isComputed.ts
|
||||
│ └── types.ts
|
||||
├── isSignal.ts
|
||||
│ ├── Atom.ts
|
||||
│ └── Computed.ts
|
||||
├── localStorageAtom.ts
|
||||
│ ├── Atom.ts
|
||||
│ └── EffectScheduler.ts
|
||||
└── transactions.ts
|
||||
├── Atom.ts
|
||||
├── constants.ts
|
||||
├── helpers.ts
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
Atom.ts and transactions.ts import each other; neither uses the other at module-evaluation time, which is what keeps the cycle harmless.
|
||||
|
||||
## Key architectural patterns
|
||||
|
||||
### Signal interface pattern
|
||||
|
||||
- All reactive values implement `Signal<Value, Diff>` interface
|
||||
- Unified API: `get()`, `getDiffSince()`, `__unsafe__getWithoutCapture()`
|
||||
- Polymorphic handling of atoms and computed values
|
||||
|
||||
### Dependency tracking pattern
|
||||
|
||||
- `capture.ts` manages parent-child relationships
|
||||
- `Child` interface for dependents, `ArraySet<Child>` for parents
|
||||
- Automatic dependency detection via `maybeCaptureParent()`
|
||||
|
||||
### Lazy evaluation pattern
|
||||
|
||||
- Computed values only recalculate when dependencies change
|
||||
- `lastChangedEpoch` tracking for invalidation
|
||||
- `haveParentsChanged()` for dirty checking (O(parents)); an actively-listening computed skips it when it has not been traversed by a flush since it was last checked and no transaction is open
|
||||
|
||||
### Transaction pattern
|
||||
|
||||
- Nested transaction support with rollback
|
||||
- `initialAtomValues` map for restoration
|
||||
- Deferred effect scheduling until commit
|
||||
|
||||
### History tracking pattern
|
||||
|
||||
- Optional `HistoryBuffer` for diff history
|
||||
- `computeDiff` functions for incremental updates
|
||||
- `RESET_VALUE` for uncomputable diffs
|
||||
|
||||
## Performance optimizations
|
||||
|
||||
### Memory management
|
||||
|
||||
- `ArraySet` for efficient parent-child tracking
|
||||
- `EMPTY_ARRAY` singleton for empty dependencies
|
||||
- Lazy history buffer allocation
|
||||
|
||||
### Computation efficiency
|
||||
|
||||
- Epoch-based invalidation system
|
||||
- `unsafe__withoutCapture` for hot paths
|
||||
- Singleton pattern for global state
|
||||
|
||||
### Effect scheduling
|
||||
|
||||
- Pluggable `scheduleEffect` for batching
|
||||
- `deferAsyncEffects` for transaction control
|
||||
- `isActivelyListening` state management
|
||||
|
||||
## API categories by use case
|
||||
|
||||
### Basic state management
|
||||
|
||||
- `atom()` - mutable state
|
||||
- `computed()` - derived state
|
||||
- `react()` - side effects
|
||||
|
||||
### Advanced control
|
||||
|
||||
- `reactor()` - controllable reactions
|
||||
- `transact()` - batched updates
|
||||
- `@computed` - class-based computed properties
|
||||
|
||||
### Performance & debugging
|
||||
|
||||
- `unsafe__withoutCapture()` - performance optimization
|
||||
- `whyAmIRunning()` - dependency debugging
|
||||
- History tracking options for incremental computation
|
||||
|
||||
### Type guards & utilities
|
||||
|
||||
- `isSignal()`, `isAtom()`, `isUninitialized()`
|
||||
- `UNINITIALIZED`, `RESET_VALUE` constants
|
||||
- `withDiff()` for incremental computation
|
||||
|
||||
## Integration points
|
||||
|
||||
### External dependencies
|
||||
|
||||
- `@tldraw/utils`: `registerTldrawLibraryVersion()`, `assert()`
|
||||
|
||||
### Related packages
|
||||
|
||||
- `@tldraw/state-react`: React integration layer
|
||||
- `@tldraw/store`: Record storage using @tldraw/state
|
||||
- `@tldraw/editor`: Canvas editor using reactive state
|
||||
|
||||
### Extension points
|
||||
|
||||
- `AtomOptions.isEqual` - custom equality functions
|
||||
- `ComputeDiff` - custom diff computation
|
||||
- `EffectSchedulerOptions.scheduleEffect` - custom scheduling
|
||||
|
||||
## File navigation guide for AI agents
|
||||
|
||||
**To understand signals**: Start with `types.ts` → `Atom.ts` → `Computed.ts`
|
||||
**To understand reactivity**: Start with `EffectScheduler.ts` → `capture.ts`
|
||||
**To understand transactions**: Start with `transactions.ts`
|
||||
**To understand the API**: Start with `index.ts` exports
|
||||
**To understand performance**: Focus on `helpers.ts` → `ArraySet.ts`
|
||||
**To understand change tracking**: Focus on `HistoryBuffer.ts`
|
||||
|
||||
**Key entry points for specific functionality**:
|
||||
|
||||
- State creation: `Atom.ts:atom()`
|
||||
- Derived values: `Computed.ts:computed()`
|
||||
- Side effects: `EffectScheduler.ts:react()`
|
||||
- Batching: `transactions.ts:transact()`
|
||||
- Debugging: `capture.ts:whyAmIRunning()`
|
||||
564
packages/state/DOCS.md
Normal file
564
packages/state/DOCS.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# @tldraw/state documentation
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### What is @tldraw/state?
|
||||
|
||||
@tldraw/state is a powerful and lightweight TypeScript library for managing state using reactive values called **signals**. Its fine-grained reactive system allows you to build complex, performant, and predictable user interfaces and data models.
|
||||
|
||||
This library provides the core of tldraw's reactivity system. Its sister library, @tldraw/state-react, provides bindings for [React](https://react.dev/).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @tldraw/state
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
@tldraw/state is written in TypeScript and provides excellent type safety out of the box. No additional types package needed.
|
||||
|
||||
### Quick example
|
||||
|
||||
Here's a simple example to show how tldraw state works:
|
||||
|
||||
```ts
|
||||
import { atom, computed, react } from '@tldraw/state'
|
||||
|
||||
// Create some state
|
||||
const name = atom('name', 'World')
|
||||
const greeting = computed('greeting', () => `Hello, ${name.get()}!`)
|
||||
|
||||
// React to changes
|
||||
react('update page title', () => {
|
||||
document.title = greeting.get()
|
||||
})
|
||||
|
||||
// Update the state
|
||||
name.set('tldraw state')
|
||||
// Page title automatically updates to "Hello, tldraw state!"
|
||||
```
|
||||
|
||||
In just a few lines, you've created reactive state that automatically updates the page when it changes.
|
||||
|
||||
## 2. Signals
|
||||
|
||||
A **signal** is a reactive container for a value that can change over time.
|
||||
|
||||
In @tldraw/state, there are two types of signals:
|
||||
|
||||
- An **Atom** is the basic type of signal that acts as a container for a single value.
|
||||
- A **Computed** is a signal that derives its value from several other signals (atoms or other computeds).
|
||||
|
||||
### Atoms: the state containers
|
||||
|
||||
Atoms are the foundation of your application's state. They contain "raw" values that the rest of your application will use and react to.
|
||||
|
||||
#### Creating atoms
|
||||
|
||||
You create an atom using the atom function. You must give it a name and an initial value.
|
||||
|
||||
```ts
|
||||
import { atom } from '@tldraw/state'
|
||||
|
||||
const count = atom('count', 0)
|
||||
const user = atom('user', { name: 'Alice', age: 30 })
|
||||
```
|
||||
|
||||
> Tip: The name is used for debugging purposes, specifically for the `whyAmIRunning` function described later in these docs.
|
||||
|
||||
#### Reading an atom's value
|
||||
|
||||
To get the current value of an atom, use its `.get()` method.
|
||||
|
||||
```ts
|
||||
console.log(count.get()) // 0
|
||||
console.log(user.get().name) // 'Alice'
|
||||
```
|
||||
|
||||
> Tip: When you call `.get()` inside the function body of computed or a reaction, the library automatically **captures** that signal as a dependency.
|
||||
|
||||
#### Updating an atom's value
|
||||
|
||||
You can change an atom's value in two ways:
|
||||
|
||||
1. `.set(newValue)`: Directly sets the atom to a new value.
|
||||
|
||||
```ts
|
||||
count.set(1)
|
||||
console.log(count.get()) // 1
|
||||
```
|
||||
|
||||
2. `.update(updaterFn)`: Takes a function that receives the current value and returns the new value. This is useful when the new state depends on the old one.
|
||||
|
||||
```ts
|
||||
count.update((currentValue) => currentValue + 1)
|
||||
console.log(count.get()) // 2
|
||||
```
|
||||
|
||||
> Tip: If you try to set an atom to a value that is equal to its current value, the update will be skipped, and no reactions will be triggered.
|
||||
|
||||
#### Atom options
|
||||
|
||||
You can pass an options object as the third argument to atom to customize its behavior.
|
||||
|
||||
- `isEqual`: A function to compare the old and new values to determine if the atom has changed. This is useful for complex objects.
|
||||
|
||||
```ts
|
||||
// This atom will only update if the 'id' property changes.
|
||||
const activeUser = atom('activeUser', { id: 1, name: 'Bob' }, { isEqual: (a, b) => a.id === b.id })
|
||||
|
||||
// This will NOT trigger an update because the IDs are the same.
|
||||
activeUser.set({ id: 1, name: 'Robert' })
|
||||
```
|
||||
|
||||
- `historyLength` & `computeDiff`: These options are used for tracking changes over time. See the "History and diffs" section for more details.
|
||||
|
||||
### Computeds: the derived values
|
||||
|
||||
A Computed is a signal whose value is derived from other signals. You can use computed signals to create complex data models that automatically stay in sync.
|
||||
|
||||
#### Creating computeds
|
||||
|
||||
You create a computed signal using the `computed` function. It takes a name and a function that calculates its value. Inside this function, you can `.get()` the value of other signals.
|
||||
|
||||
```ts
|
||||
import { atom, computed } from '@tldraw/state'
|
||||
|
||||
const firstName = atom('firstName', 'John')
|
||||
const lastName = atom('lastName', 'Doe')
|
||||
|
||||
const fullName = computed('fullName', (prevValue) => {
|
||||
return `${firstName.get()} ${lastName.get()}`
|
||||
})
|
||||
|
||||
console.log(fullName.get()) // "John Doe"
|
||||
|
||||
// Now, if we change a dependency...
|
||||
firstName.set('Jane')
|
||||
|
||||
// ...the computed signal automatically updates!
|
||||
console.log(fullName.get()) // "Jane Doe"
|
||||
```
|
||||
|
||||
Note that computed signals capture both atoms and other computed signals as dependencies. Following the example above, if we create a new computed signal that depends on `fullName`, it will automatically update when `fullName` changes.
|
||||
|
||||
```ts
|
||||
const greeting = computed('greeting', (prevValue) => {
|
||||
return `Hello, ${fullName.get()}!`
|
||||
})
|
||||
|
||||
firstName.set('Sam')
|
||||
|
||||
console.log(greeting.get()) // "Hello, Sam Doe!"
|
||||
```
|
||||
|
||||
#### Dependency capture
|
||||
|
||||
This automatic dependency tracking works through a process called **dependency capture**. When the `fullName` function runs, the library actively "listens" for any calls to `.get()`. Each signal that is "gotten" is automatically registered as a dependency of `fullName`. The list of dependencies is updated every time the function re-runs, so they can even change dynamically.
|
||||
|
||||
#### Lazy evaluation
|
||||
|
||||
Computed signals are evaluated **lazily**. The calculation function only runs when you call `.get()` on the computed _and_ one of its captured signal dependencies has changed since the last time it was gotten. If nothing has changed, the computed returns its previous cached value.
|
||||
|
||||
#### Using `@computed` as a decorator
|
||||
|
||||
For classes, you can use the `@computed` decorator to create a computed property from a method. This is a clean way to co-locate derived data with its related state.
|
||||
|
||||
```ts
|
||||
class User {
|
||||
firstName = atom('firstName', 'John')
|
||||
lastName = atom('lastName', 'Doe')
|
||||
|
||||
@computed
|
||||
getFullName() {
|
||||
return `${this.firstName.get()} ${this.lastName.get()}`
|
||||
}
|
||||
}
|
||||
|
||||
const user = new User()
|
||||
console.log(user.getFullName()) // "John Doe"
|
||||
|
||||
user.firstName.set('Jane')
|
||||
console.log(user.getFullName()) // "Jane Doe"
|
||||
```
|
||||
|
||||
If you need to access the underlying computed instance for a computed property created with the `@computed` decorator, you can use the `getComputedInstance` function.
|
||||
|
||||
```ts
|
||||
const user = new User()
|
||||
const fullNameComputed = getComputedInstance(user, 'getFullName')
|
||||
console.log(fullNameComputed.get()) // "John Doe"
|
||||
```
|
||||
|
||||
## 3. Reactivity and side effects
|
||||
|
||||
Reading and deriving state is only half the story. The other half is performing actions, called _side effects_, that run when state changes. Side effects can be used for anything: updating the DOM, logging to the console, making a network request, and so on.
|
||||
|
||||
### Simple reactions with `react`
|
||||
|
||||
The easiest way to create a side effect is with the `react` function. You give it a name and a function to run. The library automatically **captures** which signals the function `.get()`s as dependencies and will re-run it whenever any of them change.
|
||||
|
||||
When created, `react` will immediate run your function once. It returns a `stop` function that you can call to tear down the reaction and stop it from listening to changes.
|
||||
|
||||
```ts
|
||||
import { atom, react } from '@tldraw/state'
|
||||
|
||||
const color = atom('color', 'red')
|
||||
|
||||
const stop = react('Update document title', () => {
|
||||
document.title = `The color is ${color.get()}`
|
||||
})
|
||||
|
||||
// The title is now "The color is red"
|
||||
|
||||
color.set('blue')
|
||||
// The title is now "The color is blue"
|
||||
|
||||
// To clean up the effect:
|
||||
stop()
|
||||
|
||||
color.set('green')
|
||||
// The title remains "The color is blue"
|
||||
```
|
||||
|
||||
> Tip: The stop function is perfect for "fire-and-forget" effects, especially within UI components (e.g., in a useEffect hook in React).
|
||||
|
||||
### Controlled reactions with `reactor`
|
||||
|
||||
For more control over the lifecycle of an effect, you can use `reactor`. It's similar to `react` but it doesn't start automatically. Instead, it returns a Reactor object with `.start()` and `.stop()` methods.
|
||||
|
||||
```ts
|
||||
import { atom, reactor } from '@tldraw/state'
|
||||
|
||||
const name = atom('name', 'world')
|
||||
|
||||
const greeter = reactor('Greeter', () => {
|
||||
console.log(`Hello, ${name.get()}!`)
|
||||
})
|
||||
|
||||
// Nothing has been logged yet.
|
||||
|
||||
greeter.start()
|
||||
// Logs: "Hello, world!"
|
||||
|
||||
name.set('galaxy')
|
||||
// Logs: "Hello, galaxy!"
|
||||
|
||||
greeter.stop()
|
||||
|
||||
name.set('universe')
|
||||
// Nothing is logged.
|
||||
```
|
||||
|
||||
> Tip: A reactor is useful when you have a long-lived effect that needs to be paused and resumed based on application logic.
|
||||
|
||||
## 4. Advanced topics
|
||||
|
||||
### Transactions: batching state updates
|
||||
|
||||
When you update multiple atoms that are dependencies of the same reaction, you might cause the reaction to re-run multiple times. Transactions solve this by batching all state changes into a single, atomic update, after which reactions will execute.
|
||||
|
||||
#### Using transact()
|
||||
|
||||
The `transact` function takes a callback. All state updates inside this callback are queued. Reactions are only triggered _after_ the callback has finished executing — once, whether the transaction commits or (see below) is rolled back.
|
||||
|
||||
```ts
|
||||
const firstName = atom('firstName', 'John')
|
||||
const lastName = atom('lastName', 'Doe')
|
||||
|
||||
react('greet', () => {
|
||||
console.log(`Hello, ${firstName.get()} ${lastName.get()}!`)
|
||||
})
|
||||
// Logs: "Hello, John Doe!"
|
||||
|
||||
transact(() => {
|
||||
// These two updates will be batched.
|
||||
firstName.set('Jane')
|
||||
lastName.set('Smith')
|
||||
// The reaction has NOT run yet.
|
||||
})
|
||||
|
||||
// NOW the reaction runs.
|
||||
// Logs: "Hello, Jane Smith!"
|
||||
```
|
||||
|
||||
#### Aborting and rolling back
|
||||
|
||||
Transactions may be aborted. Aborting a transaction will restore previous values of all signals modified inside of the transaction.
|
||||
|
||||
Rollbacks also occur automatically if an error is thrown inside the transaction.
|
||||
|
||||
```ts
|
||||
const name = atom('name', 'Alice')
|
||||
|
||||
try {
|
||||
transaction(() => {
|
||||
name.set('Bob')
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
} catch (e) {
|
||||
// The transaction was aborted.
|
||||
}
|
||||
|
||||
console.log(name.get()) // "Alice"
|
||||
```
|
||||
|
||||
You can also abort a transaction manually by calling the `rollback` function, which `transaction` (but not `transact`) passes to its callback.
|
||||
|
||||
```ts
|
||||
const name = atom('name', 'Alice')
|
||||
|
||||
transaction((rollback) => {
|
||||
name.set('Bob')
|
||||
rollback() // Discard the change
|
||||
})
|
||||
|
||||
console.log(name.get()) // "Alice"
|
||||
```
|
||||
|
||||
Aborting a transaction will _only_ restore the values of the signals that were modified inside of the transaction. Other types of data or parts of your application will not be affected. Reactions that depend on those signals still run once after the rollback, observing the restored values.
|
||||
|
||||
#### Nested transactions
|
||||
|
||||
You can call `transact` inside of another transaction. A new transaction will only be created if there is not already one in progress.
|
||||
|
||||
If you want to create nested transactions (in order to take advantage of the rollback functionality), you can use the `transaction` function instead of `transact`.
|
||||
|
||||
```ts
|
||||
transact(() => {
|
||||
firstName.set('Jane')
|
||||
|
||||
transaction((rollback) => {
|
||||
try {
|
||||
lastName.set('Smith')
|
||||
throw new Error('Something went wrong')
|
||||
} catch (e) {
|
||||
rollback()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log(firstName.get()) // "Jane"
|
||||
console.log(lastName.get()) // "Doe" // The change was rolled back
|
||||
```
|
||||
|
||||
### History and diffs
|
||||
|
||||
@tldraw/state can automatically track the history of changes to a signal, which lets dependents update incrementally instead of recomputing from scratch.
|
||||
|
||||
To enable history, you must provide the `historyLength` option when creating an atom or computed.
|
||||
|
||||
```ts
|
||||
const count = atom('count', 0, {
|
||||
historyLength: 10,
|
||||
// You can also provide a function to compute the difference (diff)
|
||||
computeDiff: (a, b) => b - a,
|
||||
})
|
||||
```
|
||||
|
||||
The `historyLength` option defines the maximum number of diffs to keep in the history buffer. If you expect the atom 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.
|
||||
|
||||
#### Retrieving diffs
|
||||
|
||||
Once history is enabled, you can use `getDiffSince(epoch)` to get an array of diffs that occurred since a specific point in time. Every signal exposes `lastChangedEpoch`, the epoch at which it last changed, which is the natural point to measure from.
|
||||
|
||||
```ts
|
||||
const startEpoch = count.lastChangedEpoch
|
||||
|
||||
count.set(5) // diff is 5
|
||||
count.set(12) // diff is 7
|
||||
|
||||
const diffs = count.getDiffSince(startEpoch)
|
||||
console.log(diffs) // [5, 7]
|
||||
```
|
||||
|
||||
If the library doesn't have enough history to compute the diffs, it will return the special `RESET_VALUE` symbol. This tells you that you need to re-compute the state from scratch instead of applying patches.
|
||||
|
||||
### Computed options
|
||||
|
||||
Similar to atoms, you can provide a `ComputedOptions` object as the third argument to the `computed` function.
|
||||
|
||||
```ts
|
||||
const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`, {
|
||||
isEqual: (a, b) => a === b,
|
||||
})
|
||||
```
|
||||
|
||||
You also can pass in a `ComputedOptions` when using the `@computed` decorator.
|
||||
|
||||
```ts
|
||||
class Counter {
|
||||
max = 100
|
||||
count = atom('count', 0)
|
||||
|
||||
@computed({ isEqual: (a, b) => a === b })
|
||||
getRemaining() {
|
||||
return this.max - this.count.get()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Incremental computation
|
||||
|
||||
Computed signals can take advantage of diffs to compute a value incrementally.
|
||||
|
||||
In addition to the options described for atoms, you can also provide a `computeDiff` function. This function is used to compute the diff between the previous and new values of the computed signal. As with atoms, diffs are only recorded when `historyLength` is set.
|
||||
|
||||
```ts
|
||||
const count = atom('count', 0)
|
||||
const double = computed(
|
||||
'double',
|
||||
(prevValue) => {
|
||||
return count.get() * 2
|
||||
},
|
||||
{ historyLength: 10, computeDiff: (a, b) => b - a }
|
||||
)
|
||||
```
|
||||
|
||||
You can use the `withDiff` helper to wrap the return value of a computed signal function, indicating that the diff should be used instead of calculating a new one with `ComputedOptions.computeDiff`.
|
||||
|
||||
```ts
|
||||
const count = atom('count', 0)
|
||||
const double = computed(
|
||||
'double',
|
||||
(prevValue) => {
|
||||
const nextValue = count.get() * 2
|
||||
if (isUninitialized(prevValue)) {
|
||||
return nextValue
|
||||
}
|
||||
return withDiff(nextValue, nextValue - prevValue)
|
||||
},
|
||||
{ historyLength: 10 }
|
||||
)
|
||||
```
|
||||
|
||||
#### Handling the first computed run
|
||||
|
||||
Sometimes you need to know if a computed function is running for the very first time. The function is called with the previous value, which will be the special symbol `UNINITIALIZED` on the first run. You can check for this using the `isUninitialized` helper. This is particularly useful for incremental computations.
|
||||
|
||||
```ts
|
||||
import { isUninitialized } from '@tldraw/state'
|
||||
|
||||
const list = computed('list', (prevValue) => {
|
||||
if (isUninitialized(prevValue)) {
|
||||
console.log('Calculating list for the first time!')
|
||||
// ... perform expensive initial calculation
|
||||
} else {
|
||||
// ... perform cheaper incremental update
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### Using the EffectScheduler
|
||||
|
||||
Under the hood, both `react` and `reactor` are powered by the `EffectScheduler`. You can use this low-level API for advanced use cases, like batching multiple effects together to run in the next animation frame.
|
||||
|
||||
You can provide a `scheduleEffect` function in the options. This function receives the execute callback, and it's up to you to decide when to call it.
|
||||
|
||||
```ts
|
||||
let isRafScheduled = false
|
||||
const scheduledEffects = []
|
||||
|
||||
const scheduleEffect = (execute) => {
|
||||
scheduledEffects.push(execute)
|
||||
if (!isRafScheduled) {
|
||||
isRafScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
isRafScheduled = false
|
||||
// Run all batched effects
|
||||
scheduledEffects.forEach((fn) => fn())
|
||||
scheduledEffects.length = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stop = react(
|
||||
'Update DOM batched',
|
||||
() => {
|
||||
/* ... update the DOM ... */
|
||||
},
|
||||
{ scheduleEffect }
|
||||
)
|
||||
```
|
||||
|
||||
### Performance optimization
|
||||
|
||||
While @tldraw/state is fast by default, there are tools for fine-tuning performance in demanding situations.
|
||||
|
||||
#### unsafe\_\_withoutCapture()
|
||||
|
||||
As explained earlier, when a computed or reaction runs, it automatically captures any signals you `.get()` as dependencies. Sometimes, however, you need to read a signal's value _without_ creating this dependency. `unsafe__withoutCapture` lets you step out of the current capture phase to do exactly that.
|
||||
|
||||
```ts
|
||||
const frequentlyChangingValue = atom('frequent', 0)
|
||||
const importantValue = atom('important', 'A')
|
||||
|
||||
react('log important changes', () => {
|
||||
console.log(`Important value changed to ${importantValue.get()}`)
|
||||
|
||||
// We read this value, but don't create a dependency on it.
|
||||
const otherValue = unsafe__withoutCapture(() => frequentlyChangingValue.get())
|
||||
console.log(`(The other value was ${otherValue} at the time)`)
|
||||
})
|
||||
|
||||
// This will NOT re-run the reaction.
|
||||
frequentlyChangingValue.set(1)
|
||||
```
|
||||
|
||||
### Type guards and utilities
|
||||
|
||||
The library exports several type guard functions to help you work with signals in TypeScript.
|
||||
|
||||
- `isSignal(value)`: Returns true if the value is an atom or a computed.
|
||||
- `isAtom(value)`: Returns true if the value is an atom.
|
||||
- `isUninitialized(value)`: Returns true if the value is the `UNINITIALIZED` symbol passed to a computed function on its first run.
|
||||
|
||||
## 5. Debugging
|
||||
|
||||
Because @tldraw/state manages a graph of dependencies, it can sometimes be tricky to understand why a particular reaction or computed signal is re-running. The library provides a powerful utility to help with this.
|
||||
|
||||
### whyAmIRunning()
|
||||
|
||||
If you're ever confused about what caused an effect to run, you can call `whyAmIRunning()` at the beginning of its function. From the next run on, it will log a detailed, hierarchical tree to the console, showing you exactly which atom(s) changed and triggered the update.
|
||||
|
||||
```ts
|
||||
import { atom, computed, react, whyAmIRunning } from '@tldraw/state'
|
||||
|
||||
const name = atom('name', 'Bob')
|
||||
const age = atom('age', 42)
|
||||
|
||||
const greeting = computed('greeting', () => {
|
||||
// We don't need to debug this one.
|
||||
return `Hello, ${name.get()}`
|
||||
})
|
||||
|
||||
react('log details', () => {
|
||||
// But we want to know why this reaction runs.
|
||||
whyAmIRunning()
|
||||
|
||||
console.log(`${greeting.get()} is ${age.get()} years old.`)
|
||||
})
|
||||
|
||||
// Nothing is logged on the first run (the run that arms whyAmIRunning).
|
||||
|
||||
age.set(43)
|
||||
|
||||
// When age is updated, it logs:
|
||||
// Effect(log details) is executing because:
|
||||
// ↳ Atom(age) changed
|
||||
|
||||
name.set('Alice')
|
||||
|
||||
// When name is updated, it logs:
|
||||
// Effect(log details) is executing because:
|
||||
// ↳ Computed(greeting) changed
|
||||
// ↳ Atom(name) changed
|
||||
```
|
||||
|
||||
This makes it much easier to trace the flow of data and updates through your application.
|
||||
|
||||
## 6. Integrations with React
|
||||
|
||||
In addition to the core library, @tldraw/state provides a separate package, @tldraw/state-react, for integrating with the React framework. This library is documented separately.
|
||||
9
packages/state/LICENSE.md
Normal file
9
packages/state/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
295
packages/state/README.md
Normal file
295
packages/state/README.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# @tldraw/state
|
||||
|
||||
`@tldraw/state` is a powerful and lightweight TypeScript library for managing reactive state using signals. It provides fine-grained reactive primitives that automatically track dependencies and efficiently update only what needs to change.
|
||||
|
||||
`@tldraw/state` powers the reactive system at the heart of [tldraw](https://www.tldraw.com), handling everything from canvas updates to collaborative state synchronization. It's designed to work seamlessly with [@tldraw/store](https://github.com/tldraw/tldraw/tree/main/packages/store) and has optional [React bindings](https://github.com/tldraw/tldraw/tree/main/packages/state-react).
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
||||
|
||||
## Why @tldraw/state?
|
||||
|
||||
- **Fine-grained reactivity** - Only re-runs computations when their actual dependencies change
|
||||
- **High performance** - Lazy evaluation and efficient dependency tracking
|
||||
- **Automatic updates** - Derived values and side effects update automatically
|
||||
- **Incremental updates** - Built-in diff history and transactions with rollback support
|
||||
- **Framework agnostic** - Works with any JavaScript framework or vanilla JS
|
||||
- **TypeScript first** - Excellent type safety with full TypeScript support
|
||||
|
||||
Perfect for building reactive UIs, real-time collaborative apps, and complex state machines where performance and predictability matter.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @tldraw/state
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { atom, computed, react } from '@tldraw/state'
|
||||
|
||||
// Create reactive state
|
||||
const name = atom('name', 'World')
|
||||
const count = atom('count', 0)
|
||||
|
||||
// Derive values automatically
|
||||
const greeting = computed('greeting', () => {
|
||||
return `Hello, ${name.get()}! Count: ${count.get()}`
|
||||
})
|
||||
|
||||
// React to changes
|
||||
react('logger', () => {
|
||||
console.log(greeting.get())
|
||||
})
|
||||
// Logs: "Hello, World! Count: 0"
|
||||
|
||||
// Update state - reactions run automatically
|
||||
name.set('tldraw')
|
||||
// Logs: "Hello, tldraw! Count: 0"
|
||||
|
||||
count.set(42)
|
||||
// Logs: "Hello, tldraw! Count: 42"
|
||||
```
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Atoms - state containers
|
||||
|
||||
Atoms hold raw values and are the foundation of your reactive state:
|
||||
|
||||
```ts
|
||||
import { atom } from '@tldraw/state'
|
||||
|
||||
// Create atoms with initial values
|
||||
const user = atom('user', { name: 'Alice', age: 30 })
|
||||
const theme = atom('theme', 'light')
|
||||
|
||||
// Read values
|
||||
console.log(user.get().name) // 'Alice'
|
||||
|
||||
// Update values
|
||||
user.update((current) => ({ ...current, age: 31 }))
|
||||
theme.set('dark')
|
||||
```
|
||||
|
||||
### Computed values - automatic derivation
|
||||
|
||||
Computed signals derive their values from other signals and update automatically:
|
||||
|
||||
```ts
|
||||
import { computed } from '@tldraw/state'
|
||||
|
||||
const firstName = atom('firstName', 'John')
|
||||
const lastName = atom('lastName', 'Doe')
|
||||
|
||||
const fullName = computed('fullName', () => {
|
||||
return `${firstName.get()} ${lastName.get()}`
|
||||
})
|
||||
|
||||
console.log(fullName.get()) // "John Doe"
|
||||
|
||||
firstName.set('Jane')
|
||||
console.log(fullName.get()) // "Jane Doe" - automatically updated!
|
||||
```
|
||||
|
||||
### Reactions - side effects
|
||||
|
||||
Reactions run side effects when their dependencies change:
|
||||
|
||||
```ts
|
||||
import { react } from '@tldraw/state'
|
||||
|
||||
const selectedId = atom('selectedId', null)
|
||||
|
||||
// Update UI when selection changes
|
||||
const stop = react('update-selection-ui', () => {
|
||||
const id = selectedId.get()
|
||||
document.getElementById('selected').textContent = id || 'None'
|
||||
})
|
||||
|
||||
selectedId.set('shape-123')
|
||||
// UI automatically updates
|
||||
|
||||
// Clean up when no longer needed
|
||||
stop()
|
||||
```
|
||||
|
||||
### Transactions - batched updates
|
||||
|
||||
Batch multiple updates to prevent intermediate reactions:
|
||||
|
||||
```ts
|
||||
import { transact } from '@tldraw/state'
|
||||
|
||||
const x = atom('x', 0)
|
||||
const y = atom('y', 0)
|
||||
|
||||
const position = computed('position', () => `(${x.get()}, ${y.get()})`)
|
||||
|
||||
react('log-position', () => console.log(position.get()))
|
||||
// Logs: "(0, 0)"
|
||||
|
||||
transact(() => {
|
||||
x.set(10)
|
||||
y.set(20)
|
||||
// Reaction runs only once after transaction
|
||||
})
|
||||
// Logs: "(10, 20)"
|
||||
```
|
||||
|
||||
## Advanced features
|
||||
|
||||
### History and diffs
|
||||
|
||||
Track changes over time so that dependents can update incrementally instead of recomputing from scratch:
|
||||
|
||||
```ts
|
||||
const canvas = atom(
|
||||
'canvas',
|
||||
{ shapes: [] },
|
||||
{
|
||||
historyLength: 100,
|
||||
computeDiff: (prev, next) => ({ prev, next }),
|
||||
}
|
||||
)
|
||||
|
||||
// Remember where you are...
|
||||
const startEpoch = canvas.lastChangedEpoch
|
||||
|
||||
// ... make changes ...
|
||||
canvas.update((state) => ({ shapes: [...state.shapes, newShape] }))
|
||||
|
||||
// ... and get the diffs since then (or RESET_VALUE if the history doesn't reach back that far)
|
||||
const diffs = canvas.getDiffSince(startEpoch)
|
||||
```
|
||||
|
||||
### Performance optimization
|
||||
|
||||
Use `unsafe__withoutCapture` to read values without creating dependencies:
|
||||
|
||||
```ts
|
||||
const expensiveComputed = computed('expensive', () => {
|
||||
const important = importantValue.get()
|
||||
|
||||
// Read this without making it a dependency
|
||||
const metadata = unsafe__withoutCapture(() => metadataAtom.get())
|
||||
|
||||
return computeExpensiveValue(important, metadata)
|
||||
})
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Use `whyAmIRunning()` to understand what triggered an update:
|
||||
|
||||
```ts
|
||||
react('debug-reaction', () => {
|
||||
whyAmIRunning() // Logs dependency tree to console
|
||||
// Your reaction code...
|
||||
})
|
||||
```
|
||||
|
||||
## Integration examples
|
||||
|
||||
### With tldraw SDK
|
||||
|
||||
```ts
|
||||
// e.g. in Tldraw's onMount callback, which receives the editor
|
||||
const selectedShapes = computed('selectedShapes', () => {
|
||||
return editor.getSelectedShapeIds().map((id) => editor.getShape(id))
|
||||
})
|
||||
|
||||
// React to selection changes; call the returned function to stop
|
||||
const stop = react('update-property-panel', () => {
|
||||
const shapes = selectedShapes.get()
|
||||
updatePropertyPanel(shapes)
|
||||
})
|
||||
```
|
||||
|
||||
### With React
|
||||
|
||||
Install the React bindings:
|
||||
|
||||
```bash
|
||||
npm install @tldraw/state-react
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { track, useAtom, useComputed } from '@tldraw/state-react'
|
||||
|
||||
// track() re-renders the component when any signal it reads changes
|
||||
const Counter = track(function Counter() {
|
||||
const count = useAtom('count', 0)
|
||||
const doubled = useComputed('doubled', () => count.get() * 2, [count])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Count: {count.get()}</p>
|
||||
<p>Doubled: {doubled.get()}</p>
|
||||
<button onClick={() => count.set(count.get() + 1)}>+</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
For complete API documentation, see [DOCS.md](./DOCS.md).
|
||||
|
||||
### Core functions
|
||||
|
||||
- `atom(name, initialValue, options?)` - Create a reactive state container
|
||||
- `computed(name, computeFn, options?)` - Create a derived value
|
||||
- `react(name, effectFn, options?)` - Create a side effect
|
||||
- `transact(fn)` - Batch state updates
|
||||
|
||||
### Class-based APIs
|
||||
|
||||
- `@computed` - Decorator for computed class properties
|
||||
|
||||
### Advanced
|
||||
|
||||
- `reactor(name, effectFn)` - Create a controllable reaction
|
||||
- `unsafe__withoutCapture(fn)` - Read state without creating dependencies
|
||||
- `whyAmIRunning()` - Debug what triggered an update
|
||||
- `getComputedInstance(obj, prop)` - Get underlying computed instance
|
||||
- `signal.getDiffSince(epoch)` - Get the diffs recorded since an epoch (see `signal.lastChangedEpoch`)
|
||||
|
||||
## Related packages
|
||||
|
||||
- **[@tldraw/state-react](../state-react)** - React bindings for @tldraw/state
|
||||
- **[@tldraw/store](../store)** - Record storage built on @tldraw/state
|
||||
- **[@tldraw/editor](../editor)** - The tldraw canvas editor
|
||||
- **[@tldraw/tldraw](../tldraw)** - Complete tldraw UI components
|
||||
|
||||
## Examples & patterns
|
||||
|
||||
Looking for more examples? Check out:
|
||||
|
||||
- [tldraw SDK examples](https://github.com/tldraw/tldraw/tree/main/apps/examples) - Real-world usage in tldraw applications
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License found [here](https://github.com/tldraw/tldraw/blob/main/packages/state/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw). You can contact us by email at [hello@tldraw.com](mailto:hello@tldraw.com).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
171
packages/state/SPEC.md
Normal file
171
packages/state/SPEC.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# @tldraw/state behavior specification
|
||||
|
||||
This document states the rules that `@tldraw/state` implements. It is written to drive testing: each rule has a stable ID (e.g. `EP2`, `T5`), each rule is independently observable through the public API (or the documented internal API where noted), and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
|
||||
|
||||
Sections marked **internal** describe supporting machinery (`ArraySet`, `HistoryBuffer`, capture functions) that has its own contract worth testing directly, even though users never touch it.
|
||||
|
||||
## 1. Model and vocabulary
|
||||
|
||||
- A **signal** is a reactive value container. There are two kinds: **atoms** (set directly) and **computed signals** (derived from other signals).
|
||||
- A **child** is anything that depends on signals: a computed signal or an effect scheduler.
|
||||
- A child's **parents** are the signals it dereferenced during its most recent execution.
|
||||
- An **effect** is a side-effecting function managed by an `EffectScheduler`, usually via `react()` or `reactor()`.
|
||||
- The **global epoch** is a single integer clock shared by every signal. It ticks when state changes.
|
||||
- A **transaction** batches atom changes and defers effects; it can be rolled back.
|
||||
- The **reaction phase** is the period during which changed atoms' effects are being run.
|
||||
- A **diff** is a value describing the change between two sequential values of a signal. `RESET_VALUE` is a sentinel meaning "no diff available; consumers must start over from the current value."
|
||||
|
||||
## 2. The epoch clock (EP)
|
||||
|
||||
- **EP1** There is one global epoch counter shared by all signals.
|
||||
- **EP2** Creating a signal (atom or computed) does not advance the global epoch.
|
||||
- **EP3** Setting an atom to a value that is not equal to its current value (see EQ) advances the global epoch by exactly one.
|
||||
- **EP4** Setting an atom to an equal value does not advance the global epoch.
|
||||
- **EP5** Every signal exposes `lastChangedEpoch`: the global epoch at the moment its value last actually changed. For a computed, recomputing to an equal value does not update `lastChangedEpoch`.
|
||||
- **EP6** Aborting a transaction advances the global epoch (in addition to any epoch advances caused by restoring atom values).
|
||||
|
||||
## 3. Equality (EQ)
|
||||
|
||||
- **EQ1** Default equality between an old value `a` and a new value `b` is: `a === b`, else `Object.is(a, b)`, else `a.equals(b)` if `a` has a callable `equals` method.
|
||||
- **EQ2** Default equality is asymmetric: only the _old_ value's `equals` method is consulted. A `b.equals` method alone has no effect.
|
||||
- **EQ3** If an `isEqual` option is provided to `atom()` or `computed()`, it replaces default equality for change detection on that signal.
|
||||
- **EQ4** When two values are "equal" by these rules, the system treats them as no change at all: no epoch advance, no history entry, no notification of children, and (for computed) the previous value object is retained — `get()` keeps returning the _old_ reference.
|
||||
|
||||
## 4. Atoms (A)
|
||||
|
||||
- **A1** `atom(name, initialValue, options?)` creates an atom whose `get()` returns `initialValue`.
|
||||
- **A2** `atom.set(value)` makes subsequent `get()` calls return `value`, unless `value` is equal (EQ) to the current value, in which case the call is a complete no-op.
|
||||
- **A3** `atom.set` returns the atom's value after the call (the new value, or the unchanged current value).
|
||||
- **A4** `atom.update(fn)` is exactly `atom.set(fn(currentValue))`. It does not accept a diff.
|
||||
- **A5** `atom.get()` captures the atom as a parent of the currently-executing computed or effect (see CAP). `atom.__unsafe__getWithoutCapture()` returns the same value without capturing.
|
||||
- **A6** Atoms are independent: changing one atom never affects another atom's value, history, or `lastChangedEpoch`.
|
||||
|
||||
## 5. Dependency capture (CAP)
|
||||
|
||||
- **CAP1** While a computed signal is deriving or an effect is executing, every signal whose `get()` (or `getDiffSince()`) is called is captured as a parent of that computed/effect.
|
||||
- **CAP2** A child's parent set after an execution is exactly the set of signals dereferenced during that execution. Signals dereferenced in a previous execution but not the latest one are removed (and detached).
|
||||
- **CAP3** Parents are recorded in first-dereference order, deduplicated: dereferencing the same signal twice captures it once.
|
||||
- **CAP4** Capture contexts nest. A computed dereferenced inside an effect captures its own parents in its own frame; the effect captures the computed, not the computed's parents.
|
||||
- **CAP5** `unsafe__withoutCapture(fn)` runs `fn` with capture disabled and restores the previous capture context afterwards, even if `fn` throws. Signals dereferenced inside it do not become parents, and changes to them alone do not re-run the enclosing computed/effect.
|
||||
- **CAP6** Dereferencing a signal outside any capture context is allowed and captures nothing.
|
||||
- **CAP7** (internal) Liveness propagates transitively: when an actively-listening child captures a computed parent, that computed attaches to its own parents, recursively. Conversely, when a computed loses its last child it detaches from its own parents, recursively. A signal's `children` set is empty whenever nothing is actively listening downstream.
|
||||
- **CAP8** (internal) `startCapturingParents`/`stopCapturingParents`/`maybeCaptureParent` reuse the child's existing `parents`/`parentEpochs` arrays (no reallocation), shrinking them in place when fewer parents are captured, and recording capture order as dereferenced.
|
||||
|
||||
## 6. Computed signals (C)
|
||||
|
||||
- **C1** Computeds are lazy. The compute function does not run at creation time; it first runs on the first `get()`.
|
||||
- **C2** Computeds are cached. Repeated `get()` calls re-run the compute function only if at least one parent's `lastChangedEpoch` differs from the epoch recorded when that parent was last dereferenced. Epoch advances unrelated to the computed's parents never cause recomputation. A read is never stale: this holds for reads inside a transaction during the reaction phase, for a computed whose listening effect was scheduled but has not executed yet, and for a computed that regains a listener after its ancestors changed.
|
||||
- **C3** A computed whose first execution captured no parents never recomputes.
|
||||
- **C4** The compute function receives `(previousValue, lastComputedEpoch)`. On the first computation `previousValue` is the `UNINITIALIZED` symbol (test with `isUninitialized`); afterwards it is the previous value. `lastComputedEpoch` is the epoch at which the computed last finished computing.
|
||||
- **C5** If recomputation produces a value equal (EQ) to the previous one, the computed keeps the previous value object and its `lastChangedEpoch`; downstream children observe no change. The signal's `isEqual` is never invoked for the first computation.
|
||||
- **C6** A chain of computeds re-runs minimally: each computed in the chain recomputes at most once per relevant change, and value-level deduplication (C5) stops propagation early.
|
||||
- **C7** `computed.isActivelyListening` is true exactly when the computed has at least one attached child (effect or actively-listening computed downstream).
|
||||
- **C8** The `@computed` decorator on a class method makes that method behave as `Computed.get()` for a per-instance computed signal: cached, reactive, with options (`isEqual`, `historyLength`, etc.) honored. `@computed()` with no options is equivalent to `@computed`. Both legacy and TC39 decorator protocols are supported. A subclass may override a `@computed` method with another `@computed` method and call `super.method()`: each decoration has its own per-instance computed.
|
||||
- **C9** `getComputedInstance(obj, propertyName)` returns the underlying `Computed` instance for the nearest decorated method on `obj`'s prototype chain, creating it on demand if the method has not been called yet. Its value type is the method's return type.
|
||||
- **C10** Using `@computed` on a _getter_ (legacy decorators only) still works but logs a one-time deprecation warning per process.
|
||||
|
||||
## 7. Errors in computed signals (CE)
|
||||
|
||||
- **CE1** If the compute function throws, the thrown value is cached: subsequent `get()` calls rethrow the same value without re-running the compute function, until a parent changes. This includes a throw from the very first computation.
|
||||
- **CE2** Entering the error state counts as a change: downstream effects re-run (and observe the throw). Throwing again on a subsequent recomputation, while already in the error state, does _not_ count as a change — consecutive errors do not re-trigger effects.
|
||||
- **CE3** Entering the error state discards the previous value: when the computed later recovers, the compute function receives `UNINITIALIZED` as `previousValue`, and the error state clears.
|
||||
- **CE4** Entering the error state clears the computed's history buffer; `getDiffSince` spanning the error returns `RESET_VALUE`.
|
||||
- **CE5** Errors do not corrupt the graph: `__unsafe__getWithoutCapture(true)` suppresses the rethrow (used internally so `haveParentsChanged` can be called on a throwing graph without exploding), and the computed still participates in capture.
|
||||
- **CE6** An effect that throws does not roll anything back: atom values (including those set in the surrounding transaction) keep their new values, and the error propagates to whoever triggered the flush.
|
||||
|
||||
## 8. History and diffs (H)
|
||||
|
||||
- **H1** A signal has a history buffer only if `historyLength` was passed at creation. `computeDiff` without `historyLength` does nothing.
|
||||
- **H2** When an atom with history changes, the recorded diff is chosen in priority order: the explicit `diff` argument to `set(value, diff)`, else `computeDiff(prev, next, lastChangedEpoch, currentEpoch)`, else `RESET_VALUE`. Only `undefined` means "not supplied"; an explicit `null` diff is recorded as a diff.
|
||||
- **H3** When a computed with history changes, the recorded diff is chosen in priority order: the diff from a `withDiff(value, diff)` return value, else `computeDiff(prev, next, lastCheckedEpoch, currentEpoch)`, else `RESET_VALUE`. No entry is recorded for the first computation. As for H2, only `undefined` means "not supplied".
|
||||
- **H4** Recording `RESET_VALUE` as a diff clears the entire history buffer. In particular, failing to supply a diff on a signal that has history but no `computeDiff` wipes its history.
|
||||
- **H5** `getDiffSince(epoch)` returns:
|
||||
- the shared frozen `EMPTY_ARRAY` if `epoch >= lastChangedEpoch` (nothing changed since);
|
||||
- otherwise `RESET_VALUE` if the signal has no history buffer;
|
||||
- otherwise the ordered array of diffs covering `epoch` → now, if the buffer reaches back that far;
|
||||
- otherwise `RESET_VALUE` (history evicted: more than `historyLength` changes since `epoch`, or the buffer was cleared, or `epoch` predates the signal's first computation).
|
||||
- **H6** `getDiffSince` captures the signal as a parent, like `get()`. On a computed it first brings the value up to date.
|
||||
- **H7** History keeps recording inside transactions. On abort, the history buffers of all atoms changed during the transaction are cleared (their pre-transaction diffs are gone too — `getDiffSince` from before the transaction returns `RESET_VALUE`). A computed's history is _not_ cleared by an abort; it records the round-trip (the change and the change back) as ordinary entries.
|
||||
- **H8** `withDiff(value, diff)` is only meaningful as a computed's return value; it is unwrapped so `get()` returns `value`, and `diff` feeds the history buffer per H3.
|
||||
|
||||
## 9. Effects: EffectScheduler, react, reactor (E)
|
||||
|
||||
- **E1** `react(name, fn)` runs `fn` immediately and unconditionally, then re-runs it whenever any signal captured during its previous run changes. It returns a stop function; after stopping, changes no longer re-run `fn`.
|
||||
- **E2** `reactor(name, fn)` does not run `fn` until `.start()`. `.start()` runs the effect if it has never run or if any parent changed while stopped; otherwise — including for an effect that captured no parents — it does not run. `.start({ force: true })` always runs it. `.stop()` detaches. Start/stop may be repeated, and `.start()` behaves the same when called from inside an effect during the reaction phase.
|
||||
- **E3** One state change produces at most one run of a given effect, even if the change affected several of its parents (e.g. several atoms set in one transaction, or a diamond dependency graph).
|
||||
- **E4** An effect re-runs only when a parent's value actually changed (per EQ and C5). Equal-value sets and unrelated epoch advances do not re-run it.
|
||||
- **E5** The effect function receives the epoch at which the effect last ran. This enables the incremental pattern `signal.getDiffSince(lastReactedEpoch)` inside effects. The epoch passed is the one captured _before_ the run, so an effect that updates atoms during its own run remains eligible to be scheduled again.
|
||||
- **E6** With a custom `scheduleEffect` option, scheduling and execution are decoupled: when the effect would run, `scheduleEffect(execute)` is called instead and nothing executes until the callback invokes `execute`. The initial run of `react()` is also routed through `scheduleEffect`. `scheduleCount` counts scheduling events, whether or not the effect later executes.
|
||||
- **E7** If an effect is detached after being scheduled but before the deferred `execute` callback runs, executing the callback is a no-op.
|
||||
- **E8** `EffectScheduler.attach()` does not by itself run the effect (`execute()` must be called the first time; afterwards changes schedule it per E3/E4). `detach()`/`attach()` cycles retain the captured parents: re-attaching does not re-run the effect unless a parent changed while detached (matching E2 since `reactor` is a thin wrapper). `attach()` brings computed parents that went stale while nothing was listening up to date before listening to them again.
|
||||
- **E9** `maybeScheduleEffect` on a detached scheduler is a no-op. On an attached scheduler whose parents are unchanged it is a no-op (but marks the scheduler as up to date with the current epoch).
|
||||
|
||||
## 10. Change propagation and the reaction phase (P)
|
||||
|
||||
- **P1** When an atom changes outside any transaction, effects run synchronously before `set` returns.
|
||||
- **P2** Propagation reaches effects through arbitrarily deep computed chains, but only along _actively listening_ edges. Effects (or computeds) that nothing is listening to are not visited.
|
||||
- **P3** Each child in the graph is traversed at most once per epoch during a flush, even when reachable through multiple paths.
|
||||
- **P4** Effects may set atoms. Changes made during the reaction phase do not interrupt the current pass: affected effects are collected and run after the current pass completes, repeatedly, until the system quiesces.
|
||||
- **P5** If that loop fails to quiesce after 1000 passes, the flush throws `Reaction update depth limit exceeded`. An effect that unconditionally writes to one of its own parents hits this limit.
|
||||
- **P6** An effect that sets atoms inside a transaction (or `transact`/`transaction` call) during the reaction phase gets the same deferral: the inner transaction's effects are folded into the current reaction phase rather than flushed reentrantly.
|
||||
- **P7** Computeds read during the reaction phase are correct: an effect that sets an atom and then reads a computed depending on it sees the updated value (within the same pass), including when both happen inside a transaction the effect opened (T2).
|
||||
- **P8** An effect's run is never re-entered by a flush. A set during a run that happens outside the reaction phase (the first run of `react()`, `reactor.start()`, or a direct `execute()`) still flushes synchronously per P1, but if that flush reaches the running effect itself, the effect runs again after the current run finishes rather than nesting a second run inside it. As in the reaction phase, it keeps running while its parents keep changing (subject to the P5 limit), skips the extra run if none of the parents it captured have changed by then, and does not run again if it was detached during the run (E7). Calling `execute()` re-entrantly from inside the effect is not supported: both runs would share one `parents`.
|
||||
|
||||
## 11. Transactions (T)
|
||||
|
||||
- **T1** `transaction(fn)` starts a new transaction, always. `transact(fn)` joins the current transaction if one exists, else behaves like `transaction(fn)`. Both return `fn`'s return value — including when the transaction rolls back.
|
||||
- **T2** Inside a transaction there is no isolation: atom `set`s are visible immediately to subsequent reads, and computeds recompute on demand against in-transaction values.
|
||||
- **T3** Effects do not run during a transaction. They run once, synchronously, when the _outermost_ transaction commits, per E3.
|
||||
- **T4** `fn` receives a `rollback` callback. Calling it (and then returning normally) aborts the transaction: every atom changed during the transaction is restored to the value it had when the transaction began.
|
||||
- **T5** If `fn` throws, the transaction aborts as in T4 and the exception propagates.
|
||||
- **T6** An aborted transaction still flushes effects: effects whose parents went through a change-and-restore round trip are scheduled, observe the restored values, and per E4 may run. Effects never observe intermediate in-transaction values.
|
||||
- **T7** Nested `transaction` calls roll back independently: an inner rollback restores to the _inner_ transaction's start. A committed inner transaction is still undone by an outer rollback (the inner transaction's initial values fold into the parent on commit), including when the transactions run inside an effect during the reaction phase.
|
||||
- **T8** Because `transact` joins rather than nests, a throw inside an inner `transact` does not restore anything by itself; if it propagates out of the outermost transaction, T5 applies there.
|
||||
- **T9** Abort clears the history buffers of every atom changed in the transaction (H7).
|
||||
- **T10** Mismatched transaction boundaries (committing a transaction that is not the innermost) throw `Transaction boundaries overlap`.
|
||||
|
||||
## 12. Async transactions: deferAsyncEffects (AT)
|
||||
|
||||
- **AT1** `deferAsyncEffects(fn)` runs the async `fn` in a transaction context: atom changes are visible immediately to reads (T2 applies), but effects are deferred until the async transaction completes.
|
||||
- **AT2** It throws (rejects) if called while a synchronous transaction is in progress. Synchronous `transaction`/`transact` calls _inside_ the async body are fine and nest normally.
|
||||
- **AT3** If `fn` rejects or throws, all changes made during the async transaction are rolled back and the error propagates. The rollback is atomic from the point of view of effects (T6): they run once, after every atom has been restored.
|
||||
- **AT4** Calling `deferAsyncEffects` while another async transaction is in flight joins it: changes from both are batched together, and effects run only after the last participating process finishes. Async transaction state leaks across `await` boundaries between concurrent processes (no AsyncContext); the grouping of effects is the guarantee, not isolation.
|
||||
- **AT5** A `deferAsyncEffects` call kicked off during the reaction phase waits for the reaction phase to finish before starting.
|
||||
- **AT6** The returned promise resolves to `fn`'s return value.
|
||||
|
||||
## 13. Debugging aids (D)
|
||||
|
||||
- **D1** Every signal and effect takes a `name` for debugging. Names need not be unique and have no behavioral effect.
|
||||
- **D2** `whyAmIRunning()` throws if called outside a computed or effect execution.
|
||||
- **D3** When called inside one, the _next_ execution of that computed/effect logs (via `console.log`) a tree of which ancestor signals changed, by name; if it was executed without any ancestor change, it logs that it was executed manually.
|
||||
|
||||
## 14. Type guards and module duplication (G)
|
||||
|
||||
- **G1** `isAtom(v)` is true exactly for values created by `atom()`; `isComputed(v)` exactly for computed signals (including instances behind decorated methods); `isSignal(v)` is `isAtom(v) || isComputed(v)`. All three return false for `null`, `undefined`, plain objects, functions, and each other's instances.
|
||||
- **G2** The library's classes and global state (epoch counter, transaction state, capture stack, class identities) are registered as `globalThis` singletons. If two copies of the library are loaded in one realm, they share one reactive universe and the type guards in G1 work across copies.
|
||||
|
||||
## 15. localStorageAtom (LS)
|
||||
|
||||
- **LS1** `localStorageAtom(name, initialValue, options?)` returns `[atom, cleanup]`. The atom's starting value is the JSON-parsed localStorage entry for `name` when one exists, else `initialValue`.
|
||||
- **LS2** A corrupted (unparseable) localStorage entry is deleted, and `initialValue` is used. An empty-string entry is treated as absent.
|
||||
- **LS3** The atom's value is written to localStorage as JSON on creation and after every change.
|
||||
- **LS4** A `storage` event for the same key updates the atom with the parsed new value; a `storage` event with `newValue: null` resets the atom to `initialValue`; an unparseable `newValue` and events for other keys are ignored.
|
||||
- **LS5** `cleanup()` stops localStorage writes and storage-event handling. The atom itself keeps working as a plain atom. Atom `options` (equality, history) pass through per A/H rules.
|
||||
- **LS6** Without a `window` (Node, SSR) `localStorageAtom` does not throw: it behaves as a plain atom with `initialValue` and `cleanup()` is a no-op.
|
||||
|
||||
## 16. ArraySet — internal (AS)
|
||||
|
||||
`ArraySet` is the dependency-set data structure. Contract: it behaves as a set under `add`/`remove`/`clear`/`has`/`size`/`isEmpty`/`visit`/iteration.
|
||||
|
||||
- **AS1** `add` returns true and inserts when the element is absent; returns false and does nothing when present. `remove` returns true and deletes when present; returns false otherwise.
|
||||
- **AS2** `visit` and `[Symbol.iterator]` yield each element exactly once; `has`, `size`, and `isEmpty` are consistent with the elements yielded.
|
||||
- **AS3** The implementation stores up to `ARRAY_SIZE_THRESHOLD` (8) elements in an array, promoting to a `Set` on overflow. Behavior is identical in both modes and across the promotion boundary, including after interleaved adds, removes, and clears.
|
||||
- **AS4** A fresh ArraySet allocates no backing storage until the first `add`. Every query on it (`has`, `size`, `isEmpty`, `visit`, iteration) answers as empty, and `remove` and `clear` are no-ops that leave it usable.
|
||||
|
||||
## 17. HistoryBuffer — internal (HB)
|
||||
|
||||
`HistoryBuffer` is the circular diff store behind H1–H5.
|
||||
|
||||
- **HB1** `pushEntry(fromEpoch, toEpoch, diff)` stores a diff covering the epoch range. Pushing `RESET_VALUE` clears the buffer, and so does pushing `undefined` ("no diff available"): skipping the entry instead would leave a gap, and a later query from before the gap would return an incomplete diff list.
|
||||
- **HB2** `getChangesSince(epoch)` returns the shared frozen `EMPTY_ARRAY` when the epoch is at or past the newest entry's `toEpoch`; the ordered diffs back to (and including) the entry whose range contains `epoch`, when the buffer reaches back that far; and `RESET_VALUE` otherwise (epoch too old, buffer empty, capacity exceeded, or cleared).
|
||||
- **HB3** The buffer holds at most `capacity` entries; the oldest entries are overwritten, after which queries reaching past them return `RESET_VALUE`.
|
||||
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": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
207
packages/state/api-report.api.md
Normal file
207
packages/state/api-report.api.md
Normal file
@@ -0,0 +1,207 @@
|
||||
## API Report File for "@tldraw/state"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
// @internal
|
||||
export class ArraySet<T> {
|
||||
[Symbol.iterator](): Generator<T, void, unknown>;
|
||||
add(elem: T): boolean;
|
||||
clear(): void;
|
||||
has(elem: T): boolean;
|
||||
get isEmpty(): boolean;
|
||||
remove(elem: T): boolean;
|
||||
size(): number;
|
||||
visit(visitor: (item: T) => void): void;
|
||||
}
|
||||
|
||||
// @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;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export interface Child {
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null;
|
||||
isActivelyListening: boolean;
|
||||
lastTraversedEpoch: number;
|
||||
readonly name: string;
|
||||
readonly parentEpochs: number[];
|
||||
readonly parents: Signal<any, any>[];
|
||||
readonly parentSet: ArraySet<Signal<any, any>>;
|
||||
}
|
||||
|
||||
// @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>[];
|
||||
// @internal (undocumented)
|
||||
readonly parentSet: ArraySet<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
|
||||
export function computed<This extends object, Value>(compute: () => Value, context: ClassMethodDecoratorContext<This, () => Value>): () => Value;
|
||||
|
||||
// @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) & (<This>(compute: () => Value, context: ClassMethodDecoratorContext<This, () => Value>) => () => Value);
|
||||
|
||||
// @public
|
||||
export type ComputeDiff<Value, Diff> = (previousValue: Value, currentValue: Value, lastComputedEpoch: number, currentEpoch: number) => Diff | RESET_VALUE;
|
||||
|
||||
// @public
|
||||
export interface ComputedOptions<Value, Diff> {
|
||||
computeDiff?: ComputeDiff<Value, Diff>;
|
||||
historyLength?: number;
|
||||
isEqual?(a: any, b: any): boolean;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function deferAsyncEffects<T>(fn: () => Promise<T>): Promise<T | undefined>;
|
||||
|
||||
// @public
|
||||
export const EffectScheduler: new <Result>(name: string, runEffect: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions) => EffectScheduler<Result>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EffectScheduler<Result> {
|
||||
// @internal (undocumented)
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null;
|
||||
attach(): void;
|
||||
detach(): void;
|
||||
execute(): Result;
|
||||
readonly isActivelyListening: boolean;
|
||||
// @internal (undocumented)
|
||||
readonly lastTraversedEpoch: number;
|
||||
// @internal (undocumented)
|
||||
maybeExecute(): void;
|
||||
// @internal (undocumented)
|
||||
maybeScheduleEffect(): void;
|
||||
// (undocumented)
|
||||
readonly name: string;
|
||||
// @internal (undocumented)
|
||||
readonly parentEpochs: number[];
|
||||
// @internal (undocumented)
|
||||
readonly parents: Signal<any, any>[];
|
||||
// @internal (undocumented)
|
||||
readonly parentSet: ArraySet<Signal<any, any>>;
|
||||
readonly scheduleCount: number;
|
||||
// @internal (undocumented)
|
||||
scheduleEffect(): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EffectSchedulerOptions {
|
||||
scheduleEffect?: (execute: () => void) => 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] extends () => infer Value ? Value : Obj[Prop]>;
|
||||
|
||||
// @public
|
||||
export function isAtom(value: unknown): value is Atom<unknown>;
|
||||
|
||||
// @public
|
||||
export function isSignal(value: any): value is Signal<any>;
|
||||
|
||||
// @public
|
||||
export function isUninitialized(value: any): value is UNINITIALIZED;
|
||||
|
||||
// @public
|
||||
export function localStorageAtom<Value, Diff = unknown>(name: string, initialValue: Value, options?: AtomOptions<Value, Diff>): [Atom<Value, Diff>, () => void];
|
||||
|
||||
// @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
|
||||
export const RESET_VALUE: unique symbol;
|
||||
|
||||
// @public
|
||||
export type RESET_VALUE = typeof RESET_VALUE;
|
||||
|
||||
// @public
|
||||
export interface Signal<Value, Diff = unknown> {
|
||||
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value;
|
||||
// @internal (undocumented)
|
||||
children: ArraySet<Child>;
|
||||
get(): Value;
|
||||
getDiffSince(epoch: number): Diff[] | RESET_VALUE;
|
||||
lastChangedEpoch: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function transact<T>(fn: () => T): T;
|
||||
|
||||
// @public
|
||||
export function transaction<T>(fn: (rollback: () => void) => T): T;
|
||||
|
||||
// @public
|
||||
export const UNINITIALIZED: unique symbol;
|
||||
|
||||
// @public
|
||||
export type UNINITIALIZED = typeof UNINITIALIZED;
|
||||
|
||||
// @public
|
||||
export function unsafe__withoutCapture<T>(fn: () => T): T;
|
||||
|
||||
// @public
|
||||
export function whyAmIRunning(): void;
|
||||
|
||||
// @public
|
||||
export const WithDiff: {
|
||||
new <Value, Diff>(value: Value, diff: Diff): {
|
||||
diff: Diff;
|
||||
value: Value;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface WithDiff<Value, Diff> {
|
||||
diff: Diff;
|
||||
value: Value;
|
||||
}
|
||||
|
||||
// @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"]]
|
||||
]
|
||||
68
packages/state/package.json
Normal file
68
packages/state/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@tldraw/state",
|
||||
"description": "tldraw infinite canvas SDK (state).",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "MIT",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"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",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.14",
|
||||
"lodash": "^4.17.21",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"typedoc": {
|
||||
"readmeFile": "none",
|
||||
"entryPoint": "./src/index.ts",
|
||||
"displayName": "@tldraw/state",
|
||||
"tsconfig": "./tsconfig.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
41
packages/state/src/index.ts
Normal file
41
packages/state/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
import { singleton } from './lib/helpers'
|
||||
|
||||
export { ArraySet } from './lib/ArraySet'
|
||||
export { atom, isAtom } from './lib/Atom'
|
||||
export type { Atom, AtomOptions } from './lib/Atom'
|
||||
export { unsafe__withoutCapture, whyAmIRunning } from './lib/capture'
|
||||
export {
|
||||
computed,
|
||||
getComputedInstance,
|
||||
isUninitialized,
|
||||
UNINITIALIZED,
|
||||
withDiff,
|
||||
} from './lib/Computed'
|
||||
export type { Computed, ComputedOptions, WithDiff } from './lib/Computed'
|
||||
export { EffectScheduler, react, reactor } from './lib/EffectScheduler'
|
||||
export type { EffectSchedulerOptions, Reactor } from './lib/EffectScheduler'
|
||||
export { EMPTY_ARRAY } from './lib/helpers'
|
||||
export { isSignal } from './lib/isSignal'
|
||||
export { localStorageAtom } from './lib/localStorageAtom'
|
||||
export { deferAsyncEffects, transact, transaction } from './lib/transactions'
|
||||
export { RESET_VALUE } from './lib/types'
|
||||
export type { Child, ComputeDiff, Signal } from './lib/types'
|
||||
|
||||
// This should be incremented any time an API change is made. i.e. for additions or removals.
|
||||
// Bugfixes need not increment this.
|
||||
const currentApiVersion = 1
|
||||
|
||||
const actualApiVersion = singleton('apiVersion', () => currentApiVersion)
|
||||
|
||||
if (actualApiVersion !== currentApiVersion) {
|
||||
throw new Error(
|
||||
`You have multiple incompatible versions of @tldraw/state in your app. Please deduplicate the package.`
|
||||
)
|
||||
}
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
173
packages/state/src/lib/ArraySet.ts
Normal file
173
packages/state/src/lib/ArraySet.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* The number of items an ArraySet holds in array mode before switching to a Set.
|
||||
* Exported only for tests.
|
||||
* @internal
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* `set` and `array` are never both non-null. `set` being null means array mode, but the array
|
||||
* itself is only allocated on the first `add` (most signals never get a child, and an empty
|
||||
* ArraySet is created for every atom and effect, and two for every computed), so array-mode code
|
||||
* must handle `array === null`; `arraySize` is 0 in that state. Once promoted to a set, an
|
||||
* ArraySet never goes back.
|
||||
* @internal
|
||||
*/
|
||||
export class ArraySet<T> {
|
||||
private set: Set<T> | null = null
|
||||
|
||||
// Slots [0, arraySize) hold the items; slots beyond are undefined. `add`/`has` scan the whole
|
||||
// array with indexOf, which is why `clear` and `remove` must blank vacated slots.
|
||||
private array: (T | undefined)[] | null = null
|
||||
|
||||
private arraySize = 0
|
||||
|
||||
/**
|
||||
* Get whether this ArraySet has any elements.
|
||||
*/
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
get isEmpty() {
|
||||
if (this.set) {
|
||||
return this.set.size === 0
|
||||
}
|
||||
|
||||
return this.arraySize === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an element to the ArraySet if it is not already present.
|
||||
*
|
||||
* @returns `true` if the element was added, `false` if it was already present
|
||||
*/
|
||||
add(elem: T) {
|
||||
if (this.set) {
|
||||
if (this.set.has(elem)) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.set.add(elem)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!this.array) {
|
||||
this.array = Array(ARRAY_SIZE_THRESHOLD)
|
||||
} else if (this.array.indexOf(elem) !== -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.arraySize < ARRAY_SIZE_THRESHOLD) {
|
||||
this.array[this.arraySize] = elem
|
||||
this.arraySize++
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The array is full: promote to a set.
|
||||
this.set = new Set(this.array as T[])
|
||||
this.set.add(elem)
|
||||
this.array = null
|
||||
this.arraySize = 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an element from the ArraySet if it is present.
|
||||
*
|
||||
* @returns `true` if the element was removed, `false` if it was not present
|
||||
*/
|
||||
remove(elem: T) {
|
||||
if (this.set) {
|
||||
return this.set.delete(elem)
|
||||
}
|
||||
|
||||
if (!this.array) {
|
||||
return false
|
||||
}
|
||||
|
||||
const idx = this.array.indexOf(elem)
|
||||
|
||||
if (idx === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.arraySize--
|
||||
|
||||
// Move the last item into the vacated slot so the items stay dense.
|
||||
this.array[idx] = this.array[this.arraySize]
|
||||
this.array[this.arraySize] = undefined
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback function for each element in the ArraySet.
|
||||
*/
|
||||
visit(visitor: (item: T) => void) {
|
||||
if (this.set) {
|
||||
this.set.forEach(visitor)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.array) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.arraySize; i++) {
|
||||
visitor(this.array[i]!)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the ArraySet iterable, allowing it to be used in for...of loops and with spread syntax.
|
||||
*/
|
||||
*[Symbol.iterator]() {
|
||||
if (this.set) {
|
||||
yield* this.set
|
||||
} else if (this.array) {
|
||||
for (let i = 0; i < this.arraySize; i++) {
|
||||
yield this.array[i]!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an element is present in the ArraySet.
|
||||
*/
|
||||
has(elem: T) {
|
||||
if (this.set) {
|
||||
return this.set.has(elem)
|
||||
}
|
||||
|
||||
return this.array ? this.array.indexOf(elem) !== -1 : false
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all elements from the ArraySet.
|
||||
*/
|
||||
clear() {
|
||||
if (this.set) {
|
||||
this.set.clear()
|
||||
} else if (this.array) {
|
||||
// Blank the used slots in place rather than allocating a new array: this runs on every
|
||||
// computed derive and effect run.
|
||||
this.array.fill(undefined, 0, this.arraySize)
|
||||
this.arraySize = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of elements in the ArraySet.
|
||||
*/
|
||||
size() {
|
||||
if (this.set) {
|
||||
return this.set.size
|
||||
}
|
||||
|
||||
return this.arraySize
|
||||
}
|
||||
}
|
||||
283
packages/state/src/lib/Atom.ts
Normal file
283
packages/state/src/lib/Atom.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { ArraySet } from './ArraySet'
|
||||
import { maybeCaptureParent } from './capture'
|
||||
import { EMPTY_ARRAY, equals, singleton } from './helpers'
|
||||
import { HistoryBuffer } from './HistoryBuffer'
|
||||
import { advanceGlobalEpoch, atomDidChange, getGlobalEpoch } from './transactions'
|
||||
import { Child, ComputeDiff, RESET_VALUE, Signal } from './types'
|
||||
|
||||
/**
|
||||
* The options to configure an atom, passed into the {@link atom} function.
|
||||
* @public
|
||||
*/
|
||||
export interface AtomOptions<Value, Diff> {
|
||||
/**
|
||||
* The maximum number of diffs to keep in the history buffer.
|
||||
*
|
||||
* If you don't need diffs, leave this as `undefined` and no history buffer will be created. Diffs passed to {@link Atom.set} or produced by {@link AtomOptions.computeDiff} are only recorded when this is set.
|
||||
*
|
||||
* 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 {@link 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 True if the values are equal, false otherwise.
|
||||
*/
|
||||
isEqual?(a: any, b: any): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An Atom is a signal that can be updated directly by calling {@link Atom.set} or {@link Atom.update}.
|
||||
*
|
||||
* Atoms are created using the {@link atom} function.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const name = atom('name', 'John')
|
||||
*
|
||||
* print(name.get()) // '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 {@link 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 implementation of the Atom interface. This class should not be used directly - use the {@link atom} function instead.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
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 ?? equals
|
||||
if (options?.historyLength) {
|
||||
this.historyBuffer = new HistoryBuffer(options.historyLength)
|
||||
}
|
||||
this.computeDiff = options?.computeDiff
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
readonly isEqual: (a: any, b: any) => boolean
|
||||
|
||||
/** @internal */
|
||||
computeDiff?: ComputeDiff<Value, Diff>
|
||||
|
||||
/** @internal */
|
||||
lastChangedEpoch = getGlobalEpoch()
|
||||
|
||||
/** @internal */
|
||||
children = new ArraySet<Child>()
|
||||
|
||||
/** @internal */
|
||||
historyBuffer?: HistoryBuffer<Diff>
|
||||
|
||||
/**
|
||||
* Gets the current value without capturing it as a dependency in the current reactive context.
|
||||
* This is unsafe because it breaks the reactivity chain - use with caution.
|
||||
*
|
||||
* @param _ignoreErrors - Unused parameter for API compatibility
|
||||
* @returns The current value
|
||||
* @internal
|
||||
*/
|
||||
__unsafe__getWithoutCapture(_ignoreErrors?: boolean): Value {
|
||||
return this.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of this atom. When called within a computed signal or reaction,
|
||||
* this atom will be automatically captured as a dependency.
|
||||
*
|
||||
* @returns The current value
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 5)
|
||||
* console.log(count.get()) // 5
|
||||
* ```
|
||||
*/
|
||||
get() {
|
||||
maybeCaptureParent(this)
|
||||
return this.current
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {@link AtomOptions.computeDiff}
|
||||
* @returns The new value
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* count.set(5) // count.get() is now 5
|
||||
* ```
|
||||
*/
|
||||
set(value: Value, diff?: Diff): Value {
|
||||
// If the value has not changed, do nothing.
|
||||
if (this.isEqual(this.current, value)) {
|
||||
return this.current
|
||||
}
|
||||
|
||||
// `computeDiff` is user code: run it before ticking the epoch, so that any signal it reads is
|
||||
// checked against the epoch this write has not yet happened in. Reading a dependent computed
|
||||
// after the tick would stamp it as checked at the new epoch while the atom was still being
|
||||
// written, and it would then never see the change. Only `undefined` means "no diff
|
||||
// supplied"; `null` can be a legitimate diff.
|
||||
let historyDiff: Diff | RESET_VALUE | undefined
|
||||
if (this.historyBuffer) {
|
||||
historyDiff =
|
||||
diff !== undefined
|
||||
? diff
|
||||
: this.computeDiff
|
||||
? this.computeDiff(this.current, value, this.lastChangedEpoch, getGlobalEpoch() + 1)
|
||||
: RESET_VALUE
|
||||
}
|
||||
|
||||
// Tick forward the global epoch. This write belongs to that one epoch, so read it once and
|
||||
// use it everywhere below — otherwise a `computeDiff` that touches other atoms could leave
|
||||
// the history entry and `lastChangedEpoch` disagreeing.
|
||||
advanceGlobalEpoch()
|
||||
const epoch = getGlobalEpoch()
|
||||
|
||||
// Add the diff to the history buffer.
|
||||
if (this.historyBuffer) {
|
||||
this.historyBuffer.pushEntry(this.lastChangedEpoch, epoch, historyDiff)
|
||||
}
|
||||
|
||||
// Update the atom's record of the epoch when last changed.
|
||||
this.lastChangedEpoch = epoch
|
||||
|
||||
const oldValue = this.current
|
||||
this.current = value
|
||||
|
||||
// Notify all children that this atom has changed.
|
||||
atomDidChange(this as any, oldValue)
|
||||
|
||||
return 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
|
||||
* @returns The new value
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 5)
|
||||
* count.update(n => n + 1) // count.get() is now 6
|
||||
* ```
|
||||
*/
|
||||
update(updater: (value: Value) => Value): Value {
|
||||
return this.set(updater(this.current))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the diffs that have occurred since the given epoch. When called within a computed
|
||||
* signal or reaction, this atom will be automatically captured as a dependency.
|
||||
*
|
||||
* @param epoch - The epoch to get changes since
|
||||
* @returns An array of diffs, or RESET_VALUE if history is insufficient
|
||||
* @internal
|
||||
*/
|
||||
getDiffSince(epoch: number): RESET_VALUE | Diff[] {
|
||||
maybeCaptureParent(this)
|
||||
|
||||
if (epoch >= this.lastChangedEpoch) {
|
||||
return EMPTY_ARRAY
|
||||
}
|
||||
|
||||
return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton reference to the Atom constructor. Used internally to create atom instances.
|
||||
* @internal
|
||||
*/
|
||||
export const _Atom = singleton('Atom', () => __Atom__)
|
||||
|
||||
/**
|
||||
* Type alias for instances of the internal Atom class.
|
||||
* @internal
|
||||
*/
|
||||
export type _Atom = InstanceType<typeof _Atom>
|
||||
|
||||
/**
|
||||
* Creates a new {@link Atom}.
|
||||
*
|
||||
* An Atom is a signal that can be updated directly by calling {@link Atom.set} or {@link Atom.update}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const name = atom('name', 'John')
|
||||
*
|
||||
* name.get() // 'John'
|
||||
*
|
||||
* name.set('Jane')
|
||||
*
|
||||
* name.get() // '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 {@link AtomOptions}.
|
||||
*/
|
||||
options?: AtomOptions<Value, Diff>
|
||||
): Atom<Value, Diff> {
|
||||
return new _Atom(name, initialValue, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given value is an {@link Atom}.
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns True if the value is an Atom, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const myAtom = atom('test', 42)
|
||||
* const notAtom = 'hello'
|
||||
*
|
||||
* console.log(isAtom(myAtom)) // true
|
||||
* console.log(isAtom(notAtom)) // false
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isAtom(value: unknown): value is Atom<unknown> {
|
||||
return value instanceof _Atom
|
||||
}
|
||||
659
packages/state/src/lib/Computed.ts
Normal file
659
packages/state/src/lib/Computed.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
import { assert } from '@tldraw/utils'
|
||||
import { ArraySet } from './ArraySet'
|
||||
import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture'
|
||||
import { GLOBAL_START_EPOCH } from './constants'
|
||||
import { EMPTY_ARRAY, equals, haveParentsChanged, singleton } from './helpers'
|
||||
import { HistoryBuffer } from './HistoryBuffer'
|
||||
import { getGlobalEpoch, getIsInTransaction } from './transactions'
|
||||
import { Child, ComputeDiff, RESET_VALUE, Signal } from './types'
|
||||
import { logComputedGetterWarning } from './warnings'
|
||||
|
||||
/**
|
||||
* A special symbol used to indicate that a computed signal has not been initialized yet.
|
||||
* This is passed as the `previousValue` parameter to a computed signal function on its first run.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* const double = computed('double', (prevValue) => {
|
||||
* if (isUninitialized(prevValue)) {
|
||||
* console.log('First computation!')
|
||||
* }
|
||||
* return count.get() * 2
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const UNINITIALIZED = Symbol.for('com.tldraw.state/UNINITIALIZED')
|
||||
/**
|
||||
* The type of the first value passed to a computed signal function as the 'prevValue' parameter.
|
||||
* This type represents the uninitialized state of a computed signal before its first calculation.
|
||||
*
|
||||
* @see {@link isUninitialized}
|
||||
* @public
|
||||
*/
|
||||
export 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)) {
|
||||
* print('First time!')
|
||||
* }
|
||||
* return count.get() * 2
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @public
|
||||
*/
|
||||
export function isUninitialized(value: any): value is UNINITIALIZED {
|
||||
return value === UNINITIALIZED
|
||||
}
|
||||
|
||||
/**
|
||||
* A singleton class used to wrap computed signal values along with their diffs.
|
||||
* This class is used internally by the {@link withDiff} function to provide both
|
||||
* the computed value and its diff to the signal system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* const double = computed('double', (prevValue) => {
|
||||
* const nextValue = count.get() * 2
|
||||
* if (isUninitialized(prevValue)) {
|
||||
* return nextValue
|
||||
* }
|
||||
* return withDiff(nextValue, nextValue - prevValue)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const WithDiff = singleton(
|
||||
'WithDiff',
|
||||
() =>
|
||||
class WithDiff<Value, Diff> {
|
||||
constructor(
|
||||
public value: Value,
|
||||
public diff: Diff
|
||||
) {}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Interface representing a value wrapped with its corresponding diff.
|
||||
* Used in incremental computation to provide both the new value and the diff from the previous value.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface WithDiff<Value, Diff> {
|
||||
/**
|
||||
* The computed value.
|
||||
*/
|
||||
value: Value
|
||||
/**
|
||||
* The diff between the previous and current value.
|
||||
*/
|
||||
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 {@link AtomOptions.computeDiff}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* const double = computed('double', (prevValue) => {
|
||||
* const nextValue = count.get() * 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 configuring computed signals. Used when calling `computed` or using the `@computed` decorator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const greeting = computed('greeting', () => `Hello ${name.get()}!`, {
|
||||
* historyLength: 10,
|
||||
* isEqual: (a, b) => a === b,
|
||||
* computeDiff: (oldVal, newVal) => ({ type: 'change', from: oldVal, to: newVal })
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ComputedOptions<Value, Diff> {
|
||||
/**
|
||||
* The maximum number of diffs to keep in the history buffer.
|
||||
*
|
||||
* If you don't need diffs, leave this as `undefined` and no history buffer will be created. Diffs supplied via {@link withDiff} or {@link ComputedOptions.computeDiff} are only recorded when this is set.
|
||||
*
|
||||
* 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 computed's old and new values. If provided, it will not be used unless you also specify {@link ComputedOptions.historyLength}.
|
||||
*/
|
||||
computeDiff?: ComputeDiff<Value, Diff>
|
||||
/**
|
||||
* If provided, this will be used to compare the old and new values of the computed 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 True if the values are equal, false otherwise.
|
||||
*/
|
||||
isEqual?(a: any, b: any): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A computed signal created via the `computed` function or `@computed` decorator.
|
||||
* Computed signals derive their values from other signals and automatically update when their dependencies change.
|
||||
* They use lazy evaluation, only recalculating when accessed and dependencies have changed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const firstName = atom('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
* const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`)
|
||||
*
|
||||
* console.log(fullName.get()) // "John Doe"
|
||||
* firstName.set('Jane')
|
||||
* console.log(fullName.get()) // "Jane Doe"
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface Computed<Value, Diff = unknown> extends Signal<Value, Diff> {
|
||||
/**
|
||||
* Whether this computed signal is involved in an actively-running effect graph.
|
||||
* Returns true if there are any reactions or other computed signals depending on this one.
|
||||
* @public
|
||||
*/
|
||||
readonly isActivelyListening: boolean
|
||||
|
||||
/** @internal */
|
||||
readonly parentSet: ArraySet<Signal<any, any>>
|
||||
/** @internal */
|
||||
readonly parents: Signal<any, any>[]
|
||||
/** @internal */
|
||||
readonly parentEpochs: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class __UNSAFE__Computed<Value, Diff = unknown> implements Computed<Value, Diff> {
|
||||
readonly __isComputed = true as const
|
||||
lastChangedEpoch = GLOBAL_START_EPOCH
|
||||
lastTraversedEpoch = GLOBAL_START_EPOCH
|
||||
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null
|
||||
|
||||
/**
|
||||
* The epoch when the reactor was last checked. Advances on every check, including ones that
|
||||
* find no change, unlike `lastChangedEpoch`.
|
||||
*/
|
||||
private lastCheckedEpoch = GLOBAL_START_EPOCH
|
||||
|
||||
parentSet = new ArraySet<Signal<any, any>>()
|
||||
parents: Signal<any, any>[] = []
|
||||
parentEpochs: number[] = []
|
||||
|
||||
children = new ArraySet<Child>()
|
||||
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
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
|
||||
// If the signal throws an error we stash it so we can rethrow it on the next get()
|
||||
private error: null | { thrownValue: any } = null
|
||||
|
||||
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(ignoreErrors?: boolean): Value {
|
||||
const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH
|
||||
|
||||
const globalEpoch = getGlobalEpoch()
|
||||
|
||||
if (
|
||||
!isNew &&
|
||||
(this.lastCheckedEpoch === globalEpoch ||
|
||||
// Every ancestor change to an actively-listening computed traverses it (`flushChanges`),
|
||||
// so if it hasn't been traversed since it was last checked the O(parents) scan can be
|
||||
// skipped. Not valid while a transaction is open (changes traverse at commit), and only
|
||||
// if it was up to date when attached (see `attach` in helpers.ts).
|
||||
(this.isActivelyListening &&
|
||||
!getIsInTransaction() &&
|
||||
this.lastTraversedEpoch <= this.lastCheckedEpoch) ||
|
||||
!haveParentsChanged(this))
|
||||
) {
|
||||
this.lastCheckedEpoch = globalEpoch
|
||||
if (this.error) {
|
||||
if (!ignoreErrors) {
|
||||
throw this.error.thrownValue
|
||||
} else {
|
||||
return this.state // will be UNINITIALIZED
|
||||
}
|
||||
} else {
|
||||
return this.state
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
startCapturingParents(this)
|
||||
const result = this.derive(this.state, this.lastCheckedEpoch)
|
||||
const newState = result instanceof WithDiff ? result.value : result
|
||||
const isUninitialized = this.state === UNINITIALIZED
|
||||
// `derive` may have advanced the epoch, so re-read it here, but only once — the whole
|
||||
// commit below belongs to a single epoch.
|
||||
const epoch = getGlobalEpoch()
|
||||
if (isUninitialized || !this.isEqual(this.state, newState)) {
|
||||
if (this.historyBuffer && !isUninitialized) {
|
||||
// Only `undefined` means "no diff supplied"; `null` can be a legitimate diff.
|
||||
const diff = result instanceof WithDiff ? result.diff : undefined
|
||||
this.historyBuffer.pushEntry(
|
||||
this.lastChangedEpoch,
|
||||
epoch,
|
||||
diff !== undefined
|
||||
? diff
|
||||
: this.computeDiff
|
||||
? this.computeDiff(this.state, newState, this.lastCheckedEpoch, epoch)
|
||||
: RESET_VALUE
|
||||
)
|
||||
}
|
||||
this.lastChangedEpoch = epoch
|
||||
this.state = newState
|
||||
}
|
||||
this.error = null
|
||||
this.lastCheckedEpoch = epoch
|
||||
|
||||
return this.state
|
||||
} catch (e) {
|
||||
// if a derived value throws an error, we reset the state to UNINITIALIZED
|
||||
const epoch = getGlobalEpoch()
|
||||
// Entering the error state (from a value, or from never having computed) is a change;
|
||||
// throwing again while already in it is not. Checking `error` rather than `state` matters
|
||||
// for a first run that throws: `state` is already UNINITIALIZED then, and leaving
|
||||
// `lastChangedEpoch` at GLOBAL_START_EPOCH would keep `isNew` true and re-run `derive` on
|
||||
// every read instead of rethrowing the cached error.
|
||||
if (this.error === null) {
|
||||
this.state = UNINITIALIZED as unknown as Value
|
||||
this.lastChangedEpoch = epoch
|
||||
}
|
||||
this.lastCheckedEpoch = epoch
|
||||
// we also clear the history buffer if an error was thrown
|
||||
if (this.historyBuffer) {
|
||||
this.historyBuffer.clear()
|
||||
}
|
||||
this.error = { thrownValue: e }
|
||||
// we don't wish to propagate errors when derefed via haveParentsChanged()
|
||||
if (!ignoreErrors) throw e
|
||||
return this.state
|
||||
} finally {
|
||||
stopCapturingParents()
|
||||
}
|
||||
}
|
||||
|
||||
get(): Value {
|
||||
try {
|
||||
return this.__unsafe__getWithoutCapture()
|
||||
} finally {
|
||||
// if the deriver throws an error we still need to capture
|
||||
maybeCaptureParent(this)
|
||||
}
|
||||
}
|
||||
|
||||
getDiffSince(epoch: number): RESET_VALUE | Diff[] {
|
||||
// we can ignore any errors thrown during derive
|
||||
this.__unsafe__getWithoutCapture(true)
|
||||
// and we still need to capture this signal as a parent
|
||||
maybeCaptureParent(this)
|
||||
|
||||
if (epoch >= this.lastChangedEpoch) {
|
||||
return EMPTY_ARRAY
|
||||
}
|
||||
|
||||
return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton reference to the computed signal implementation class.
|
||||
* Used internally by the library to create computed signal instances.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const _Computed = singleton('Computed', () => __UNSAFE__Computed)
|
||||
|
||||
/**
|
||||
* Type alias for the computed signal implementation class.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type _Computed = InstanceType<typeof __UNSAFE__Computed>
|
||||
|
||||
// Set on every decorated wrapper: the symbol its per-instance computed is stored under.
|
||||
const derivationKeyKey = '@@__computedDerivationKey__@@'
|
||||
|
||||
/**
|
||||
* Builds the method (or getter) body that `@computed` installs: a per-instance, lazily-created
|
||||
* computed over the original method. The storage symbol is unique per decoration rather than
|
||||
* `Symbol.for(name)` so that a subclass which overrides a `@computed` method and calls
|
||||
* `super.method()` reaches the superclass's computed instead of re-entering its own.
|
||||
*/
|
||||
function makeComputedWrapper(name: string, compute: () => any, options: ComputedOptions<any, any>) {
|
||||
const derivationKey = Symbol('__@tldraw/state__computed__' + name)
|
||||
|
||||
const wrapper = function (this: any) {
|
||||
let d = this[derivationKey] as Computed<any> | undefined
|
||||
|
||||
if (!d) {
|
||||
d = new _Computed(name, compute.bind(this) as any, options)
|
||||
Object.defineProperty(this, derivationKey, {
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
value: d,
|
||||
})
|
||||
}
|
||||
return d.get()
|
||||
} as any
|
||||
wrapper[derivationKeyKey] = derivationKey
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function computedDecorator(
|
||||
options: ComputedOptions<any, any> = {},
|
||||
args:
|
||||
| [target: any, key: string, descriptor: PropertyDescriptor]
|
||||
| [originalMethod: () => any, context: ClassMethodDecoratorContext]
|
||||
) {
|
||||
if (args.length === 2) {
|
||||
const [originalMethod, context] = args
|
||||
assert(context.kind === 'method', '@computed can only be used on methods')
|
||||
return makeComputedWrapper(String(context.name), originalMethod, options)
|
||||
} else {
|
||||
const [_target, key, descriptor] = args
|
||||
if (descriptor.get) {
|
||||
logComputedGetterWarning()
|
||||
descriptor.get = makeComputedWrapper(key, descriptor.get, options)
|
||||
} else {
|
||||
descriptor.value = makeComputedWrapper(key, descriptor.value, options)
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the underlying computed instance for a given property created with the `computed`
|
||||
* decorator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class Counter {
|
||||
* max = 100
|
||||
* count = atom('count', 0)
|
||||
*
|
||||
* @computed getRemaining() {
|
||||
* return this.max - this.count.get()
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const c = new Counter()
|
||||
* const remaining = getComputedInstance(c, 'getRemaining')
|
||||
* remaining.get() === 100 // true
|
||||
* c.count.set(13)
|
||||
* remaining.get() === 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] extends () => infer Value ? Value : Obj[Prop]> {
|
||||
// The nearest decorated wrapper (a method, or a legacy getter) on the prototype chain knows
|
||||
// which symbol the instance's computed is stored under.
|
||||
for (let proto: any = obj; proto; proto = Object.getPrototypeOf(proto)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, propertyName)
|
||||
const wrapper = descriptor?.get ?? descriptor?.value
|
||||
const key = wrapper?.[derivationKeyKey]
|
||||
if (!key) continue
|
||||
|
||||
let inst = (obj as any)[key]
|
||||
if (!inst) {
|
||||
// calling the wrapper creates the computed (before it first derives, so a throwing derive
|
||||
// still leaves the instance behind)
|
||||
try {
|
||||
wrapper.call(obj)
|
||||
} catch {
|
||||
// the caller asked for the instance, not its value
|
||||
}
|
||||
inst = (obj as any)[key]
|
||||
}
|
||||
return inst
|
||||
}
|
||||
return undefined as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a computed signal that derives its value from other signals.
|
||||
* Computed signals automatically update when their dependencies change and use lazy evaluation
|
||||
* for optimal performance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const name = atom('name', 'John')
|
||||
* const greeting = computed('greeting', () => `Hello ${name.get()}!`)
|
||||
* console.log(greeting.get()) // 'Hello John!'
|
||||
* ```
|
||||
*
|
||||
* `computed` may also be used as a decorator for creating computed getter methods.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class Counter {
|
||||
* max = 100
|
||||
* count = atom('count', 0)
|
||||
*
|
||||
* @computed getRemaining() {
|
||||
* return this.max - this.count.get()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* You may optionally pass in a {@link ComputedOptions} when used as a decorator:
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class Counter {
|
||||
* max = 100
|
||||
* count = atom('count', 0)
|
||||
*
|
||||
* @computed({isEqual: (a, b) => a === b})
|
||||
* getRemaining() {
|
||||
* return this.max - this.count.get()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param name - The name of the signal for debugging purposes
|
||||
* @param compute - The function that computes the value of the signal. Receives the previous value and last computed epoch
|
||||
* @param options - Optional configuration for the computed signal
|
||||
* @returns A new computed 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>
|
||||
/**
|
||||
* TC39 decorator for creating computed methods in classes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyClass {
|
||||
* value = atom('value', 10)
|
||||
*
|
||||
* @computed
|
||||
* doubled() {
|
||||
* return this.value.get() * 2
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param compute - The method to be decorated
|
||||
* @param context - The decorator context provided by TypeScript
|
||||
* @returns The decorated method
|
||||
* @public
|
||||
*/
|
||||
export function computed<This extends object, Value>(
|
||||
compute: () => Value,
|
||||
context: ClassMethodDecoratorContext<This, () => Value>
|
||||
): () => Value
|
||||
/**
|
||||
* Legacy TypeScript decorator for creating computed methods in classes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyClass {
|
||||
* value = atom('value', 10)
|
||||
*
|
||||
* @computed
|
||||
* doubled() {
|
||||
* return this.value.get() * 2
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param target - The class prototype
|
||||
* @param key - The property key
|
||||
* @param descriptor - The property descriptor
|
||||
* @returns The modified property descriptor
|
||||
* @public
|
||||
*/
|
||||
export function computed(
|
||||
target: any,
|
||||
key: string,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor
|
||||
/**
|
||||
* Decorator factory for creating computed methods with options.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyClass {
|
||||
* items = atom('items', [1, 2, 3])
|
||||
*
|
||||
* @computed({ historyLength: 10 })
|
||||
* sum() {
|
||||
* return this.items.get().reduce((a, b) => a + b, 0)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param options - Configuration options for the computed signal
|
||||
* @returns A decorator function that can be applied to methods
|
||||
* @public
|
||||
*/
|
||||
export function computed<Value, Diff = unknown>(
|
||||
options?: ComputedOptions<Value, Diff>
|
||||
): ((target: any, key: string, descriptor: PropertyDescriptor) => PropertyDescriptor) &
|
||||
(<This>(
|
||||
compute: () => Value,
|
||||
context: ClassMethodDecoratorContext<This, () => Value>
|
||||
) => () => Value)
|
||||
|
||||
/**
|
||||
* Implementation function that handles all computed signal creation and decoration scenarios.
|
||||
* This function is overloaded to support multiple usage patterns:
|
||||
* - Creating computed signals directly
|
||||
* - Using as a TC39 decorator
|
||||
* - Using as a legacy decorator
|
||||
* - Using as a decorator factory with options
|
||||
*
|
||||
* @returns Either a computed signal instance or a decorator function depending on usage
|
||||
* @public
|
||||
*/
|
||||
export function computed() {
|
||||
if (arguments.length <= 1) {
|
||||
// decorator factory: `@computed(options)` or `@computed()`
|
||||
const options = arguments[0]
|
||||
return (...args: any) => computedDecorator(options, args)
|
||||
} else if (typeof arguments[0] === 'string') {
|
||||
return new _Computed(arguments[0], arguments[1], arguments[2])
|
||||
} else {
|
||||
return computedDecorator(undefined, arguments as any)
|
||||
}
|
||||
}
|
||||
|
||||
import { isComputed as _isComputed } from './isComputed'
|
||||
|
||||
/**
|
||||
* Returns true if the given value is a computed signal.
|
||||
* @public
|
||||
*/
|
||||
export function isComputed(value: any): value is Computed<any> {
|
||||
return _isComputed(value)
|
||||
}
|
||||
407
packages/state/src/lib/EffectScheduler.ts
Normal file
407
packages/state/src/lib/EffectScheduler.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { ArraySet } from './ArraySet'
|
||||
import { startCapturingParents, stopCapturingParents } from './capture'
|
||||
import { GLOBAL_START_EPOCH } from './constants'
|
||||
import { attach, detach, haveParentsChanged, singleton } from './helpers'
|
||||
import { getGlobalEpoch } from './transactions'
|
||||
import { Signal } from './types'
|
||||
|
||||
/** @public */
|
||||
export 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 })
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line tldraw/method-signature-style
|
||||
scheduleEffect?: (execute: () => void) => void
|
||||
}
|
||||
|
||||
class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
||||
readonly __isEffectScheduler = true as const
|
||||
/** @internal */
|
||||
private _isActivelyListening = false
|
||||
/**
|
||||
* Whether this scheduler is attached and actively listening to its parents.
|
||||
* @public
|
||||
*/
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
get isActivelyListening() {
|
||||
return this._isActivelyListening
|
||||
}
|
||||
/** @internal */
|
||||
lastTraversedEpoch = GLOBAL_START_EPOCH
|
||||
|
||||
/** @internal */
|
||||
private lastReactedEpoch = GLOBAL_START_EPOCH
|
||||
|
||||
/** @internal */
|
||||
private _scheduleCount = 0
|
||||
/** @internal */
|
||||
private _executeDepth = 0
|
||||
/** @internal */
|
||||
private _wasScheduledWhileExecuting = false
|
||||
/** @internal */
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null
|
||||
|
||||
/**
|
||||
* The number of times this effect has been scheduled.
|
||||
* @public
|
||||
*/
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
get scheduleCount() {
|
||||
return this._scheduleCount
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
readonly parentSet = new ArraySet<Signal<any, any>>()
|
||||
/** @internal */
|
||||
readonly parentEpochs: number[] = []
|
||||
/** @internal */
|
||||
readonly parents: Signal<any, any>[] = []
|
||||
/** @internal */
|
||||
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 === getGlobalEpoch()) return
|
||||
|
||||
// An effect that has run before (or captured parents before throwing) only needs to run
|
||||
// again if one of those parents changed; that includes an effect that captured no parents at
|
||||
// all. An effect that has never run always runs.
|
||||
if (
|
||||
(this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&
|
||||
!haveParentsChanged(this)
|
||||
) {
|
||||
this.lastReactedEpoch = getGlobalEpoch()
|
||||
return
|
||||
}
|
||||
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.maybeExecute()
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
// eslint-disable-next-line tldraw/prefer-class-methods
|
||||
readonly maybeExecute = () => {
|
||||
// bail out if we have been detached before this runs
|
||||
if (!this._isActivelyListening) return
|
||||
// A set inside the running effect flushed synchronously back to this scheduler (only possible
|
||||
// outside the reaction phase, e.g. the first run of `react()`). Running now would open a second
|
||||
// capture frame inside the open one and corrupt `parents`, so re-check after the run instead.
|
||||
if (this._executeDepth > 0) {
|
||||
this._wasScheduledWhileExecuting = true
|
||||
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++) {
|
||||
const parent = this.parents[i]
|
||||
// a computed parent may have gone stale while nothing listened; see `attach` in helpers.ts
|
||||
parent.__unsafe__getWithoutCapture(true)
|
||||
attach(parent, 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.
|
||||
* @public
|
||||
*/
|
||||
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.
|
||||
* @public
|
||||
*/
|
||||
execute(): Result {
|
||||
// A direct re-entrant `execute()` from inside the effect is unsupported (both runs share one
|
||||
// `parents`); it is left to run so the outer run can at least finish normally.
|
||||
if (this._executeDepth > 0) return this.executeOnce()
|
||||
// a run that threw may have left this set
|
||||
this._wasScheduledWhileExecuting = false
|
||||
let result = this.executeOnce()
|
||||
// If a set inside the run reached this scheduler (see `maybeExecute`), settle it now the way
|
||||
// the reaction phase's cleanup pass would: run again while the parents keep changing.
|
||||
for (let depth = 0; this._wasScheduledWhileExecuting; depth++) {
|
||||
this._wasScheduledWhileExecuting = false
|
||||
if (depth >= 1000) {
|
||||
throw new Error('Reaction update depth limit exceeded')
|
||||
}
|
||||
if (!this._isActivelyListening) break
|
||||
if (!haveParentsChanged(this)) {
|
||||
this.lastReactedEpoch = getGlobalEpoch()
|
||||
break
|
||||
}
|
||||
result = this.executeOnce()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private executeOnce(): Result {
|
||||
// A counter rather than a flag: a nested `execute()` must not mark the outer run as finished.
|
||||
this._executeDepth++
|
||||
try {
|
||||
startCapturingParents(this)
|
||||
// Important! We have to make a note of the current epoch before running the effect.
|
||||
// We allow atoms to be updated during effects, which increments the global epoch,
|
||||
// so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself.
|
||||
const currentEpoch = getGlobalEpoch()
|
||||
const result = this.runEffect(this.lastReactedEpoch)
|
||||
this.lastReactedEpoch = currentEpoch
|
||||
return result
|
||||
} finally {
|
||||
stopCapturingParents()
|
||||
this._executeDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {@link react} and {@link reactor} functions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const render = new EffectScheduler('render', drawToCanvas)
|
||||
*
|
||||
* render.attach()
|
||||
* render.execute()
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EffectScheduler = singleton(
|
||||
'EffectScheduler',
|
||||
(): {
|
||||
new <Result>(
|
||||
name: string,
|
||||
runEffect: (lastReactedEpoch: number) => Result,
|
||||
options?: EffectSchedulerOptions
|
||||
): EffectScheduler<Result>
|
||||
} => __EffectScheduler__
|
||||
)
|
||||
/** @public */
|
||||
export interface EffectScheduler<Result> {
|
||||
/**
|
||||
* Whether this scheduler is attached and actively listening to its parents.
|
||||
* @public
|
||||
*/
|
||||
readonly isActivelyListening: boolean
|
||||
|
||||
/** @internal */
|
||||
readonly lastTraversedEpoch: number
|
||||
|
||||
/** @public */
|
||||
readonly name: string
|
||||
|
||||
/** @internal */
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null
|
||||
|
||||
/**
|
||||
* The number of times this effect has been scheduled.
|
||||
* @public
|
||||
*/
|
||||
readonly scheduleCount: number
|
||||
|
||||
/** @internal */
|
||||
readonly parentSet: ArraySet<Signal<any, any>>
|
||||
|
||||
/** @internal */
|
||||
readonly parentEpochs: number[]
|
||||
|
||||
/** @internal */
|
||||
readonly parents: Signal<any, any>[]
|
||||
|
||||
/** @internal */
|
||||
maybeScheduleEffect(): void
|
||||
|
||||
/** @internal */
|
||||
scheduleEffect(): void
|
||||
|
||||
/** @internal */
|
||||
maybeExecute(): void
|
||||
|
||||
/**
|
||||
* 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(): void
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @public
|
||||
*/
|
||||
detach(): void
|
||||
|
||||
/**
|
||||
* Executes the effect immediately and returns the result.
|
||||
* @returns The result of the effect.
|
||||
* @public
|
||||
*/
|
||||
execute(): Result
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.get()
|
||||
* })
|
||||
* 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.get()
|
||||
* }), [])
|
||||
* ```
|
||||
*
|
||||
* @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 {@link 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 {@link 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
163
packages/state/src/lib/HistoryBuffer.ts
Normal file
163
packages/state/src/lib/HistoryBuffer.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { EMPTY_ARRAY } from './helpers'
|
||||
import { RESET_VALUE } from './types'
|
||||
|
||||
/**
|
||||
* A tuple representing a range of epochs and the associated diff.
|
||||
* Used internally by HistoryBuffer to store change information.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
type RangeTuple<Diff> = [fromEpoch: number, toEpoch: number, diff: Diff]
|
||||
|
||||
/**
|
||||
* A circular buffer that stores diffs between sequential values of an atom or computed signal.
|
||||
* This enables efficient change tracking and history retrieval for features like undo/redo.
|
||||
*
|
||||
* The buffer uses a wrap-around strategy to maintain a fixed-size history of the most recent
|
||||
* changes, automatically overwriting older entries when the capacity is exceeded.
|
||||
*
|
||||
* Entries must be contiguous (`entry[k].toEpoch === entry[k+1].fromEpoch`); `getChangesSince`
|
||||
* relies on that to find the entry covering an epoch.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = new HistoryBuffer<string>(5)
|
||||
* buffer.pushEntry(0, 1, 'first change')
|
||||
* buffer.pushEntry(1, 2, 'second change')
|
||||
* const changes = buffer.getChangesSince(0) // ['first change', 'second change']
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class HistoryBuffer<Diff> {
|
||||
/**
|
||||
* Current write position in the circular buffer.
|
||||
* @internal
|
||||
*/
|
||||
private index = 0
|
||||
|
||||
/**
|
||||
* Circular buffer storing range tuples. Uses undefined to represent empty slots.
|
||||
* @internal
|
||||
*/
|
||||
buffer: Array<RangeTuple<Diff> | undefined>
|
||||
|
||||
/**
|
||||
* Creates a new HistoryBuffer with the specified capacity.
|
||||
*
|
||||
* capacity - Maximum number of diffs to store in the buffer
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = new HistoryBuffer<number>(10) // Store up to 10 diffs
|
||||
* ```
|
||||
*/
|
||||
constructor(private readonly capacity: number) {
|
||||
this.buffer = new Array(capacity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a diff entry to the history buffer, representing a change between two epochs.
|
||||
*
|
||||
* If the diff is RESET_VALUE, or undefined (meaning no diff is available), the entire buffer is
|
||||
* cleared to indicate that historical tracking should restart. Silently skipping an entry would
|
||||
* leave a gap, and a later `getChangesSince` from before the gap would return an incomplete
|
||||
* diff list rather than RESET_VALUE.
|
||||
*
|
||||
* @param lastComputedEpoch - The epoch when the previous value was computed
|
||||
* @param currentEpoch - The epoch when the current value was computed
|
||||
* @param diff - The diff representing the change, or RESET_VALUE / undefined to clear history
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = new HistoryBuffer<string>(5)
|
||||
* buffer.pushEntry(0, 1, 'added text')
|
||||
* buffer.pushEntry(1, 2, RESET_VALUE) // Clears the buffer
|
||||
* ```
|
||||
*/
|
||||
pushEntry(lastComputedEpoch: number, currentEpoch: number, diff: Diff | RESET_VALUE | undefined) {
|
||||
if (diff === RESET_VALUE || diff === undefined) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all entries from the history buffer and resets the write position.
|
||||
* This is called when a RESET_VALUE diff is encountered.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = new HistoryBuffer<string>(5)
|
||||
* buffer.pushEntry(0, 1, 'change')
|
||||
* buffer.clear()
|
||||
* console.log(buffer.getChangesSince(0)) // RESET_VALUE
|
||||
* ```
|
||||
*/
|
||||
clear() {
|
||||
this.index = 0
|
||||
this.buffer.fill(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all diffs that occurred since the specified epoch.
|
||||
*
|
||||
* The method searches backwards through the circular buffer to find changes
|
||||
* that occurred after the given epoch. If insufficient history is available
|
||||
* or the requested epoch is too old, returns RESET_VALUE indicating that
|
||||
* a complete state rebuild is required.
|
||||
*
|
||||
* @param sinceEpoch - The epoch from which to retrieve changes
|
||||
* @returns Array of diffs since the epoch, or RESET_VALUE if history is insufficient
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = new HistoryBuffer<string>(5)
|
||||
* buffer.pushEntry(0, 1, 'first')
|
||||
* buffer.pushEntry(1, 2, 'second')
|
||||
* const changes = buffer.getChangesSince(0) // ['first', 'second']
|
||||
* const recentChanges = buffer.getChangesSince(1) // ['second']
|
||||
* const tooOld = buffer.getChangesSince(-100) // RESET_VALUE
|
||||
* ```
|
||||
*/
|
||||
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 EMPTY_ARRAY
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
240
packages/state/src/lib/__tests__/ArraySet.test.ts
Normal file
240
packages/state/src/lib/__tests__/ArraySet.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { ARRAY_SIZE_THRESHOLD, ArraySet } from '../ArraySet'
|
||||
|
||||
// Tests for SPEC.md §16 (ArraySet, internal).
|
||||
// Rule IDs like [AS1] in test names refer to that document.
|
||||
|
||||
const get = <T>(set: ArraySet<T>) => {
|
||||
const s = new Set<T>()
|
||||
|
||||
set.visit((i) => s.add(i))
|
||||
return s
|
||||
}
|
||||
|
||||
describe(ArraySet, () => {
|
||||
it('[AS1] add and remove report whether they changed the set', () => {
|
||||
const as = new ArraySet<number>()
|
||||
|
||||
expect(as.add(1)).toBe(true)
|
||||
expect(as.add(1)).toBe(false)
|
||||
expect(as.remove(1)).toBe(true)
|
||||
expect(as.remove(1)).toBe(false)
|
||||
|
||||
// the same holds in set mode
|
||||
for (let i = 0; i < ARRAY_SIZE_THRESHOLD + 1; i++) {
|
||||
as.add(i)
|
||||
}
|
||||
expect(as.add(0)).toBe(false)
|
||||
expect(as.remove(0)).toBe(true)
|
||||
expect(as.remove(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('[AS2] has, size, isEmpty, visit, and iteration agree', () => {
|
||||
const as = new ArraySet<number>()
|
||||
|
||||
expect(as.isEmpty).toBe(true)
|
||||
expect(as.size()).toBe(0)
|
||||
expect([...as]).toEqual([])
|
||||
|
||||
as.add(1)
|
||||
as.add(2)
|
||||
as.add(3)
|
||||
|
||||
expect(as.isEmpty).toBe(false)
|
||||
expect(as.size()).toBe(3)
|
||||
expect(as.has(2)).toBe(true)
|
||||
expect(as.has(4)).toBe(false)
|
||||
expect(new Set([...as])).toEqual(new Set([1, 2, 3]))
|
||||
expect(get(as)).toEqual(new Set([1, 2, 3]))
|
||||
|
||||
as.clear()
|
||||
|
||||
expect(as.isEmpty).toBe(true)
|
||||
expect(as.size()).toBe(0)
|
||||
expect(as.has(1)).toBe(false)
|
||||
expect([...as]).toEqual([])
|
||||
})
|
||||
|
||||
it('[AS3] behaves identically across the array-to-set promotion boundary', () => {
|
||||
const as = new ArraySet<number>()
|
||||
const expected = new Set<number>()
|
||||
|
||||
// fill exactly to the threshold (array mode)
|
||||
for (let i = 0; i < ARRAY_SIZE_THRESHOLD; i++) {
|
||||
expect(as.add(i)).toBe(true)
|
||||
expected.add(i)
|
||||
}
|
||||
expect(as.size()).toBe(ARRAY_SIZE_THRESHOLD)
|
||||
expect(get(as)).toEqual(expected)
|
||||
|
||||
// one more promotes to set mode
|
||||
expect(as.add(ARRAY_SIZE_THRESHOLD)).toBe(true)
|
||||
expected.add(ARRAY_SIZE_THRESHOLD)
|
||||
|
||||
expect(as.size()).toBe(ARRAY_SIZE_THRESHOLD + 1)
|
||||
expect(get(as)).toEqual(expected)
|
||||
expect(new Set([...as])).toEqual(expected)
|
||||
|
||||
for (const value of expected) {
|
||||
expect(as.has(value)).toBe(true)
|
||||
}
|
||||
|
||||
// removal works the same after promotion
|
||||
for (const value of expected) {
|
||||
expect(as.remove(value)).toBe(true)
|
||||
}
|
||||
expect(as.isEmpty).toBe(true)
|
||||
|
||||
// clearing in set mode keeps working
|
||||
as.add(1)
|
||||
as.clear()
|
||||
expect(as.isEmpty).toBe(true)
|
||||
expect(as.add(1)).toBe(true)
|
||||
})
|
||||
|
||||
it('[AS4] a fresh ArraySet answers every query as empty and survives remove and clear', () => {
|
||||
const as = new ArraySet<number>()
|
||||
|
||||
expect(as.has(1)).toBe(false)
|
||||
expect(as.size()).toBe(0)
|
||||
expect(as.isEmpty).toBe(true)
|
||||
expect([...as]).toEqual([])
|
||||
expect(get(as)).toEqual(new Set())
|
||||
|
||||
expect(as.remove(1)).toBe(false)
|
||||
as.clear()
|
||||
|
||||
expect(as.isEmpty).toBe(true)
|
||||
expect(as.add(1)).toBe(true)
|
||||
expect(as.has(1)).toBe(true)
|
||||
expect(as.size()).toBe(1)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Enough candidates, and enough bias toward adds, that the set fills past the promotion
|
||||
// threshold rather than hovering below it in array mode forever.
|
||||
const nums = new Array(ARRAY_SIZE_THRESHOLD * 4).fill(0).map(() => Math.floor(r() * 100))
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const num = nums[Math.floor(r() * nums.length)]
|
||||
|
||||
const choice = r()
|
||||
try {
|
||||
if (choice < 0.55) {
|
||||
expect(as.add(num)).toBe(!s.has(num))
|
||||
s.add(num)
|
||||
} else if (choice < 0.95) {
|
||||
expect(as.remove(num)).toBe(s.has(num))
|
||||
s.delete(num)
|
||||
} else {
|
||||
as.clear()
|
||||
s.clear()
|
||||
}
|
||||
|
||||
expect(get(as)).toEqual(s)
|
||||
expect(new Set([...as])).toEqual(s)
|
||||
expect(as.size()).toBe(s.size)
|
||||
expect(as.isEmpty).toBe(s.size === 0)
|
||||
for (const n of nums) {
|
||||
expect(as.has(n)).toBe(s.has(n))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed on iteration', i, 'with seed', seed)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('fuzzing against a reference Set', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
472
packages/state/src/lib/__tests__/EffectScheduler.test.ts
Normal file
472
packages/state/src/lib/__tests__/EffectScheduler.test.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { computed } from '../Computed'
|
||||
import { EffectScheduler, react, reactor } from '../EffectScheduler'
|
||||
import { advanceGlobalEpoch, transact } from '../transactions'
|
||||
import { RESET_VALUE } from '../types'
|
||||
|
||||
// Tests for SPEC.md §9 (effects: EffectScheduler, react, reactor).
|
||||
// Rule IDs like [E3] in test names refer to that document.
|
||||
|
||||
describe('react (E)', () => {
|
||||
it('[E1] runs immediately, re-runs on changes, and stops when unsubscribed', () => {
|
||||
const a = atom('', 234)
|
||||
|
||||
let val = 0
|
||||
const stop = react('', () => {
|
||||
val = a.get()
|
||||
})
|
||||
|
||||
expect(val).toBe(234)
|
||||
|
||||
a.set(939)
|
||||
|
||||
expect(val).toBe(939)
|
||||
|
||||
stop()
|
||||
|
||||
a.set(2342)
|
||||
|
||||
expect(val).toBe(939)
|
||||
expect(a.get()).toBe(2342)
|
||||
})
|
||||
|
||||
it('[E1] detaches from its parents when stopped', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
const rfn = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
const stop = react('', rfn)
|
||||
|
||||
expect(a.children.isEmpty).toBe(false)
|
||||
|
||||
a.set(8)
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
|
||||
stop()
|
||||
|
||||
expect(a.children.isEmpty).toBe(true)
|
||||
|
||||
a.set(2)
|
||||
a.set(3)
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[E4] does not re-run when a parent is set to an equal value', () => {
|
||||
const a = atom('', 'x')
|
||||
const effect = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
const stop = react('', effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set('x')
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('[E4] does not re-run when a computed parent recomputes to an equal value', () => {
|
||||
const a = atom('', 1.2)
|
||||
const floored = computed('', () => Math.floor(a.get()))
|
||||
const effect = vi.fn(() => {
|
||||
floored.get()
|
||||
})
|
||||
const stop = react('', effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(1.5)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(2.3)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('[E5] passes the epoch of the previous run, enabling incremental effects', () => {
|
||||
const a = atom('', 1, { historyLength: 5, computeDiff: (a, b) => b - a })
|
||||
|
||||
const collected: Array<number[] | RESET_VALUE> = []
|
||||
const stop = react('', (lastReactedEpoch) => {
|
||||
collected.push(a.getDiffSince(lastReactedEpoch))
|
||||
})
|
||||
|
||||
// the first run has no previous epoch, so the effect must initialize from scratch
|
||||
expect(collected).toEqual([RESET_VALUE])
|
||||
|
||||
a.set(3)
|
||||
expect(collected).toEqual([RESET_VALUE, [+2]])
|
||||
|
||||
a.set(6)
|
||||
expect(collected).toEqual([RESET_VALUE, [+2], [+3]])
|
||||
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactor (E)', () => {
|
||||
it('[E2] can be started and stopped', () => {
|
||||
const a = atom('', 1)
|
||||
const r = reactor('', () => {
|
||||
a.get()
|
||||
})
|
||||
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('[E2] start() does not re-run the effect if nothing changed while stopped', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
const rfn = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
|
||||
const r = reactor('', rfn)
|
||||
r.start()
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
r.stop()
|
||||
|
||||
r.start()
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('[E2] start() re-runs the effect if a parent changed while stopped', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
const rfn = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
|
||||
const r = reactor('', rfn)
|
||||
r.start()
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
r.stop()
|
||||
a.set(2)
|
||||
|
||||
r.start()
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[E2] start({force: true}) re-runs the effect even if nothing has changed', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
const rfn = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
|
||||
const r = reactor('', rfn)
|
||||
r.start()
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
r.stop()
|
||||
|
||||
r.start({ force: true })
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[E3] runs once per state change, even when several of its parents changed', () => {
|
||||
const atomA = atom('', 1)
|
||||
const atomB = atom('', 1)
|
||||
|
||||
const rfn = vi.fn(() => {
|
||||
atomA.get()
|
||||
atomB.get()
|
||||
})
|
||||
const r = reactor('', rfn)
|
||||
|
||||
r.start()
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
transact(() => {
|
||||
atomA.set(2)
|
||||
atomB.set(2)
|
||||
})
|
||||
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[E9] maybeScheduleEffect is a no-op when the reactor is stopped', () => {
|
||||
const a = atom('', 1)
|
||||
const rfn = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
const r = reactor('', rfn)
|
||||
|
||||
r.scheduler.maybeScheduleEffect()
|
||||
|
||||
expect(rfn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('[E9] maybeScheduleEffect is a no-op when the parents have not changed', () => {
|
||||
const a = atom('', 1)
|
||||
const rfn = vi
|
||||
.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
.mockName('rfn')
|
||||
const r = reactor('', rfn)
|
||||
|
||||
r.start()
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
advanceGlobalEpoch()
|
||||
r.scheduler.maybeScheduleEffect()
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('[E2] start() inside a reaction phase runs the effect if a parent changed while stopped', () => {
|
||||
// Regression: re-attaching during a reaction used to make a stale computed parent "actively
|
||||
// listening" without refreshing it, and Computed's cache then served the stale value, so the
|
||||
// restarted reactor never ran and the computed stayed wrong until its ancestor changed again.
|
||||
const a = atom('a', 1)
|
||||
const c = computed('c', () => a.get() * 2)
|
||||
const seen: number[] = []
|
||||
const r = reactor('r', () => {
|
||||
seen.push(c.get())
|
||||
})
|
||||
r.start()
|
||||
expect(seen).toEqual([2])
|
||||
r.stop()
|
||||
|
||||
a.set(5) // nothing is listening to c now, so it goes stale
|
||||
|
||||
const trigger = atom('trigger', 0)
|
||||
react('outer', () => {
|
||||
if (trigger.get() === 1) r.start()
|
||||
})
|
||||
trigger.set(1) // r.start() runs inside this reaction phase
|
||||
|
||||
expect(seen).toEqual([2, 10])
|
||||
expect(c.get()).toBe(10)
|
||||
})
|
||||
|
||||
it('[E2][E9] an effect that captured no parents does not re-run on start() after unrelated changes', () => {
|
||||
const unrelated = atom('', 0)
|
||||
const rfn = vi.fn()
|
||||
const r = reactor('', rfn)
|
||||
|
||||
r.start()
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
r.stop()
|
||||
unrelated.set(1)
|
||||
r.start()
|
||||
// no parent changed (there are none), so there is nothing to react to
|
||||
expect(rfn).toHaveBeenCalledTimes(1)
|
||||
|
||||
r.start({ force: true })
|
||||
expect(rfn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom scheduling (E6, E7)', () => {
|
||||
it('[E6] a custom scheduleEffect decouples scheduling from execution', () => {
|
||||
const a = atom('', 1)
|
||||
const scheduled: Array<() => void> = []
|
||||
let runs = 0
|
||||
|
||||
react(
|
||||
'',
|
||||
() => {
|
||||
a.get()
|
||||
runs++
|
||||
},
|
||||
{
|
||||
scheduleEffect: (execute) => {
|
||||
scheduled.push(execute)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// the initial run of react() is also routed through scheduleEffect
|
||||
expect(scheduled).toHaveLength(1)
|
||||
expect(runs).toBe(0)
|
||||
|
||||
scheduled.shift()!()
|
||||
expect(runs).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(scheduled).toHaveLength(1)
|
||||
expect(runs).toBe(1)
|
||||
|
||||
scheduled.shift()!()
|
||||
expect(runs).toBe(2)
|
||||
})
|
||||
|
||||
it('[E6] reactor.start() with a custom scheduler only schedules, it does not execute', () => {
|
||||
let numSchedules = 0
|
||||
let numExecutes = 0
|
||||
|
||||
const r = reactor(
|
||||
'',
|
||||
() => {
|
||||
numExecutes++
|
||||
},
|
||||
{
|
||||
scheduleEffect: () => {
|
||||
numSchedules++
|
||||
},
|
||||
}
|
||||
)
|
||||
r.start()
|
||||
|
||||
expect(numSchedules).toBe(1)
|
||||
expect(numExecutes).toBe(0)
|
||||
})
|
||||
|
||||
it('[E6] scheduleCount counts scheduling events even if the effect never executes', () => {
|
||||
const a = atom('a', 1)
|
||||
let numReactions = 0
|
||||
let numSchedules = 0
|
||||
const scheduler = new EffectScheduler(
|
||||
'test',
|
||||
() => {
|
||||
a.get()
|
||||
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)
|
||||
})
|
||||
|
||||
it('[E7] a deferred execute callback is a no-op if the effect was detached in the meantime', () => {
|
||||
const a = atom('a', 1)
|
||||
const scheduled: Array<() => void> = []
|
||||
let runs = 0
|
||||
|
||||
const r = reactor(
|
||||
'',
|
||||
() => {
|
||||
a.get()
|
||||
runs++
|
||||
},
|
||||
{
|
||||
scheduleEffect: (execute) => {
|
||||
scheduled.push(execute)
|
||||
},
|
||||
}
|
||||
)
|
||||
r.start()
|
||||
scheduled.shift()!()
|
||||
expect(runs).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
expect(scheduled).toHaveLength(1)
|
||||
|
||||
r.stop()
|
||||
scheduled.shift()!()
|
||||
|
||||
expect(runs).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EffectScheduler attach/detach (E8)', () => {
|
||||
it('[E8] retains parents across detach/attach without re-running if nothing changed', () => {
|
||||
const a = atom('a', 1)
|
||||
let numReactions = 0
|
||||
const scheduler = new EffectScheduler('test', () => {
|
||||
a.get()
|
||||
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)
|
||||
})
|
||||
|
||||
it('[E8] does not observe changes while detached, but sees them after re-attaching and executing', () => {
|
||||
const a = atom('a', 1)
|
||||
let numReactions = 0
|
||||
const scheduler = new EffectScheduler('test', () => {
|
||||
a.get()
|
||||
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)
|
||||
})
|
||||
|
||||
it('[E8] attach() brings stale computed parents up to date before listening to them', () => {
|
||||
const a = atom('a', 1)
|
||||
const c = computed('c', () => a.get() * 2)
|
||||
const seen: number[] = []
|
||||
const scheduler = new EffectScheduler('test', () => {
|
||||
seen.push(c.get())
|
||||
})
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
scheduler.detach()
|
||||
a.set(5) // c is stale now
|
||||
|
||||
// the useStateTracking pattern: re-attach, then ask whether a run is needed, inside a reaction
|
||||
const trigger = atom('trigger', 0)
|
||||
react('outer', () => {
|
||||
if (trigger.get() === 1) {
|
||||
scheduler.attach()
|
||||
scheduler.maybeScheduleEffect()
|
||||
}
|
||||
})
|
||||
trigger.set(1)
|
||||
|
||||
expect(seen).toEqual([2, 10])
|
||||
})
|
||||
})
|
||||
76
packages/state/src/lib/__tests__/HistoryBuffer.test.ts
Normal file
76
packages/state/src/lib/__tests__/HistoryBuffer.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { HistoryBuffer } from '../HistoryBuffer'
|
||||
import { RESET_VALUE } from '../types'
|
||||
|
||||
// Tests for SPEC.md §17 (HistoryBuffer, internal).
|
||||
// Rule IDs like [HB2] in test names refer to that document.
|
||||
|
||||
describe('HistoryBuffer', () => {
|
||||
it('[HB2][HB3] 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('[HB2] returns RESET_VALUE when the buffer is empty', () => {
|
||||
const buf = new HistoryBuffer<string>(3)
|
||||
expect(buf.getChangesSince(0)).toEqual(RESET_VALUE)
|
||||
})
|
||||
|
||||
it('[HB1] treats an undefined diff like RESET_VALUE: it clears rather than leaving a gap', () => {
|
||||
const buf = new HistoryBuffer<string>(3)
|
||||
buf.pushEntry(0, 1, 'a')
|
||||
buf.pushEntry(1, 2, undefined as any)
|
||||
buf.pushEntry(2, 3, 'c')
|
||||
|
||||
// if the undefined entry had been skipped, this would be ['a', 'c'] with the 1 -> 2 change missing
|
||||
expect(buf.getChangesSince(0)).toEqual(RESET_VALUE)
|
||||
expect(buf.getChangesSince(1)).toEqual(RESET_VALUE)
|
||||
expect(buf.getChangesSince(2)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('[HB1] 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)
|
||||
})
|
||||
})
|
||||
203
packages/state/src/lib/__tests__/atom.test.ts
Normal file
203
packages/state/src/lib/__tests__/atom.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { atom } from '../Atom'
|
||||
import { computed } from '../Computed'
|
||||
import { react } from '../EffectScheduler'
|
||||
import { getGlobalEpoch } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §2 (the epoch clock), §3 (equality), and §4 (atoms).
|
||||
// Rule IDs like [EP3] in test names refer to that document.
|
||||
|
||||
describe('the epoch clock (EP)', () => {
|
||||
it('[EP1] is shared by all atoms', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('a', 1)
|
||||
const b = atom('b', 1)
|
||||
|
||||
a.set(2)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch + 1)
|
||||
|
||||
b.set(2)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch + 2)
|
||||
})
|
||||
|
||||
it('[EP2] does not advance when signals are created', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
atom('a', 3)
|
||||
computed('c', () => 1)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch)
|
||||
})
|
||||
|
||||
it('[EP3] advances by exactly one when an atom is set to a new value', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('a', 3)
|
||||
a.set(4)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch + 1)
|
||||
})
|
||||
|
||||
it('[EP4] does not advance when an atom is set to an equal value', () => {
|
||||
const a = atom('a', 3)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
a.set(3)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch)
|
||||
})
|
||||
|
||||
it('[EP5] is recorded on the atom as lastChangedEpoch when it changes', () => {
|
||||
const a = atom('a', 1)
|
||||
|
||||
a.set(2)
|
||||
const changedEpoch = getGlobalEpoch()
|
||||
expect(a.lastChangedEpoch).toBe(changedEpoch)
|
||||
|
||||
// other atoms changing does not move this atom's lastChangedEpoch
|
||||
const b = atom('b', 1)
|
||||
b.set(2)
|
||||
expect(a.lastChangedEpoch).toBe(changedEpoch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('equality (EQ)', () => {
|
||||
it('[EQ1] treats === and Object.is values as equal by default', () => {
|
||||
const value = { hello: true }
|
||||
const a = atom('a', value)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(value)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch)
|
||||
|
||||
const n = atom('n', NaN)
|
||||
n.set(NaN)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch)
|
||||
})
|
||||
|
||||
it('[EQ1] consults the old value’s equals method by default', () => {
|
||||
class Box {
|
||||
constructor(public value: number) {}
|
||||
equals(other: unknown) {
|
||||
return other instanceof Box && other.value === this.value
|
||||
}
|
||||
}
|
||||
|
||||
const original = new Box(1)
|
||||
const a = atom('a', original)
|
||||
|
||||
a.set(new Box(1))
|
||||
expect(a.get()).toBe(original)
|
||||
|
||||
const different = new Box(2)
|
||||
a.set(different)
|
||||
expect(a.get()).toBe(different)
|
||||
})
|
||||
|
||||
it('[EQ2] does not consult the new value’s equals method', () => {
|
||||
const oldValue = { x: 1 }
|
||||
const newValue = { x: 1, equals: () => true }
|
||||
|
||||
const a = atom('a', oldValue)
|
||||
a.set(newValue)
|
||||
|
||||
expect(a.get()).toBe(newValue)
|
||||
})
|
||||
|
||||
it('[EQ3] uses a custom isEqual instead of default equality when provided', () => {
|
||||
const foo = { hello: true }
|
||||
const bar = { hello: true }
|
||||
|
||||
const a = atom('a', foo)
|
||||
|
||||
a.set(bar)
|
||||
|
||||
expect(a.get()).toBe(bar)
|
||||
|
||||
const b = atom('b', foo, { isEqual: (a, b) => a.hello === b.hello })
|
||||
|
||||
b.set(bar)
|
||||
|
||||
expect(b.get()).toBe(foo)
|
||||
})
|
||||
|
||||
it('[EQ4] makes setting an equal value a complete no-op', () => {
|
||||
const initial = { x: 1 }
|
||||
const a = atom('a', initial, {
|
||||
isEqual: (a, b) => a.x === b.x,
|
||||
historyLength: 5,
|
||||
computeDiff: () => 'diff',
|
||||
})
|
||||
|
||||
const effect = vi.fn(() => {
|
||||
a.get()
|
||||
})
|
||||
const stop = react('r', effect)
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const lastChanged = a.lastChangedEpoch
|
||||
|
||||
a.set({ x: 1 })
|
||||
|
||||
expect(getGlobalEpoch()).toBe(startEpoch)
|
||||
expect(a.lastChangedEpoch).toBe(lastChanged)
|
||||
expect(a.get()).toBe(initial)
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([])
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('atoms (A)', () => {
|
||||
it('[A1] contain data', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
expect(a.get()).toBe(1)
|
||||
})
|
||||
|
||||
it('[A2] can be updated with set', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[A3] set returns the value of the atom after the call', () => {
|
||||
const a = atom('', 1)
|
||||
|
||||
expect(a.set(2)).toBe(2)
|
||||
// setting an equal value returns the unchanged current value
|
||||
expect(a.set(2)).toBe(2)
|
||||
})
|
||||
|
||||
it('[A4] update(fn) sets the atom to fn(currentValue)', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('', 1)
|
||||
|
||||
a.update((value) => value + 1)
|
||||
|
||||
expect(a.get()).toBe(2)
|
||||
expect(getGlobalEpoch()).toBe(startEpoch + 1)
|
||||
})
|
||||
|
||||
it('[A5] __unsafe__getWithoutCapture returns the value without capturing a dependency', () => {
|
||||
const a = atom('a', 1)
|
||||
const effect = vi.fn(() => {
|
||||
a.__unsafe__getWithoutCapture()
|
||||
})
|
||||
const stop = react('r', effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('[A6] are independent of each other', () => {
|
||||
const a = atom('a', 1)
|
||||
const b = atom('b', 10)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(b.get()).toBe(10)
|
||||
expect(b.lastChangedEpoch).toBeLessThan(a.lastChangedEpoch)
|
||||
})
|
||||
})
|
||||
359
packages/state/src/lib/__tests__/capture.test.ts
Normal file
359
packages/state/src/lib/__tests__/capture.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { ArraySet } from '../ArraySet'
|
||||
import { atom } from '../Atom'
|
||||
import {
|
||||
maybeCaptureParent,
|
||||
startCapturingParents,
|
||||
stopCapturingParents,
|
||||
unsafe__withoutCapture,
|
||||
} from '../capture'
|
||||
import { computed } from '../Computed'
|
||||
import { react, reactor } from '../EffectScheduler'
|
||||
import { advanceGlobalEpoch, getGlobalEpoch } from '../transactions'
|
||||
import { Child } from '../types'
|
||||
|
||||
// Tests for SPEC.md §5 (dependency capture).
|
||||
// Rule IDs like [CAP2] in test names refer to that document.
|
||||
|
||||
describe('dependency capture (CAP)', () => {
|
||||
it('[CAP1][CAP3] captures each dereferenced signal once, in first-dereference order', () => {
|
||||
const a = atom('a', 1)
|
||||
const b = atom('b', 2)
|
||||
|
||||
const r = reactor('r', () => {
|
||||
a.get()
|
||||
a.get()
|
||||
b.get()
|
||||
a.get()
|
||||
})
|
||||
r.start()
|
||||
|
||||
expect(r.scheduler.parents).toEqual([a, b])
|
||||
r.stop()
|
||||
})
|
||||
|
||||
it('[CAP2] the parent set is exactly the signals dereferenced in the latest run', () => {
|
||||
const which = atom('which', true)
|
||||
const b = atom('b', 1)
|
||||
const c = atom('c', 2)
|
||||
|
||||
let runs = 0
|
||||
const stop = react('r', () => {
|
||||
runs++
|
||||
if (which.get()) {
|
||||
b.get()
|
||||
} else {
|
||||
c.get()
|
||||
}
|
||||
})
|
||||
expect(runs).toBe(1)
|
||||
|
||||
// c is not currently a parent
|
||||
c.set(3)
|
||||
expect(runs).toBe(1)
|
||||
|
||||
which.set(false)
|
||||
expect(runs).toBe(2)
|
||||
|
||||
// b is no longer a parent after the latest run
|
||||
b.set(10)
|
||||
expect(runs).toBe(2)
|
||||
|
||||
// c now is
|
||||
c.set(4)
|
||||
expect(runs).toBe(3)
|
||||
|
||||
stop()
|
||||
})
|
||||
|
||||
it('[CAP4] capture contexts nest: an effect captures a computed, not its parents', () => {
|
||||
const a = atom('a', 1)
|
||||
const double = computed('double', () => a.get() * 2)
|
||||
|
||||
const r = reactor('r', () => {
|
||||
double.get()
|
||||
})
|
||||
r.start()
|
||||
|
||||
expect(r.scheduler.parents).toEqual([double])
|
||||
expect(double.parents).toEqual([a])
|
||||
r.stop()
|
||||
})
|
||||
|
||||
it('[CAP7] liveness propagates transitively up the graph', () => {
|
||||
const a = atom('a', 1)
|
||||
const c = computed('c', () => a.get())
|
||||
|
||||
// lazily dereferencing a computed does not attach anything
|
||||
c.get()
|
||||
expect(a.children.isEmpty).toBe(true)
|
||||
expect(c.children.isEmpty).toBe(true)
|
||||
|
||||
const r = reactor('r', () => {
|
||||
c.get()
|
||||
})
|
||||
r.start()
|
||||
|
||||
expect(a.children.isEmpty).toBe(false)
|
||||
expect(c.children.isEmpty).toBe(false)
|
||||
|
||||
r.stop()
|
||||
|
||||
expect(a.children.isEmpty).toBe(true)
|
||||
expect(c.children.isEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it('[CAP6] dereferencing signals outside a capture context captures nothing', () => {
|
||||
expect(() => {
|
||||
maybeCaptureParent(atom('', 1))
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsafe__withoutCapture (CAP5)', () => {
|
||||
it('[CAP5] short-circuits the current capture frame in a computed', () => {
|
||||
const atomA = atom('a', 1)
|
||||
const atomB = atom('b', 1)
|
||||
const atomC = atom('c', 1)
|
||||
|
||||
const child = computed('', () => {
|
||||
return atomA.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
|
||||
})
|
||||
|
||||
let lastValue: number | undefined
|
||||
let numReactions = 0
|
||||
|
||||
react('', () => {
|
||||
numReactions++
|
||||
lastValue = child.get()
|
||||
})
|
||||
|
||||
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('[CAP5] short-circuits the current capture frame in an effect', () => {
|
||||
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.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
|
||||
})
|
||||
|
||||
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('[CAP5] restores the capture context even if the wrapped function throws', () => {
|
||||
const a = atom('a', 1)
|
||||
let runs = 0
|
||||
|
||||
const stop = react('r', () => {
|
||||
runs++
|
||||
try {
|
||||
unsafe__withoutCapture(() => {
|
||||
throw new Error('oops')
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// captured only if the capture context was restored
|
||||
a.get()
|
||||
})
|
||||
|
||||
expect(runs).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
// Internal contract of startCapturingParents / maybeCaptureParent / stopCapturingParents [CAP8].
|
||||
|
||||
const emptyChild = (props: Partial<Child> = {}) =>
|
||||
({
|
||||
parentEpochs: [],
|
||||
parents: [],
|
||||
parentSet: new ArraySet(),
|
||||
isActivelyListening: false,
|
||||
lastTraversedEpoch: 0,
|
||||
...props,
|
||||
}) as Child
|
||||
|
||||
describe('capturing parents (CAP8, internal)', () => {
|
||||
it('[CAP8] can be started and stopped', () => {
|
||||
const a = atom('', 1)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
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('[CAP8] can handle several parents', () => {
|
||||
const atomA = atom('', 1)
|
||||
const atomAEpoch = getGlobalEpoch()
|
||||
advanceGlobalEpoch() // let's say time has passed
|
||||
const atomB = atom('', 1)
|
||||
const atomBEpoch = getGlobalEpoch()
|
||||
advanceGlobalEpoch() // let's say time has passed
|
||||
const atomC = atom('', 1)
|
||||
const atomCEpoch = getGlobalEpoch()
|
||||
|
||||
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('[CAP8] 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('[CAP8] will shrink the parent arrays if the number of captured parents shrinks', () => {
|
||||
const atomA = atom('', 1)
|
||||
const atomAEpoch = getGlobalEpoch()
|
||||
advanceGlobalEpoch() // let's say time has passed
|
||||
const atomB = atom('', 1)
|
||||
const atomBEpoch = getGlobalEpoch()
|
||||
advanceGlobalEpoch() // let's say time has passed
|
||||
const atomC = atom('', 1)
|
||||
const atomCEpoch = getGlobalEpoch()
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
411
packages/state/src/lib/__tests__/computed.test.ts
Normal file
411
packages/state/src/lib/__tests__/computed.test.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { Computed, _Computed, computed, getComputedInstance, isUninitialized } from '../Computed'
|
||||
import { GLOBAL_START_EPOCH } from '../constants'
|
||||
import { react } from '../EffectScheduler'
|
||||
import { advanceGlobalEpoch, getGlobalEpoch } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §6 (computed signals).
|
||||
// Rule IDs like [C2] in test names refer to that document.
|
||||
|
||||
function getLastCheckedEpoch(derivation: Computed<any>): number {
|
||||
return (derivation as any).lastCheckedEpoch
|
||||
}
|
||||
|
||||
describe('computed signals (C)', () => {
|
||||
it('[C1] are lazy: the compute function does not run until the first get', () => {
|
||||
const a = atom('a', 1)
|
||||
const double = vi.fn(() => a.get() * 2)
|
||||
const derivation = computed('double', double)
|
||||
|
||||
expect(double).toHaveBeenCalledTimes(0)
|
||||
|
||||
a.set(2)
|
||||
expect(double).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(derivation.get()).toBe(4)
|
||||
expect(double).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('[C2] recompute only when a parent has changed', () => {
|
||||
const a = atom('', 1)
|
||||
const double = vi.fn(() => a.get() * 2)
|
||||
const derivation = computed('', double)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(double).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
||||
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(double).toHaveBeenCalledTimes(1)
|
||||
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
||||
|
||||
a.set(2)
|
||||
const nextEpoch = getGlobalEpoch()
|
||||
expect(nextEpoch > startEpoch).toBe(true)
|
||||
|
||||
// lazy: no recomputation until deref
|
||||
expect(double).toHaveBeenCalledTimes(1)
|
||||
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
||||
expect(derivation.get()).toBe(4)
|
||||
|
||||
expect(double).toHaveBeenCalledTimes(2)
|
||||
expect(derivation.lastChangedEpoch).toBe(nextEpoch)
|
||||
|
||||
// changing an unrelated atom has no effect
|
||||
const unrelatedAtom = atom('', 1)
|
||||
unrelatedAtom.set(2)
|
||||
unrelatedAtom.set(3)
|
||||
|
||||
expect(derivation.get()).toBe(4)
|
||||
expect(double).toHaveBeenCalledTimes(2)
|
||||
expect(derivation.lastChangedEpoch).toBe(nextEpoch)
|
||||
})
|
||||
|
||||
it('[C2] update their lastCheckedEpoch without recomputing when the epoch advances for unrelated reasons', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('', 1)
|
||||
|
||||
const double = vi.fn(() => a.get() * 2)
|
||||
const derivation = computed('', double)
|
||||
|
||||
derivation.get()
|
||||
|
||||
expect(getLastCheckedEpoch(derivation)).toEqual(startEpoch)
|
||||
|
||||
advanceGlobalEpoch()
|
||||
derivation.get()
|
||||
|
||||
expect(getLastCheckedEpoch(derivation)).toBeGreaterThan(startEpoch)
|
||||
|
||||
expect(double).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('[C3] never recompute if the first execution captured no parents', () => {
|
||||
const derive = vi.fn(() => 1)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const derivation = computed('', derive)
|
||||
|
||||
expect(derive).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(derivation.get()).toBe(1)
|
||||
expect(derivation.get()).toBe(1)
|
||||
|
||||
expect(derive).toHaveBeenCalledTimes(1)
|
||||
|
||||
advanceGlobalEpoch()
|
||||
advanceGlobalEpoch()
|
||||
|
||||
expect(derivation.get()).toBe(1)
|
||||
expect(derivation.get()).toBe(1)
|
||||
|
||||
expect(derive).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(derivation.parents.length).toBe(0)
|
||||
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
||||
})
|
||||
|
||||
it('[C4] pass the previous value and the last-computed epoch to the compute function', () => {
|
||||
const a = atom('a', 1)
|
||||
const calls: Array<[unknown, number]> = []
|
||||
const derivation = computed('', (prev, lastComputedEpoch) => {
|
||||
calls.push([prev, lastComputedEpoch])
|
||||
return a.get() * 2
|
||||
})
|
||||
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(isUninitialized(calls[0][0])).toBe(true)
|
||||
expect(calls[0][1]).toBe(GLOBAL_START_EPOCH)
|
||||
|
||||
const firstComputedEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(derivation.get()).toBe(4)
|
||||
expect(isUninitialized(calls[1][0])).toBe(false)
|
||||
expect(calls[1][0]).toBe(2)
|
||||
expect(calls[1][1]).toBe(firstComputedEpoch)
|
||||
})
|
||||
|
||||
it('[C5] keep the previous value and epoch when recomputation produces an equal value', () => {
|
||||
const a = atom('a', 1.2)
|
||||
const floored = computed('floored', () => Math.floor(a.get()))
|
||||
|
||||
expect(floored.get()).toBe(1)
|
||||
const changedEpoch = floored.lastChangedEpoch
|
||||
|
||||
a.set(1.9)
|
||||
|
||||
expect(floored.get()).toBe(1)
|
||||
expect(floored.lastChangedEpoch).toBe(changedEpoch)
|
||||
|
||||
a.set(2.3)
|
||||
|
||||
expect(floored.get()).toBe(2)
|
||||
expect(floored.lastChangedEpoch).toBeGreaterThan(changedEpoch)
|
||||
})
|
||||
|
||||
it('[C5] never invoke isEqual for the first computation', () => {
|
||||
const isEqual = vi.fn((a, b) => a === b)
|
||||
|
||||
const a = atom('a', 1)
|
||||
const b = computed('b', () => a.get() * 2, { isEqual })
|
||||
|
||||
expect(b.get()).toBe(2)
|
||||
expect(isEqual).not.toHaveBeenCalled()
|
||||
expect(b.get()).toBe(2)
|
||||
expect(isEqual).not.toHaveBeenCalled()
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(b.get()).toBe(4)
|
||||
expect(isEqual).toHaveBeenCalledTimes(1)
|
||||
expect(b.get()).toBe(4)
|
||||
expect(isEqual).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('[C6] stop propagation early in chains when an intermediate value does not change', () => {
|
||||
const a = atom('a', 1.2)
|
||||
const floored = computed('floored', () => Math.floor(a.get()))
|
||||
const tens = vi.fn(() => floored.get() * 10)
|
||||
const derivation = computed('tens', tens)
|
||||
|
||||
expect(derivation.get()).toBe(10)
|
||||
expect(tens).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(1.5)
|
||||
|
||||
expect(derivation.get()).toBe(10)
|
||||
expect(tens).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(2.5)
|
||||
|
||||
expect(derivation.get()).toBe(20)
|
||||
expect(tens).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[C7] isActivelyListening is true exactly when something is listening downstream', () => {
|
||||
const a = atom('a', 1)
|
||||
const c = computed('c', () => a.get())
|
||||
|
||||
expect(c.isActivelyListening).toBe(false)
|
||||
|
||||
c.get()
|
||||
expect(c.isActivelyListening).toBe(false)
|
||||
|
||||
const stop = react('r', () => {
|
||||
c.get()
|
||||
})
|
||||
expect(c.isActivelyListening).toBe(true)
|
||||
|
||||
stop()
|
||||
expect(c.isActivelyListening).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the computed decorator (C8, C9, C10)', () => {
|
||||
it('[C8] makes a class method behave as a cached, reactive computed', () => {
|
||||
const compute = vi.fn(function (this: Foo) {
|
||||
return this.a.get() * 2
|
||||
})
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
@computed
|
||||
getB() {
|
||||
return compute.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
const foo = new Foo()
|
||||
|
||||
expect(foo.getB()).toBe(2)
|
||||
expect(foo.getB()).toBe(2)
|
||||
expect(compute).toHaveBeenCalledTimes(1)
|
||||
|
||||
foo.a.set(2)
|
||||
|
||||
expect(foo.getB()).toBe(4)
|
||||
expect(compute).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[C8] honors options passed to the decorator', () => {
|
||||
let numComputations = 0
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
|
||||
@computed({ isEqual: (a, b) => a.b === b.b })
|
||||
getB() {
|
||||
numComputations++
|
||||
return { b: this.a.get() * this.a.get() }
|
||||
}
|
||||
}
|
||||
|
||||
const foo = new Foo()
|
||||
|
||||
const firstVal = foo.getB()
|
||||
expect(firstVal).toEqual({ b: 1 })
|
||||
|
||||
foo.a.set(-1)
|
||||
|
||||
const secondVal = foo.getB()
|
||||
expect(secondVal).toEqual({ b: 1 })
|
||||
|
||||
// [EQ4] the previous value object is retained when the new value is equal
|
||||
expect(firstVal).toBe(secondVal)
|
||||
expect(numComputations).toBe(2)
|
||||
})
|
||||
|
||||
it('[C8] creates a separate computed per instance', () => {
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
@computed
|
||||
getB() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
|
||||
const foo1 = new Foo()
|
||||
const foo2 = new Foo()
|
||||
|
||||
foo1.a.set(10)
|
||||
|
||||
expect(foo1.getB()).toBe(20)
|
||||
expect(foo2.getB()).toBe(2)
|
||||
})
|
||||
|
||||
it('[C9] getComputedInstance retrieves the underlying computed, creating it on demand', () => {
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
|
||||
@computed
|
||||
getB() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
|
||||
const foo = new Foo()
|
||||
|
||||
// the method has not been called yet
|
||||
const bInst = getComputedInstance(foo, 'getB')
|
||||
|
||||
expect(bInst).toBeDefined()
|
||||
expect(bInst).toBeInstanceOf(_Computed)
|
||||
expect(bInst.get()).toBe(2)
|
||||
|
||||
foo.a.set(2)
|
||||
expect(bInst.get()).toBe(4)
|
||||
})
|
||||
|
||||
it('[C10] works on getters (legacy decorators) but logs a one-time deprecation warning', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
@computed
|
||||
get b() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0][0]).toContain('deprecated')
|
||||
|
||||
const foo = new Foo()
|
||||
expect(foo.b).toBe(2)
|
||||
foo.a.set(2)
|
||||
expect(foo.b).toBe(4)
|
||||
|
||||
// the warning is logged once per process, not once per class
|
||||
class Bar {
|
||||
a = atom('a', 1)
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
@computed
|
||||
get b() {
|
||||
return this.a.get()
|
||||
}
|
||||
}
|
||||
expect(new Bar().b).toBe(1)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('[C8] can be applied as a factory with no options', () => {
|
||||
// the options argument is optional, so `@computed()` must work like `@computed`
|
||||
class Foo {
|
||||
a = atom('a', 1)
|
||||
@computed()
|
||||
getB() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
|
||||
const foo = new Foo()
|
||||
expect(foo.getB()).toBe(2)
|
||||
foo.a.set(2)
|
||||
expect(foo.getB()).toBe(4)
|
||||
})
|
||||
|
||||
it('[C8] a subclass override can call the superclass computed via super', () => {
|
||||
// each decoration stores its computed under its own key; keying by method name made the
|
||||
// super call re-enter the subclass's own computed and overflow the stack
|
||||
class Base {
|
||||
a = atom('a', 1)
|
||||
@computed
|
||||
getB() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
class Sub extends Base {
|
||||
@computed
|
||||
override getB() {
|
||||
return super.getB() + 1
|
||||
}
|
||||
}
|
||||
|
||||
const sub = new Sub()
|
||||
expect(sub.getB()).toBe(3)
|
||||
sub.a.set(2)
|
||||
expect(sub.getB()).toBe(5)
|
||||
expect(new Base().getB()).toBe(2)
|
||||
})
|
||||
|
||||
it('[C9] getComputedInstance resolves the nearest decorated method on the prototype chain', () => {
|
||||
class Base {
|
||||
a = atom('a', 1)
|
||||
@computed
|
||||
getB() {
|
||||
return this.a.get() * 2
|
||||
}
|
||||
}
|
||||
class Sub extends Base {
|
||||
@computed
|
||||
override getB() {
|
||||
return super.getB() + 1
|
||||
}
|
||||
}
|
||||
class PlainSub extends Base {
|
||||
override getB() {
|
||||
return super.getB() + 100
|
||||
}
|
||||
}
|
||||
|
||||
const sub = new Sub()
|
||||
const subInst = getComputedInstance(sub, 'getB')
|
||||
expect(subInst.get()).toBe(3)
|
||||
sub.a.set(2)
|
||||
expect(subInst.get()).toBe(5)
|
||||
|
||||
// a plain (undecorated) override still exposes the superclass's computed
|
||||
const plain = new PlainSub()
|
||||
expect(getComputedInstance(plain, 'getB').get()).toBe(2)
|
||||
|
||||
// the returned type unwraps the method's return type
|
||||
const n: number = getComputedInstance(new Base(), 'getB').get()
|
||||
expect(n).toBe(2)
|
||||
})
|
||||
})
|
||||
84
packages/state/src/lib/__tests__/debug.test.ts
Normal file
84
packages/state/src/lib/__tests__/debug.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { whyAmIRunning } from '../capture'
|
||||
import { computed } from '../Computed'
|
||||
import { react, reactor } from '../EffectScheduler'
|
||||
|
||||
// Tests for SPEC.md §13 (debugging aids).
|
||||
// Rule IDs like [D2] in test names refer to that document.
|
||||
|
||||
describe('whyAmIRunning (D)', () => {
|
||||
it('[D2] throws when called outside of a reactive context', () => {
|
||||
expect(() => whyAmIRunning()).toThrow('whyAmIRunning() called outside of a reactive context')
|
||||
})
|
||||
|
||||
it('[D3] logs which ancestor atoms caused an effect to run', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const name = atom('name', 'Bob')
|
||||
const stop = react('greeting', () => {
|
||||
whyAmIRunning()
|
||||
name.get()
|
||||
})
|
||||
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
|
||||
name.set('Alice')
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(log.mock.calls[0][0]).toContain('Effect(greeting) is executing because:')
|
||||
expect(log.mock.calls[0][0]).toContain('Atom(name) changed')
|
||||
|
||||
stop()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('[D3] logs which ancestors caused a computed to recompute, including nested computeds', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const a = atom('a', 1)
|
||||
const double = computed('double', () => a.get() * 2)
|
||||
const quadruple = computed('quadruple', () => {
|
||||
whyAmIRunning()
|
||||
return double.get() * 2
|
||||
})
|
||||
|
||||
const stop = react('r', () => {
|
||||
quadruple.get()
|
||||
})
|
||||
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(log.mock.calls[0][0]).toContain('Computed(quadruple) is recomputing because:')
|
||||
expect(log.mock.calls[0][0]).toContain('Computed(double) changed')
|
||||
expect(log.mock.calls[0][0]).toContain('Atom(a) changed')
|
||||
|
||||
stop()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('[D3] logs that an effect was executed manually when no ancestors changed', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const a = atom('a', 1)
|
||||
const r = reactor('manual', () => {
|
||||
whyAmIRunning()
|
||||
a.get()
|
||||
})
|
||||
|
||||
r.start()
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
|
||||
r.stop()
|
||||
r.start({ force: true })
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(log.mock.calls[0][0]).toContain('Effect(manual) was executed manually.')
|
||||
|
||||
r.stop()
|
||||
log.mockRestore()
|
||||
})
|
||||
})
|
||||
255
packages/state/src/lib/__tests__/deferAsyncEffects.test.ts
Normal file
255
packages/state/src/lib/__tests__/deferAsyncEffects.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { promiseWithResolve, sleep } from '@tldraw/utils'
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { computed } from '../Computed'
|
||||
import { react } from '../EffectScheduler'
|
||||
import { deferAsyncEffects, transact, transaction } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §12 (async transactions: deferAsyncEffects).
|
||||
// Rule IDs like [AT3] in test names refer to that document.
|
||||
|
||||
describe('deferAsyncEffects (AT)', () => {
|
||||
it('[AT1] defers effects until the async transaction commits', async () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
const effectCalls = vi.fn()
|
||||
|
||||
react('', () => {
|
||||
a.get()
|
||||
b.get()
|
||||
effectCalls()
|
||||
})
|
||||
|
||||
expect(effectCalls).toHaveBeenCalledTimes(1)
|
||||
|
||||
const txPromise = deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
expect(effectCalls).toHaveBeenCalledTimes(1) // no effect yet
|
||||
await sleep(1)
|
||||
b.set(2)
|
||||
expect(effectCalls).toHaveBeenCalledTimes(1) // still no effect
|
||||
})
|
||||
|
||||
await txPromise
|
||||
expect(effectCalls).toHaveBeenCalledTimes(2) // effect runs after commit
|
||||
})
|
||||
|
||||
it('[AT1] keeps computed signals up to date during the async transaction', async () => {
|
||||
const a = atom('', 1)
|
||||
const doubled = computed('', () => a.get() * 2)
|
||||
|
||||
expect(doubled.get()).toBe(2)
|
||||
|
||||
await deferAsyncEffects(async () => {
|
||||
a.set(5)
|
||||
// computed should update during transaction
|
||||
expect(doubled.get()).toBe(10)
|
||||
await sleep(1)
|
||||
a.set(10)
|
||||
expect(doubled.get()).toBe(20)
|
||||
})
|
||||
|
||||
// computed should update after commit
|
||||
expect(doubled.get()).toBe(20)
|
||||
expect(a.get()).toBe(10)
|
||||
})
|
||||
|
||||
it('[AT2] throws if kicked off during a sync transaction', async () => {
|
||||
const a = atom('', 0)
|
||||
let txp: any = null
|
||||
transact(() => {
|
||||
txp = deferAsyncEffects(async () => {
|
||||
expect(a.get()).toBe(1)
|
||||
a.set(2)
|
||||
})
|
||||
a.set(1)
|
||||
})
|
||||
|
||||
await expect(txp).rejects.toMatchInlineSnapshot(
|
||||
`[Error: deferAsyncEffects cannot be called during a sync transaction]`
|
||||
)
|
||||
})
|
||||
|
||||
it('[AT2] allows sync transactions inside the async body', async () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
await deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
transaction(() => {
|
||||
a.set(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[AT2] allows transact inside the async body', async () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
await deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
transact(() => {
|
||||
a.set(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[AT3] rolls back all changes on exception', async () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
await expect(
|
||||
deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
b.set(2)
|
||||
throw new Error('test error')
|
||||
})
|
||||
).rejects.toThrow('test error')
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(0)
|
||||
})
|
||||
|
||||
it('[AT3][T6] effects never observe partially restored values when an async transaction aborts', async () => {
|
||||
// Regression: the rollback used to run after the async transaction had been cleared, so each
|
||||
// restoring set flushed effects on its own and they saw one atom restored but not the other.
|
||||
const a = atom('', 1)
|
||||
const b = atom('', 1)
|
||||
const seen: string[] = []
|
||||
react('', () => {
|
||||
seen.push(`${a.get()},${b.get()}`)
|
||||
})
|
||||
seen.length = 0
|
||||
|
||||
await expect(
|
||||
deferAsyncEffects(async () => {
|
||||
a.set(2)
|
||||
b.set(2)
|
||||
await sleep(1)
|
||||
throw new Error('boom')
|
||||
})
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
expect(seen).toEqual(['1,1'])
|
||||
})
|
||||
|
||||
it('[AT3][AT4] rolls back everything when a nested async transaction throws', async () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
await expect(
|
||||
deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
|
||||
await deferAsyncEffects(async () => {
|
||||
b.set(2)
|
||||
throw new Error('inner error')
|
||||
})
|
||||
})
|
||||
).rejects.toThrow('inner error')
|
||||
|
||||
expect(a.get()).toBe(0) // all changes should be rolled back
|
||||
expect(b.get()).toBe(0)
|
||||
})
|
||||
|
||||
it('[AT4] nested async transactions join the outer one', async () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
await deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
await deferAsyncEffects(async () => {
|
||||
a.set(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[AT4] concurrent async transactions are merged', async () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
const results: number[] = []
|
||||
|
||||
const tx1 = deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
await sleep(10)
|
||||
results.push(a.get())
|
||||
return 'tx1'
|
||||
})
|
||||
|
||||
const tx2 = deferAsyncEffects(async () => {
|
||||
b.set(2)
|
||||
await sleep(5)
|
||||
results.push(b.get())
|
||||
return 'tx2'
|
||||
})
|
||||
|
||||
const [result1, result2] = await Promise.all([tx1, tx2])
|
||||
|
||||
expect(result1).toBe('tx1')
|
||||
expect(result2).toBe('tx2')
|
||||
expect(a.get()).toBe(1)
|
||||
expect(b.get()).toBe(2)
|
||||
expect(results).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('[AT4] overlapping transactions leak state to each other but group their effects', async () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
let txp = null
|
||||
|
||||
const p = deferAsyncEffects(async () => {
|
||||
a.set(1)
|
||||
const x = promiseWithResolve()
|
||||
txp = deferAsyncEffects(async () => {
|
||||
a.set(2)
|
||||
x.resolve(null)
|
||||
await sleep(10)
|
||||
a.set(3)
|
||||
return 'inner'
|
||||
})
|
||||
await x
|
||||
// inner transactions leak, this can't be avoided without AsyncContext
|
||||
// but at least we can group effects.
|
||||
expect(a.get()).toBe(2)
|
||||
return 'outer'
|
||||
})
|
||||
|
||||
await expect(p).resolves.toBe('outer')
|
||||
await expect(txp).resolves.toBe('inner')
|
||||
expect(a.get()).toBe(3)
|
||||
})
|
||||
|
||||
it('[AT5] waits for the reaction phase to finish if kicked off during a reaction', async () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
let txp: any = null
|
||||
|
||||
react('', () => {
|
||||
a.get()
|
||||
txp = deferAsyncEffects(async () => {
|
||||
await sleep(1)
|
||||
b.set(a.get() + 1)
|
||||
})
|
||||
})
|
||||
|
||||
await txp
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(1)
|
||||
|
||||
a.set(1)
|
||||
|
||||
await txp
|
||||
|
||||
expect(a.get()).toBe(1)
|
||||
expect(b.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[AT6] resolves to the return value of the function', async () => {
|
||||
await expect(deferAsyncEffects(async () => 'value')).resolves.toBe('value')
|
||||
})
|
||||
})
|
||||
217
packages/state/src/lib/__tests__/errors.test.ts
Normal file
217
packages/state/src/lib/__tests__/errors.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { atom } from '../Atom'
|
||||
import { computed, isUninitialized } from '../Computed'
|
||||
import { EffectScheduler, react } from '../EffectScheduler'
|
||||
import { haveParentsChanged } from '../helpers'
|
||||
import { getGlobalEpoch, transact } from '../transactions'
|
||||
import { RESET_VALUE } from '../types'
|
||||
|
||||
// Tests for SPEC.md §7 (errors in computed signals) and rule CE6 (effects that throw).
|
||||
// Rule IDs like [CE1] in test names refer to that document.
|
||||
|
||||
describe('computed signals that throw (CE)', () => {
|
||||
it('[CE1] cache thrown values until a parent changes', () => {
|
||||
let numComputations = 0
|
||||
const a = atom('', 1)
|
||||
const b = computed('', () => {
|
||||
numComputations++
|
||||
if (a.get() === 2) throw new Error('test')
|
||||
return a.get()
|
||||
})
|
||||
|
||||
expect(b.get()).toBe(1)
|
||||
expect(numComputations).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(numComputations).toBe(2)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(numComputations).toBe(2)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(numComputations).toBe(2)
|
||||
|
||||
a.set(3)
|
||||
|
||||
expect(b.get()).toBe(3)
|
||||
expect(b.get()).toBe(3)
|
||||
})
|
||||
|
||||
it('[CE1] cache a throw from the very first computation too', () => {
|
||||
let numComputations = 0
|
||||
const a = atom('', 1)
|
||||
const b = computed('', () => {
|
||||
numComputations++
|
||||
if (a.get() === 1) throw new Error('test')
|
||||
return a.get()
|
||||
})
|
||||
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(numComputations).toBe(1)
|
||||
|
||||
// an unrelated change doesn't re-run it either
|
||||
atom('', 0).set(1)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(numComputations).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
expect(b.get()).toBe(2)
|
||||
expect(numComputations).toBe(2)
|
||||
})
|
||||
|
||||
it('[CE2] entering the error state notifies effects, but consecutive errors do not', () => {
|
||||
const a = atom('', 1)
|
||||
let numComputations = 0
|
||||
const b = computed('', () => {
|
||||
numComputations++
|
||||
if (a.get() % 2 === 0) throw new Error('test')
|
||||
return a.get()
|
||||
})
|
||||
|
||||
let numReactions = 0
|
||||
react('', () => {
|
||||
try {
|
||||
b.get()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
numReactions++
|
||||
})
|
||||
expect(numReactions).toBe(1)
|
||||
expect(numComputations).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
// entering the error state is a change
|
||||
expect(numReactions).toBe(2)
|
||||
expect(numComputations).toBe(2)
|
||||
|
||||
a.set(4)
|
||||
|
||||
// erroring again while already in the error state is not a change
|
||||
expect(numComputations).toBe(3)
|
||||
expect(numReactions).toBe(2)
|
||||
|
||||
a.set(3)
|
||||
|
||||
// recovering is a change
|
||||
expect(numComputations).toBe(4)
|
||||
expect(numReactions).toBe(3)
|
||||
})
|
||||
|
||||
it('[CE3] discard the previous value: recovery receives UNINITIALIZED as previousValue', () => {
|
||||
const a = atom('', 1)
|
||||
const previousValues: unknown[] = []
|
||||
const b = computed('', (prev) => {
|
||||
previousValues.push(prev)
|
||||
if (a.get() === 2) throw new Error('test')
|
||||
return a.get()
|
||||
})
|
||||
|
||||
expect(b.get()).toBe(1)
|
||||
|
||||
a.set(2)
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
|
||||
a.set(3)
|
||||
expect(b.get()).toBe(3)
|
||||
|
||||
expect(previousValues.map(isUninitialized)).toEqual([true, false, true])
|
||||
expect(previousValues[1]).toBe(1)
|
||||
})
|
||||
|
||||
it('[CE4] clear the history buffer when an error is thrown', () => {
|
||||
const a = atom('', 1)
|
||||
const b = computed(
|
||||
'',
|
||||
() => {
|
||||
if (a.get() === 5) throw new Error('test')
|
||||
return a.get()
|
||||
},
|
||||
{
|
||||
historyLength: 10,
|
||||
computeDiff: (a, b) => {
|
||||
return b - a
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(b.get()).toBe(1)
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(2)
|
||||
expect(b.get()).toBe(2)
|
||||
expect(b.getDiffSince(startEpoch)).toEqual([1])
|
||||
|
||||
a.set(4)
|
||||
expect(b.get()).toBe(4)
|
||||
expect(b.getDiffSince(startEpoch)).toEqual([1, 2])
|
||||
|
||||
a.set(5)
|
||||
|
||||
expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(b.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
||||
const errorEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(6)
|
||||
expect(b.get()).toBe(6)
|
||||
expect(b.getDiffSince(errorEpoch)).toEqual(RESET_VALUE)
|
||||
expect(b.getDiffSince(errorEpoch + 1)).toEqual([])
|
||||
|
||||
a.set(7)
|
||||
expect(b.get()).toBe(7)
|
||||
expect(b.getDiffSince(errorEpoch)).toEqual(RESET_VALUE)
|
||||
expect(b.getDiffSince(errorEpoch + 1)).toEqual([1])
|
||||
})
|
||||
|
||||
it('[CE5] haveParentsChanged will not throw if one of the parents is throwing', () => {
|
||||
const a = atom('', 1)
|
||||
const scheduler = new EffectScheduler('', () => {
|
||||
a.get()
|
||||
throw new Error('test')
|
||||
})
|
||||
expect(() => {
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
}).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
|
||||
expect(haveParentsChanged(scheduler)).toBe(false)
|
||||
|
||||
expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
|
||||
// haveParentsChanged should still be false because it already
|
||||
// executed the effect and it errored
|
||||
expect(haveParentsChanged(scheduler)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effects that throw (CE6)', () => {
|
||||
it('[CE6] will not roll back the atom value', () => {
|
||||
const a = atom('', 1)
|
||||
react('', () => {
|
||||
if (a.get() === 2) throw new Error('test')
|
||||
})
|
||||
expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
expect(a.get()).toBe(2)
|
||||
})
|
||||
|
||||
it('[CE6] will not roll back the changes in a transaction', () => {
|
||||
const a = atom('', 1)
|
||||
const b = atom('', 2)
|
||||
|
||||
react('', () => {
|
||||
if (a.get() + b.get() === 4) throw new Error('test')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
transact(() => {
|
||||
a.set(3)
|
||||
b.set(1)
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
|
||||
|
||||
expect(a.get()).toBe(3)
|
||||
expect(b.get()).toBe(1)
|
||||
})
|
||||
})
|
||||
464
packages/state/src/lib/__tests__/fuzz.tlstate.test.ts
Normal file
464
packages/state/src/lib/__tests__/fuzz.tlstate.test.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import times from 'lodash/times'
|
||||
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.get()) as Letter
|
||||
}
|
||||
return value as Letter
|
||||
}
|
||||
|
||||
interface FuzzSystemState {
|
||||
atoms: Record<string, Atom<Letter>>
|
||||
atomsInAtoms: Record<string, Atom<Atom<Letter>>>
|
||||
// eslint-disable-next-line tldraw/method-signature-style
|
||||
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>[]
|
||||
// runs its body inside a transaction that first writes a fixed letter to an atom, so
|
||||
// that reads inside transactions inside the reaction phase get exercised
|
||||
transacting: boolean
|
||||
// schedules through a queue drained by the `flush_deferred` op (like state-react does)
|
||||
deferred: boolean
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
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 }
|
||||
| { type: 'deref_derivation_in_transaction'; id: string; atomId: string; value: Letter }
|
||||
| { type: 'flush_deferred' }
|
||||
|
||||
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
|
||||
deferredExecutes: Array<() => void> = []
|
||||
systemState: FuzzSystemState = {
|
||||
atoms: {},
|
||||
atomsInAtoms: {},
|
||||
derivations: {},
|
||||
derivationsInDerivations: {},
|
||||
atomsInDerivations: {},
|
||||
reactors: {},
|
||||
}
|
||||
|
||||
// eslint-disable-next-line tldraw/prefer-class-methods
|
||||
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, deferred },
|
||||
] of Object.entries(this.systemState.reactors)) {
|
||||
if (!reactor.scheduler.isActivelyListening) continue
|
||||
// a deferred reactor's result lags until its queued execute runs
|
||||
if (deferred && this.deferredExecutes.length > 0) 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))
|
||||
)
|
||||
})
|
||||
|
||||
// atoms not yet claimed by a transacting reactor; two reactors writing different letters to
|
||||
// the same atom would keep re-triggering each other
|
||||
const writableAtoms = Object.values(this.systemState.atoms)
|
||||
|
||||
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)))
|
||||
})
|
||||
|
||||
const transacting = writableAtoms.length > 0 && this.source.choice(0.3)
|
||||
const deferred = !transacting && this.source.choice(0.3)
|
||||
// A transacting reactor writes one fixed letter to its own atom before reading its
|
||||
// dependencies, inside a transaction. Writing the same letter every run means it cannot
|
||||
// keep re-triggering itself, and the oracle (which reads the atoms' current values)
|
||||
// stays valid once the reaction phase has settled.
|
||||
const writeAtom = transacting
|
||||
? writableAtoms.splice(this.source.nextInt(writableAtoms.length), 1)[0]
|
||||
: null
|
||||
const writeLetter = this.source.selectOne(LETTERS)
|
||||
const body = () => {
|
||||
this.systemState.reactors[reactorId].result = dependencies.map(unpack).join(':')
|
||||
}
|
||||
const fn = writeAtom
|
||||
? () => {
|
||||
transact(() => {
|
||||
writeAtom.set(writeLetter)
|
||||
body()
|
||||
})
|
||||
}
|
||||
: body
|
||||
this.systemState.reactors[reactorId] = {
|
||||
reactor: reactor(
|
||||
reactorId,
|
||||
fn,
|
||||
deferred
|
||||
? { scheduleEffect: (execute) => this.deferredExecutes.push(execute) }
|
||||
: undefined
|
||||
),
|
||||
result: '',
|
||||
dependencies,
|
||||
transacting,
|
||||
deferred,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
},
|
||||
'deref derivation in transaction': () => {
|
||||
return {
|
||||
type: 'deref_derivation_in_transaction',
|
||||
id: this.source.selectOne(Object.keys(this.systemState.derivations)),
|
||||
atomId: this.source.selectOne(Object.keys(this.systemState.atoms)),
|
||||
value: this.source.selectOne(LETTERS),
|
||||
}
|
||||
},
|
||||
flush_deferred: () => {
|
||||
return { type: 'flush_deferred' }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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].get()
|
||||
break
|
||||
}
|
||||
case 'deref_derivation': {
|
||||
const { derivation, sneakyGet } = this.systemState.derivations[op.id]
|
||||
const actual = derivation.get()
|
||||
const expected = sneakyGet()
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`derivation ${op.id} returned ${String(actual)}, expected ${String(expected)}`
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'deref_derivation_in_derivation': {
|
||||
this.systemState.derivationsInDerivations[op.id].get()
|
||||
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
|
||||
}
|
||||
case 'deref_derivation_in_transaction': {
|
||||
// T2: a read inside a transaction must see the in-transaction value, whether or not
|
||||
// the derivation is actively listening
|
||||
const { derivation, sneakyGet } = this.systemState.derivations[op.id]
|
||||
transact(() => {
|
||||
this.systemState.atoms[op.atomId].set(op.value)
|
||||
const actual = derivation.get()
|
||||
const expected = sneakyGet()
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`derivation ${op.id} read inside a transaction returned ${String(actual)}, expected ${String(expected)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'flush_deferred': {
|
||||
const executes = this.deferredExecutes
|
||||
this.deferredExecutes = []
|
||||
for (const execute of executes) execute()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown op type: ${op}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
const op = this.getNextOp()
|
||||
this.ops.push(op)
|
||||
this.applyOp(op)
|
||||
}
|
||||
}
|
||||
|
||||
const NUM_TESTS = 20
|
||||
const NUM_OPS_PER_TEST = 1000
|
||||
|
||||
function runTest(seed: number) {
|
||||
const test = new Test(seed)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
test('regression 728608', () => {
|
||||
runTest(728608)
|
||||
})
|
||||
49
packages/state/src/lib/__tests__/guards.test.ts
Normal file
49
packages/state/src/lib/__tests__/guards.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { atom, isAtom } from '../Atom'
|
||||
import { computed, getComputedInstance, isComputed } from '../Computed'
|
||||
import { isSignal } from '../isSignal'
|
||||
|
||||
// Tests for SPEC.md §14 (type guards and module duplication).
|
||||
// Rule G2 (singleton sharing across module copies) is covered at the unit level in helpers.test.ts.
|
||||
|
||||
describe('type guards (G1)', () => {
|
||||
const a = atom('a', 1)
|
||||
const c = computed('c', () => a.get() * 2)
|
||||
|
||||
class Foo {
|
||||
x = atom('x', 1)
|
||||
@computed
|
||||
getY() {
|
||||
return this.x.get()
|
||||
}
|
||||
}
|
||||
const decorated = getComputedInstance(new Foo(), 'getY')
|
||||
|
||||
const nonSignals = [null, undefined, 0, 1, NaN, 'hello', {}, [], () => {}, Symbol('s')]
|
||||
|
||||
it('[G1] isAtom is true exactly for atoms', () => {
|
||||
expect(isAtom(a)).toBe(true)
|
||||
expect(isAtom(c)).toBe(false)
|
||||
expect(isAtom(decorated)).toBe(false)
|
||||
for (const value of nonSignals) {
|
||||
expect(isAtom(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('[G1] isComputed is true exactly for computed signals', () => {
|
||||
expect(isComputed(c)).toBe(true)
|
||||
expect(isComputed(decorated)).toBe(true)
|
||||
expect(isComputed(a)).toBe(false)
|
||||
for (const value of nonSignals) {
|
||||
expect(isComputed(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('[G1] isSignal is true exactly for atoms and computed signals', () => {
|
||||
expect(isSignal(a)).toBe(true)
|
||||
expect(isSignal(c)).toBe(true)
|
||||
expect(isSignal(decorated)).toBe(true)
|
||||
for (const value of nonSignals) {
|
||||
expect(isSignal(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
152
packages/state/src/lib/__tests__/helpers.test.ts
Normal file
152
packages/state/src/lib/__tests__/helpers.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ArraySet } from '../ArraySet'
|
||||
import { atom } from '../Atom'
|
||||
import { reactor } from '../EffectScheduler'
|
||||
import { attach, detach, equals, haveParentsChanged, singleton } from '../helpers'
|
||||
import { Child } from '../types'
|
||||
|
||||
// Unit tests for the internal helpers behind SPEC.md rules EQ1/EQ2 (equals),
|
||||
// CAP7 (attach/detach), and G2 (singleton).
|
||||
|
||||
const makeChild = (): Child => ({
|
||||
parents: [],
|
||||
parentEpochs: [],
|
||||
parentSet: new ArraySet(),
|
||||
name: 'test-child',
|
||||
lastTraversedEpoch: 0,
|
||||
isActivelyListening: true,
|
||||
__debug_ancestor_epochs__: null,
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('haveParentsChanged', () => {
|
||||
it('returns false when no parents exist', () => {
|
||||
expect(haveParentsChanged(makeChild())).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when parent epoch has changed', () => {
|
||||
const parentAtom = atom('parent', 1)
|
||||
const oldEpoch = parentAtom.lastChangedEpoch
|
||||
|
||||
const child = makeChild()
|
||||
child.parents.push(parentAtom)
|
||||
child.parentEpochs.push(oldEpoch)
|
||||
|
||||
// Change the parent, which should update its epoch
|
||||
parentAtom.set(2)
|
||||
|
||||
expect(haveParentsChanged(child)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when any parent has changed among multiple parents', () => {
|
||||
const parent1 = atom('parent1', 1)
|
||||
const parent2 = atom('parent2', 2)
|
||||
|
||||
const child = makeChild()
|
||||
child.parents.push(parent1, parent2)
|
||||
child.parentEpochs.push(parent1.lastChangedEpoch, parent2.lastChangedEpoch)
|
||||
|
||||
// Change only the second parent
|
||||
parent2.set(3)
|
||||
|
||||
expect(haveParentsChanged(child)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detach [CAP7]', () => {
|
||||
it('removes child from parent children when attached', () => {
|
||||
const parent = atom('parent', 1)
|
||||
const child = makeChild()
|
||||
|
||||
parent.children.add(child)
|
||||
expect(parent.children.size()).toBe(1)
|
||||
|
||||
detach(parent, child)
|
||||
|
||||
expect(parent.children.size()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attach [CAP7]', () => {
|
||||
it('adds child to parent children when not already attached', () => {
|
||||
const parent = atom('parent', 1)
|
||||
const child = makeChild()
|
||||
|
||||
expect(parent.children.size()).toBe(0)
|
||||
|
||||
attach(parent, child)
|
||||
|
||||
expect(parent.children.size()).toBe(1)
|
||||
expect(Array.from(parent.children)).toContain(child)
|
||||
})
|
||||
})
|
||||
|
||||
describe('equals [EQ1, EQ2]', () => {
|
||||
it('[EQ1] returns true for identical references and Object.is cases', () => {
|
||||
const obj = { a: 1 }
|
||||
expect(equals(obj, obj)).toBe(true)
|
||||
expect(equals(1, 1)).toBe(true)
|
||||
expect(equals(NaN, NaN)).toBe(true)
|
||||
})
|
||||
|
||||
it('[EQ1] returns false for different values', () => {
|
||||
expect(equals(1, 2)).toBe(false)
|
||||
expect(equals({ id: 1 }, { id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('[EQ1] uses the first value’s custom equals method when available', () => {
|
||||
const obj1 = {
|
||||
id: 1,
|
||||
equals: (other: any) => other && other.id === 1,
|
||||
}
|
||||
const obj2 = { id: 1, name: 'test' }
|
||||
|
||||
expect(equals(obj1, obj2)).toBe(true)
|
||||
})
|
||||
|
||||
it('[EQ2] does not consult the second value’s equals method', () => {
|
||||
const obj1 = { id: 1 }
|
||||
const obj2 = { id: 1, equals: () => true }
|
||||
|
||||
expect(equals(obj1, obj2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton [G2]', () => {
|
||||
it('returns the same instance on subsequent calls with the same key', () => {
|
||||
const init = vi.fn(() => ({ value: 42 }))
|
||||
|
||||
const instance1 = singleton('test-singleton', init)
|
||||
const instance2 = singleton('test-singleton', init)
|
||||
|
||||
expect(init).toHaveBeenCalledTimes(1)
|
||||
expect(instance1).toBe(instance2)
|
||||
})
|
||||
|
||||
it('stores the instance on globalThis so duplicate module copies share it', () => {
|
||||
const instance = singleton('test-singleton-global', () => ({ value: 1 }))
|
||||
|
||||
expect((globalThis as any)[Symbol.for('com.tldraw.state/test-singleton-global')]).toBe(
|
||||
instance
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// CAP7: a signal's children set is non-empty exactly while something downstream is listening
|
||||
describe('children', () => {
|
||||
it('is empty unless something is actively listening', () => {
|
||||
const baseAtom = atom('base', 1)
|
||||
expect(baseAtom.children.isEmpty).toBe(true)
|
||||
|
||||
const reactorInstance = reactor('test-reactor', () => {
|
||||
baseAtom.get()
|
||||
})
|
||||
|
||||
reactorInstance.start()
|
||||
expect(baseAtom.children.isEmpty).toBe(false)
|
||||
|
||||
reactorInstance.stop()
|
||||
expect(baseAtom.children.isEmpty).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
554
packages/state/src/lib/__tests__/history.test.ts
Normal file
554
packages/state/src/lib/__tests__/history.test.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { exhaustiveSwitchError } from '@tldraw/utils'
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { Computed, UNINITIALIZED, computed, isUninitialized, withDiff } from '../Computed'
|
||||
import { react } from '../EffectScheduler'
|
||||
import { EMPTY_ARRAY } from '../helpers'
|
||||
import { getGlobalEpoch, transact, transaction } from '../transactions'
|
||||
import { RESET_VALUE, Signal } from '../types'
|
||||
|
||||
// Tests for SPEC.md §8 (history and diffs).
|
||||
// Rule IDs like [H2] in test names refer to that document.
|
||||
|
||||
describe('atom history (H)', () => {
|
||||
it('[H1] only exists when historyLength is provided', () => {
|
||||
const computeDiff = vi.fn((a: number, b: number) => b - a)
|
||||
const a = atom<number, number>('', 1, { computeDiff })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(computeDiff).not.toHaveBeenCalled()
|
||||
expect(a.getDiffSince(startEpoch)).toBe(RESET_VALUE)
|
||||
})
|
||||
|
||||
it('[H2][H5] records computeDiff diffs and returns RESET_VALUE when the buffer is exceeded', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
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('[H2] prefers an explicit diff passed to set over computeDiff', () => {
|
||||
const computeDiff = vi.fn((a: number, b: number) => b - a)
|
||||
const a = atom<number, number>('', 1, { historyLength: 3, computeDiff })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(5, +100)
|
||||
expect(computeDiff).not.toHaveBeenCalled()
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([+100])
|
||||
|
||||
a.set(6)
|
||||
expect(computeDiff).toHaveBeenCalledTimes(1)
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([+100, +1])
|
||||
})
|
||||
|
||||
it('[H2] passes the previous and current epochs to computeDiff', () => {
|
||||
const calls: Array<[number, number, number, number]> = []
|
||||
const a = atom('', 1, {
|
||||
historyLength: 3,
|
||||
computeDiff: (prev, next, lastEpoch, currentEpoch) => {
|
||||
calls.push([prev, next, lastEpoch, currentEpoch])
|
||||
return next - prev
|
||||
},
|
||||
})
|
||||
|
||||
const epochBeforeSet = a.lastChangedEpoch
|
||||
a.set(5)
|
||||
|
||||
expect(calls).toEqual([[1, 5, epochBeforeSet, getGlobalEpoch()]])
|
||||
})
|
||||
|
||||
it('[H2][C2] a computed read inside computeDiff still sees the change afterwards', () => {
|
||||
// computeDiff runs before the epoch ticks, so a dependent computed it reads is not stamped as
|
||||
// checked at the epoch of the write that is still in progress.
|
||||
const a = atom('', 1, {
|
||||
historyLength: 10,
|
||||
computeDiff: (prev, next) => {
|
||||
c.get()
|
||||
return next - prev
|
||||
},
|
||||
})
|
||||
const c: Computed<number> = computed('', () => a.get() * 2)
|
||||
const seen: number[] = []
|
||||
react('', () => seen.push(c.get()))
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(c.get()).toBe(4)
|
||||
expect(seen).toEqual([2, 4])
|
||||
expect(a.getDiffSince(a.lastChangedEpoch - 1)).toEqual([1])
|
||||
})
|
||||
|
||||
it('[H2] records an explicit null diff rather than treating it as missing', () => {
|
||||
const a = atom<number, null | number>('', 1, { historyLength: 3 })
|
||||
const startEpoch = a.lastChangedEpoch
|
||||
a.set(2, 1)
|
||||
a.set(3, null)
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([1, null])
|
||||
})
|
||||
|
||||
it('[H4] clears the history buffer if no diff can be determined', () => {
|
||||
const a = atom('', 1, { historyLength: 3 })
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(5, +4)
|
||||
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
||||
|
||||
// no explicit diff and no computeDiff: the diff is RESET_VALUE, which wipes history
|
||||
a.set(6)
|
||||
|
||||
expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
||||
})
|
||||
|
||||
it('[H5] returns the shared EMPTY_ARRAY instance when nothing changed since the epoch', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(a.getDiffSince(getGlobalEpoch())).toBe(EMPTY_ARRAY)
|
||||
})
|
||||
|
||||
it('[H6] getDiffSince captures the signal as a dependency', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
const effect = vi.fn((lastReactedEpoch: number) => {
|
||||
a.getDiffSince(lastReactedEpoch)
|
||||
})
|
||||
const stop = react('r', effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('[A6] is independent of other atoms’ history', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
const b = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
b.set(-5)
|
||||
b.set(-10)
|
||||
b.set(-20)
|
||||
expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10])
|
||||
expect(b.getDiffSince(getGlobalEpoch())).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(getGlobalEpoch())).toEqual([])
|
||||
})
|
||||
|
||||
it('[H7] keeps recording inside transactions', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
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('[H7][T9] is cleared when a transaction aborts', () => {
|
||||
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
transaction((rollback) => {
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([])
|
||||
|
||||
a.set(5)
|
||||
|
||||
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
||||
|
||||
rollback()
|
||||
})
|
||||
|
||||
expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed history (H)', () => {
|
||||
it('[H3][H5] records computeDiff diffs and returns RESET_VALUE when the buffer is exceeded', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('', 1)
|
||||
|
||||
const derivation = computed('', () => a.get() * 2, {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => {
|
||||
return b - a
|
||||
},
|
||||
})
|
||||
|
||||
derivation.get()
|
||||
|
||||
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 history
|
||||
expect(derivation.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
||||
})
|
||||
|
||||
it('[H3][H8] prefers a withDiff diff over computeDiff, and get() unwraps the value', () => {
|
||||
const a = atom('', 1)
|
||||
const computeDiff = vi.fn((prev: number, next: number) => next - prev)
|
||||
|
||||
const derivation = computed(
|
||||
'',
|
||||
(prev: number | UNINITIALIZED) => {
|
||||
const next = a.get() * 2
|
||||
if (isUninitialized(prev)) return next
|
||||
return withDiff(next, `+${next - prev}`)
|
||||
},
|
||||
{ historyLength: 3, computeDiff: computeDiff as any }
|
||||
)
|
||||
|
||||
expect(derivation.get()).toBe(2)
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(derivation.get()).toBe(4)
|
||||
expect(derivation.getDiffSince(startEpoch)).toEqual(['+2'])
|
||||
expect(computeDiff).not.toHaveBeenCalled()
|
||||
|
||||
a.set(5)
|
||||
|
||||
expect(derivation.get()).toBe(10)
|
||||
expect(derivation.getDiffSince(startEpoch)).toEqual(['+2', '+6'])
|
||||
expect(computeDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('[H3] records no history entry for the first computation', () => {
|
||||
const a = atom('', 1)
|
||||
const derivation = computed('', () => a.get() * 2, {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => b - a,
|
||||
})
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(derivation.getDiffSince(startEpoch)).toEqual([])
|
||||
})
|
||||
|
||||
it('[C5] does not record history when the recomputed value is equal', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
const a = atom('', 1)
|
||||
|
||||
const floor = vi.fn((n: number) => Math.floor(n))
|
||||
const derivation = computed('', () => floor(a.get()), {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => {
|
||||
return b - a
|
||||
},
|
||||
})
|
||||
|
||||
expect(derivation.get()).toBe(1)
|
||||
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
||||
|
||||
a.set(1.2)
|
||||
|
||||
expect(derivation.get()).toBe(1)
|
||||
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
||||
expect(floor).toHaveBeenCalledTimes(2)
|
||||
|
||||
a.set(1.9)
|
||||
|
||||
expect(derivation.get()).toBe(1)
|
||||
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
||||
expect(floor).toHaveBeenCalledTimes(3)
|
||||
|
||||
a.set(2.3)
|
||||
|
||||
expect(derivation.get()).toBe(2)
|
||||
expect(derivation.getDiffSince(startEpoch)).toEqual([+1])
|
||||
expect(floor).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('[H7] is not cleared by an aborted transaction: the round trip is recorded as ordinary entries', () => {
|
||||
const a = atom('', 1)
|
||||
const b = atom('', 1)
|
||||
|
||||
const c = computed('', () => a.get() + b.get(), {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => b - a,
|
||||
})
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
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('[H5] returns RESET_VALUE for an epoch before the first computation', () => {
|
||||
const a = atom('', 1)
|
||||
const b = atom('', 1)
|
||||
|
||||
const c = computed('', () => a.get() + b.get(), {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => b - a,
|
||||
})
|
||||
|
||||
expect(c.getDiffSince(getGlobalEpoch() - 1)).toEqual(RESET_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
// The incremental computation pattern that the history system exists to support:
|
||||
// a derived record map that applies upstream diffs instead of recomputing from scratch.
|
||||
// This exercises [C4], [H5], and [H6] together.
|
||||
|
||||
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.get()
|
||||
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.get()
|
||||
|
||||
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:
|
||||
exhaustiveSwitchError(change)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
describe('incremental derivations', () => {
|
||||
it('[C4][H5][H6] can apply upstream diffs instead of recomputing from scratch', () => {
|
||||
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 = vi.fn((val) => val * 2)
|
||||
|
||||
const doubledNodes = getIncrementalRecordMapper(nodes, mapper)
|
||||
|
||||
expect(doubledNodes.get()).toEqual({
|
||||
a: 2,
|
||||
b: 4,
|
||||
c: 6,
|
||||
d: 8,
|
||||
e: 10,
|
||||
})
|
||||
expect(mapper).toHaveBeenCalledTimes(5)
|
||||
|
||||
nodes.update((ns) => ({ ...ns, a: 10 }))
|
||||
|
||||
expect(doubledNodes.get()).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.get()).toEqual({
|
||||
a: 20,
|
||||
b: 4,
|
||||
c: 6,
|
||||
e: 10,
|
||||
})
|
||||
expect(mapper).toHaveBeenCalledTimes(6)
|
||||
|
||||
nodes.update((ns) => ({ ...ns, f: 50, g: 60 }))
|
||||
|
||||
expect(doubledNodes.get()).toEqual({
|
||||
a: 20,
|
||||
b: 4,
|
||||
c: 6,
|
||||
e: 10,
|
||||
f: 100,
|
||||
g: 120,
|
||||
})
|
||||
expect(mapper).toHaveBeenCalledTimes(8)
|
||||
|
||||
nodes.set({ ...nodes.get() })
|
||||
// no changes so no new calls to mapper
|
||||
expect(doubledNodes.get()).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.get()).toEqual({
|
||||
a: 8,
|
||||
b: 18,
|
||||
c: 34,
|
||||
d: 8,
|
||||
e: 10,
|
||||
})
|
||||
|
||||
expect(mapper).toHaveBeenCalledTimes(12)
|
||||
})
|
||||
})
|
||||
278
packages/state/src/lib/__tests__/localStorageAtom.test.ts
Normal file
278
packages/state/src/lib/__tests__/localStorageAtom.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { localStorageAtom } from '../localStorageAtom'
|
||||
import { getGlobalEpoch } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §15 (localStorageAtom).
|
||||
// Rule IDs like [LS2] in test names refer to that document.
|
||||
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] || null),
|
||||
// Internal helper to reset store
|
||||
_reset: () => {
|
||||
store = {}
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
// Mock the @tldraw/utils functions
|
||||
vi.mock('@tldraw/utils', () => ({
|
||||
getFromLocalStorage: (key: string) => mockLocalStorage.getItem(key),
|
||||
setInLocalStorage: (key: string, value: string) => mockLocalStorage.setItem(key, value),
|
||||
deleteFromLocalStorage: (key: string) => mockLocalStorage.removeItem(key),
|
||||
}))
|
||||
|
||||
describe('localStorageAtom', () => {
|
||||
beforeEach(() => {
|
||||
mockLocalStorage._reset()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('[LS1] should create atom with initial value when localStorage is empty', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
|
||||
|
||||
expect(atom.get()).toBe('initial-value')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS1] should restore value from localStorage when it exists', () => {
|
||||
mockLocalStorage.setItem('test-key', JSON.stringify('stored-value'))
|
||||
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
|
||||
|
||||
expect(atom.get()).toBe('stored-value')
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corrupted localStorage handling', () => {
|
||||
it('[LS2] should use initial value and delete corrupted localStorage entry', () => {
|
||||
mockLocalStorage.setItem('test-key', 'invalid-json')
|
||||
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
|
||||
|
||||
expect(atom.get()).toBe('initial-value')
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('test-key')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS2] should handle empty string in localStorage', () => {
|
||||
mockLocalStorage.setItem('test-key', '')
|
||||
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
|
||||
|
||||
// Empty string should be falsy, so initial value is used
|
||||
expect(atom.get()).toBe('initial-value')
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage synchronization', () => {
|
||||
it('[LS3] should save to localStorage when atom value changes', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
atom.set('new-value')
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('test-key', JSON.stringify('new-value'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS3] should update localStorage on multiple changes', () => {
|
||||
const [atom, cleanup] = localStorageAtom('counter', 0)
|
||||
|
||||
// Clear initial call from atom creation
|
||||
vi.clearAllMocks()
|
||||
|
||||
atom.set(1)
|
||||
atom.set(2)
|
||||
atom.set(3)
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(3)
|
||||
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(1, 'counter', JSON.stringify(1))
|
||||
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(2, 'counter', JSON.stringify(2))
|
||||
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(3, 'counter', JSON.stringify(3))
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-tab synchronization', () => {
|
||||
it('[LS4] updates the atom when a storage event for its key arrives', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
const event = new Event('storage')
|
||||
Object.defineProperties(event, {
|
||||
key: { value: 'test-key' },
|
||||
newValue: { value: JSON.stringify('from-other-tab') },
|
||||
})
|
||||
window.dispatchEvent(event as StorageEvent)
|
||||
|
||||
expect(atom.get()).toBe('from-other-tab')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS4] resets the atom to the initial value when the key is deleted in another tab', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
atom.set('changed')
|
||||
|
||||
const event = new Event('storage')
|
||||
Object.defineProperties(event, {
|
||||
key: { value: 'test-key' },
|
||||
newValue: { value: null },
|
||||
})
|
||||
window.dispatchEvent(event as StorageEvent)
|
||||
|
||||
expect(atom.get()).toBe('initial')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS4] ignores storage events for other keys', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
const event = new Event('storage')
|
||||
Object.defineProperties(event, {
|
||||
key: { value: 'other-key' },
|
||||
newValue: { value: JSON.stringify('other-value') },
|
||||
})
|
||||
window.dispatchEvent(event as StorageEvent)
|
||||
|
||||
expect(atom.get()).toBe('initial')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS4] ignores storage events with unparseable values', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
atom.set('current')
|
||||
|
||||
const event = new Event('storage')
|
||||
Object.defineProperties(event, {
|
||||
key: { value: 'test-key' },
|
||||
newValue: { value: 'not json' },
|
||||
})
|
||||
window.dispatchEvent(event as StorageEvent)
|
||||
|
||||
expect(atom.get()).toBe('current')
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup functionality', () => {
|
||||
it('[LS5] should stop syncing to localStorage after cleanup', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
// Change value before cleanup - should sync
|
||||
atom.set('before-cleanup')
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'test-key',
|
||||
JSON.stringify('before-cleanup')
|
||||
)
|
||||
|
||||
// Clear mocks and cleanup
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
|
||||
// Change value after cleanup - should not sync
|
||||
atom.set('after-cleanup')
|
||||
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('[LS5] should stop handling storage events after cleanup', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
cleanup()
|
||||
|
||||
const event = new Event('storage')
|
||||
Object.defineProperties(event, {
|
||||
key: { value: 'test-key' },
|
||||
newValue: { value: JSON.stringify('from-other-tab') },
|
||||
})
|
||||
window.dispatchEvent(event as StorageEvent)
|
||||
|
||||
expect(atom.get()).toBe('initial')
|
||||
})
|
||||
|
||||
it('[LS5] should allow atom to continue functioning after cleanup', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial')
|
||||
|
||||
cleanup()
|
||||
|
||||
atom.set('new-value')
|
||||
expect(atom.get()).toBe('new-value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('atom options', () => {
|
||||
it('[LS5] should pass through atom options', () => {
|
||||
const isEqual = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'Hello', { isEqual })
|
||||
|
||||
atom.set('HELLO')
|
||||
expect(atom.get()).toBe('Hello') // Should use custom equality
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('[LS5] should work with history options', () => {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 0, {
|
||||
historyLength: 3,
|
||||
computeDiff: (a, b) => b - a,
|
||||
})
|
||||
|
||||
const startEpoch = getGlobalEpoch()
|
||||
|
||||
atom.set(5)
|
||||
atom.set(10)
|
||||
|
||||
const diffs = atom.getDiffSince(startEpoch)
|
||||
expect(diffs).toEqual([5, 5])
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple instances', () => {
|
||||
it('[LS3] should handle multiple atoms with different keys', () => {
|
||||
const [atom1, cleanup1] = localStorageAtom('key1', 'value1')
|
||||
const [atom2, cleanup2] = localStorageAtom('key2', 'value2')
|
||||
|
||||
atom1.set('updated1')
|
||||
atom2.set('updated2')
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('key1', JSON.stringify('updated1'))
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('key2', JSON.stringify('updated2'))
|
||||
|
||||
cleanup1()
|
||||
cleanup2()
|
||||
})
|
||||
})
|
||||
|
||||
describe('outside the browser', () => {
|
||||
it('[LS6] does not throw without a window and returns a working atom', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
try {
|
||||
const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
|
||||
expect(atom.get()).toBe('initial-value')
|
||||
atom.set('next')
|
||||
expect(atom.get()).toBe('next')
|
||||
expect(() => cleanup()).not.toThrow()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
477
packages/state/src/lib/__tests__/propagation.test.ts
Normal file
477
packages/state/src/lib/__tests__/propagation.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import { vi } from 'vitest'
|
||||
import { atom } from '../Atom'
|
||||
import { computed } from '../Computed'
|
||||
import { EffectScheduler, react, reactor } from '../EffectScheduler'
|
||||
import { transact, transaction } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §10 (change propagation and the reaction phase).
|
||||
// Rule IDs like [P4] in test names refer to that document.
|
||||
|
||||
describe('change propagation (P)', () => {
|
||||
it('[P1] runs effects synchronously, before set returns', () => {
|
||||
const a = atom('', 1)
|
||||
let observed = 0
|
||||
|
||||
react('', () => {
|
||||
observed = a.get()
|
||||
})
|
||||
|
||||
a.set(2)
|
||||
expect(observed).toBe(2)
|
||||
})
|
||||
|
||||
it('[P2] reaches effects through chains of computeds, but only along listening edges', () => {
|
||||
const a = atom('', 1)
|
||||
const double = vi.fn(() => a.get() * 2)
|
||||
const c1 = computed('', double)
|
||||
const c2 = computed('', () => c1.get() + 1)
|
||||
|
||||
let last = 0
|
||||
const stop = react('', () => {
|
||||
last = c2.get()
|
||||
})
|
||||
|
||||
expect(last).toBe(3)
|
||||
|
||||
a.set(2)
|
||||
expect(last).toBe(5)
|
||||
|
||||
stop()
|
||||
|
||||
// with nothing listening, changes do not propagate (and computeds stay lazy)
|
||||
a.set(3)
|
||||
expect(last).toBe(5)
|
||||
expect(double).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[P3] runs an effect once per change in a diamond-shaped graph', () => {
|
||||
const a = atom('', 1)
|
||||
const left = computed('', () => a.get() + 1)
|
||||
const right = computed('', () => a.get() * 2)
|
||||
|
||||
const effect = vi.fn(() => {
|
||||
left.get()
|
||||
right.get()
|
||||
})
|
||||
react('', effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
|
||||
a.set(2)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setting atoms during the reaction phase (P)', () => {
|
||||
it('[P4] works', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
react('', () => {
|
||||
b.set(a.get() + 1)
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(1)
|
||||
})
|
||||
|
||||
it('[P5][P8] throws an error if it gets into a loop', () => {
|
||||
expect(() => {
|
||||
const a = atom('', 0)
|
||||
|
||||
react('', () => {
|
||||
a.set(a.get() + 1)
|
||||
})
|
||||
}).toThrowErrorMatchingInlineSnapshot(`[Error: Reaction update depth limit exceeded]`)
|
||||
})
|
||||
|
||||
it('[P5] throws when a reactor can not stop setting atom values', () => {
|
||||
const a = atom('', 1)
|
||||
const r = reactor('', () => {
|
||||
if (a.get() < +Infinity) {
|
||||
a.update((a) => a + 1)
|
||||
}
|
||||
})
|
||||
expect(() => r.start()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Reaction update depth limit exceeded]`
|
||||
)
|
||||
})
|
||||
|
||||
it('[P4][P6] works with a transaction running', () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
react('', () => {
|
||||
transact(() => {
|
||||
if (a.get() < 10) {
|
||||
a.set(a.get() + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(10)
|
||||
})
|
||||
|
||||
it('[P7][regression 1] should allow computeds to be updated properly', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
const c = computed('', () => b.get() * 2)
|
||||
|
||||
let cValue = 0
|
||||
|
||||
react('', () => {
|
||||
b.set(a.get() + 1)
|
||||
cValue = c.get()
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(1)
|
||||
expect(cValue).toBe(2)
|
||||
|
||||
transact(() => {
|
||||
a.set(1)
|
||||
})
|
||||
expect(cValue).toBe(4)
|
||||
})
|
||||
|
||||
it('[P7][regression 2] should allow computeds to be updated properly', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 1)
|
||||
const c = atom('', 0)
|
||||
const d = computed('', () => a.get() * 2)
|
||||
|
||||
let dValue = 0
|
||||
react('', () => {
|
||||
// update a, causes a and d to be traversed (but not updated)
|
||||
a.set(b.get())
|
||||
// update c
|
||||
c.set(a.get())
|
||||
// make sure that when we get d, it is updated properly
|
||||
dValue = d.get()
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(1)
|
||||
expect(b.get()).toBe(1)
|
||||
expect(c.get()).toBe(1)
|
||||
|
||||
expect(dValue).toBe(2)
|
||||
|
||||
transact(() => {
|
||||
b.set(2)
|
||||
})
|
||||
expect(dValue).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions during the reaction phase (P6)', () => {
|
||||
it('[P6] it should be possible to run a transaction during a reaction', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
react('', () => {
|
||||
transaction(() => {
|
||||
b.set(a.get() + 1)
|
||||
})
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(1)
|
||||
|
||||
a.set(1)
|
||||
|
||||
expect(b.get()).toBe(2)
|
||||
|
||||
transaction(() => {
|
||||
a.set(2)
|
||||
expect(b.get()).toBe(2)
|
||||
})
|
||||
|
||||
expect(b.get()).toBe(3)
|
||||
})
|
||||
|
||||
it('[P6] it should be possible to abort a transaction during a reaction', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
|
||||
const unsub = react('', () => {
|
||||
transaction((rollback) => {
|
||||
b.set(a.get() + 1)
|
||||
rollback()
|
||||
})
|
||||
expect(b.get()).toBe(0)
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(0)
|
||||
|
||||
unsub()
|
||||
|
||||
react('', () => {
|
||||
transaction(() => {
|
||||
b.set(3)
|
||||
try {
|
||||
transaction(() => {
|
||||
b.set(a.get() + 1)
|
||||
throw new Error('oops')
|
||||
})
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe('oops')
|
||||
} finally {
|
||||
expect(b.get()).toBe(3)
|
||||
}
|
||||
})
|
||||
expect(b.get()).toBe(3)
|
||||
})
|
||||
|
||||
expect(a.get()).toBe(0)
|
||||
expect(b.get()).toBe(3)
|
||||
|
||||
expect.assertions(8)
|
||||
})
|
||||
|
||||
it('[P6] defers all side effects until the end of the outer reaction pass', () => {
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
const c = atom('', 0)
|
||||
|
||||
const aChanged = vi.fn()
|
||||
const bChanged = vi.fn()
|
||||
const cChanged = vi.fn()
|
||||
|
||||
react('', () => {
|
||||
a.get()
|
||||
aChanged()
|
||||
})
|
||||
|
||||
react('', () => {
|
||||
transaction(() => {
|
||||
a.set(b.get() + 1)
|
||||
})
|
||||
bChanged()
|
||||
})
|
||||
|
||||
react('', () => {
|
||||
transaction(() => {
|
||||
b.set(c.get() + 1)
|
||||
})
|
||||
cChanged()
|
||||
})
|
||||
|
||||
expect(aChanged).toHaveBeenCalledTimes(3)
|
||||
expect(bChanged).toHaveBeenCalledTimes(2)
|
||||
expect(cChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(a.__unsafe__getWithoutCapture()).toBe(2)
|
||||
|
||||
cChanged.mockImplementationOnce(() => {
|
||||
// b was .set() during c's reaction
|
||||
expect(b.__unsafe__getWithoutCapture()).toBe(2)
|
||||
// a was not yet set because the effect was deferred
|
||||
// until the end of the reaction
|
||||
expect(a.__unsafe__getWithoutCapture()).toBe(2)
|
||||
})
|
||||
|
||||
c.set(1)
|
||||
|
||||
expect(a.__unsafe__getWithoutCapture()).toBe(3)
|
||||
expect(cChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('[P6][T2][P7] an actively-listening computed read inside a transaction during a reaction sees in-transaction values', () => {
|
||||
// Regression: Computed's cache shortcut for actively-listening computeds ("not traversed this
|
||||
// reaction, so unchanged") ignored that in-transaction changes are only traversed at commit,
|
||||
// and served the pre-transaction value.
|
||||
const a = atom('', 0)
|
||||
const c = computed('', () => a.get() * 2)
|
||||
react('listener', () => {
|
||||
c.get()
|
||||
})
|
||||
|
||||
const trigger = atom('', 0)
|
||||
const seen: number[] = []
|
||||
react('', () => {
|
||||
if (trigger.get() === 0) return
|
||||
transact(() => {
|
||||
a.set(5)
|
||||
seen.push(c.get())
|
||||
})
|
||||
})
|
||||
trigger.set(1)
|
||||
|
||||
expect(seen).toEqual([10])
|
||||
expect(c.get()).toBe(10)
|
||||
})
|
||||
|
||||
it('[P6][T2] an incremental computed read inside a transaction during a reaction is not corrupted', () => {
|
||||
// The stale read above also stamped the computed as checked at the current epoch, so an
|
||||
// incremental computed would later ask for diffs since an epoch that skipped the change.
|
||||
const log = atom<number[], number[]>('log', [], {
|
||||
historyLength: 10,
|
||||
computeDiff: (prev, next) => next.slice(prev.length),
|
||||
})
|
||||
const sum = computed<number>('sum', (prev, lastComputedEpoch) => {
|
||||
if (typeof prev !== 'number') return log.get().reduce((a, b) => a + b, 0)
|
||||
const diffs = log.getDiffSince(lastComputedEpoch)
|
||||
if (typeof diffs === 'symbol') return log.get().reduce((a, b) => a + b, 0)
|
||||
let s = prev
|
||||
for (const diff of diffs) for (const n of diff) s += n
|
||||
return s
|
||||
})
|
||||
react('listener', () => {
|
||||
sum.get()
|
||||
})
|
||||
|
||||
const trigger = atom('', 0)
|
||||
const seen: number[] = []
|
||||
let done = false
|
||||
react('', () => {
|
||||
if (trigger.get() === 0 || done) return
|
||||
done = true
|
||||
transact(() => {
|
||||
log.update((l) => [...l, 10])
|
||||
seen.push(sum.get())
|
||||
})
|
||||
})
|
||||
trigger.set(1)
|
||||
expect(seen).toEqual([10])
|
||||
|
||||
log.update((l) => [...l, 5])
|
||||
expect(sum.get()).toBe(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actively-listening computeds stay fresh (C2)', () => {
|
||||
it('[C2][E6] a computed traversed for a deferred effect but not re-checked is not served stale', () => {
|
||||
// Regression: with deferred scheduling (as in state-react), haveParentsChanged returns at the
|
||||
// first changed parent, so a later computed parent is traversed but never re-checked; a read
|
||||
// during a subsequent reaction then took the "not traversed this reaction" shortcut.
|
||||
const a = atom('', 0)
|
||||
const b = atom('', 0)
|
||||
const c = computed('', () => a.get())
|
||||
const deferred: (() => void)[] = []
|
||||
const scheduler = new EffectScheduler(
|
||||
'',
|
||||
() => {
|
||||
b.get()
|
||||
c.get()
|
||||
},
|
||||
{ scheduleEffect: (execute) => deferred.push(execute) }
|
||||
)
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
|
||||
// b is checked first, changed, so c is traversed but not re-checked; the run is deferred
|
||||
transact(() => {
|
||||
b.set(1)
|
||||
a.set(1)
|
||||
})
|
||||
|
||||
const z = atom('', 0)
|
||||
const seen: number[] = []
|
||||
react('', () => {
|
||||
if (z.get() > 0) seen.push(c.get())
|
||||
})
|
||||
z.set(1)
|
||||
|
||||
expect(seen).toEqual([1])
|
||||
expect(c.get()).toBe(1)
|
||||
|
||||
deferred.forEach((execute) => execute())
|
||||
expect(c.get()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effects that set atoms outside the reaction phase (P8)', () => {
|
||||
// Regression: a first run happens outside a reaction phase, so a set inside it flushed
|
||||
// synchronously and re-entered execute() for the same scheduler. The nested capture frame then
|
||||
// truncated the parents captured by the outer run — `b` was dropped while `b.children` still
|
||||
// held the effect, so `b.set()` never reached the effect again.
|
||||
it.each([
|
||||
['react()', (fn: () => void) => react('r', fn)],
|
||||
['reactor.start()', (fn: () => void) => reactor('r', fn).start()],
|
||||
[
|
||||
'execute()',
|
||||
(fn: () => void) => {
|
||||
const scheduler = new EffectScheduler('r', fn)
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
},
|
||||
],
|
||||
])(
|
||||
'[P8] a first run via %s that sets one of its own parents re-runs after it instead of nesting',
|
||||
(_, start) => {
|
||||
const initialized = atom('initialized', false)
|
||||
const b = atom('b', 0)
|
||||
const log: string[] = []
|
||||
|
||||
start(() => {
|
||||
log.push('start')
|
||||
if (!initialized.get()) initialized.set(true)
|
||||
log.push(`b=${b.get()}`)
|
||||
log.push('end')
|
||||
})
|
||||
|
||||
// the set changed a parent read before it, so the effect runs once more, sequentially
|
||||
expect(log).toEqual(['start', 'b=0', 'end', 'start', 'b=0', 'end'])
|
||||
|
||||
b.set(1)
|
||||
expect(log.slice(6)).toEqual(['start', 'b=1', 'end'])
|
||||
}
|
||||
)
|
||||
|
||||
it('[P8][E4] skips the extra run when none of the parents it captured have changed', () => {
|
||||
const a = atom('a', 0)
|
||||
let readsA = true
|
||||
let runs = 0
|
||||
|
||||
const scheduler = new EffectScheduler('r', () => {
|
||||
runs++
|
||||
if (readsA) {
|
||||
a.get()
|
||||
} else {
|
||||
a.set(1)
|
||||
}
|
||||
})
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
|
||||
// `a` is a parent from the first run, so this run's set schedules the effect. But the run
|
||||
// drops `a` instead of reading it, so by the time it finishes nothing it depends on changed.
|
||||
readsA = false
|
||||
scheduler.execute()
|
||||
expect(scheduler.scheduleCount).toBe(1)
|
||||
expect(runs).toBe(2)
|
||||
})
|
||||
|
||||
it('[P8][E7] does not re-run an effect that was detached during its first run', () => {
|
||||
const a = atom('a', 0)
|
||||
let runs = 0
|
||||
|
||||
const scheduler = new EffectScheduler('r', () => {
|
||||
runs++
|
||||
a.set(a.get() + 1)
|
||||
scheduler.detach()
|
||||
})
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
|
||||
expect(scheduler.scheduleCount).toBe(1)
|
||||
expect(runs).toBe(1)
|
||||
})
|
||||
|
||||
it('[P8] a first run that throws is not re-entered by its own set', () => {
|
||||
const a = atom('a', 0)
|
||||
let runs = 0
|
||||
|
||||
const scheduler = new EffectScheduler('r', () => {
|
||||
runs++
|
||||
a.set(a.get() + 1)
|
||||
throw new Error('boom')
|
||||
})
|
||||
scheduler.attach()
|
||||
|
||||
expect(() => scheduler.execute()).toThrow('boom')
|
||||
expect(scheduler.scheduleCount).toBe(1)
|
||||
expect(runs).toBe(1)
|
||||
})
|
||||
})
|
||||
265
packages/state/src/lib/__tests__/transactions.test.ts
Normal file
265
packages/state/src/lib/__tests__/transactions.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { atom } from '../Atom'
|
||||
import { computed } from '../Computed'
|
||||
import { react } from '../EffectScheduler'
|
||||
import { getGlobalEpoch, transact, transaction } from '../transactions'
|
||||
|
||||
// Tests for SPEC.md §11 (transactions), plus rule EP6.
|
||||
// Rule IDs like [T4] in test names refer to that document.
|
||||
|
||||
describe('transactions (T)', () => {
|
||||
it('[T2][T3][T4][T6] batch changes, defer effects, and can be rolled back', () => {
|
||||
const firstName = atom('', 'John')
|
||||
const lastName = atom('', 'Doe')
|
||||
|
||||
let numTimesComputed = 0
|
||||
const fullName = computed('', () => {
|
||||
numTimesComputed++
|
||||
return `${firstName.get()} ${lastName.get()}`
|
||||
})
|
||||
|
||||
let numTimesReacted = 0
|
||||
let name = ''
|
||||
|
||||
react('', () => {
|
||||
name = fullName.get()
|
||||
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)
|
||||
// [T6] effects never observe intermediate in-transaction values
|
||||
expect(name).toBe('John Doe')
|
||||
lastName.set('Jones')
|
||||
expect(numTimesComputed).toBe(1)
|
||||
expect(numTimesReacted).toBe(1)
|
||||
expect(name).toBe('John Doe')
|
||||
// [T2] reads inside the transaction see the latest values
|
||||
expect(fullName.get()).toBe('Wilbur Jones')
|
||||
|
||||
expect(numTimesComputed).toBe(2)
|
||||
expect(numTimesReacted).toBe(1)
|
||||
expect(name).toBe('John Doe')
|
||||
|
||||
rollback()
|
||||
})
|
||||
|
||||
// [T6] the aborted transaction still flushes effects, which observe the restored values
|
||||
expect(numTimesComputed).toBe(3)
|
||||
expect(numTimesReacted).toBe(2)
|
||||
|
||||
expect(fullName.get()).toBe('John Doe')
|
||||
expect(name).toBe('John Doe')
|
||||
})
|
||||
|
||||
it('[T1] returns the value of the function, even when rolled back', () => {
|
||||
expect(transaction(() => 'hello')).toBe('hello')
|
||||
expect(transact(() => 42)).toBe(42)
|
||||
expect(
|
||||
transaction((rollback) => {
|
||||
rollback()
|
||||
return 'rolled back'
|
||||
})
|
||||
).toBe('rolled back')
|
||||
})
|
||||
|
||||
it('[EP6] advances the global epoch when aborted', () => {
|
||||
const startEpoch = getGlobalEpoch()
|
||||
transaction((rollback) => {
|
||||
rollback()
|
||||
})
|
||||
expect(getGlobalEpoch()).toBeGreaterThan(startEpoch)
|
||||
})
|
||||
|
||||
it('[T7] nested transactions roll back independently', () => {
|
||||
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.get()).toBe(0)
|
||||
expect(atomB.get()).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.get()).toBe(0)
|
||||
expect(atomB.get()).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.get()).toBe(0)
|
||||
expect(atomB.get()).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.get()).toBe(1)
|
||||
expect(atomB.get()).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.get()).toBe(2)
|
||||
expect(atomB.get()).toBe(-2)
|
||||
})
|
||||
|
||||
it('[T7] an outer rollback undoes a committed inner transaction', () => {
|
||||
const a = atom('', 'a')
|
||||
|
||||
transaction((rollback) => {
|
||||
transaction(() => {
|
||||
a.set('b')
|
||||
})
|
||||
rollback()
|
||||
})
|
||||
|
||||
expect(a.get()).toBe('a')
|
||||
})
|
||||
|
||||
it('[T7] an outer rollback undoes an inner transaction committed during the reaction phase', () => {
|
||||
// Regression: committing a nested transaction inside an effect used to skip folding its
|
||||
// initial values into the parent, so the outer rollback could not restore them.
|
||||
const trigger = atom('', 0)
|
||||
const a = atom('', 'a')
|
||||
const b = atom('', 'b')
|
||||
|
||||
react('', () => {
|
||||
if (trigger.get() === 0) return
|
||||
transaction((rollback) => {
|
||||
b.set('B')
|
||||
transaction(() => {
|
||||
a.set('A')
|
||||
})
|
||||
rollback()
|
||||
})
|
||||
})
|
||||
trigger.set(1)
|
||||
|
||||
expect(a.get()).toBe('a')
|
||||
expect(b.get()).toBe('b')
|
||||
})
|
||||
|
||||
it('[T4] rollback restores computed signals too', () => {
|
||||
const firstName = atom('', 'John')
|
||||
const lastName = atom('', 'Doe')
|
||||
|
||||
const fullName = computed('', () => `${firstName.get()} ${lastName.get()}`)
|
||||
|
||||
transaction((rollback) => {
|
||||
firstName.set('Jane')
|
||||
lastName.set('Jones')
|
||||
expect(fullName.get()).toBe('Jane Jones')
|
||||
rollback()
|
||||
})
|
||||
|
||||
expect(fullName.get()).toBe('John Doe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transact (T)', () => {
|
||||
it('[T5] aborts and rethrows if the function throws', () => {
|
||||
const a = atom('', 'a')
|
||||
|
||||
try {
|
||||
transact(() => {
|
||||
a.set('b')
|
||||
throw new Error('blah')
|
||||
})
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe('blah')
|
||||
}
|
||||
|
||||
expect(a.get()).toBe('a')
|
||||
|
||||
expect.assertions(2)
|
||||
})
|
||||
|
||||
it('[T1][T8] joins the current transaction instead of nesting, so an inner throw restores nothing', () => {
|
||||
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.get()).toBe('c')
|
||||
})
|
||||
|
||||
expect(a.get()).toBe('c')
|
||||
|
||||
expect.assertions(3)
|
||||
})
|
||||
})
|
||||
275
packages/state/src/lib/capture.ts
Normal file
275
packages/state/src/lib/capture.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { attach, detach, singleton } from './helpers'
|
||||
import { isComputed } from './isComputed'
|
||||
import type { Child, Signal } from './types'
|
||||
|
||||
class CaptureStackFrame {
|
||||
offset = 0
|
||||
|
||||
maybeRemoved?: Signal<any>[]
|
||||
|
||||
constructor(
|
||||
public readonly below: CaptureStackFrame | null,
|
||||
public readonly child: Child
|
||||
) {}
|
||||
}
|
||||
|
||||
const inst = singleton('capture', () => ({ stack: null as null | CaptureStackFrame }))
|
||||
|
||||
/**
|
||||
* 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', Date.now())
|
||||
*
|
||||
* setInterval(() => {
|
||||
* time.set(Date.now())
|
||||
* })
|
||||
*
|
||||
* react('log name changes', () => {
|
||||
* print(name.get(), 'was changed at', unsafe__withoutCapture(() => time.get()))
|
||||
* })
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function unsafe__withoutCapture<T>(fn: () => T): T {
|
||||
const oldStack = inst.stack
|
||||
inst.stack = null
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
inst.stack = oldStack
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins capturing parent signal dependencies for the given child signal.
|
||||
*
|
||||
* This function initiates a capture session where any signal accessed via `.get()`
|
||||
* will be automatically registered as a dependency of the child signal. It sets up
|
||||
* the capture stack frame and clears the existing parent set to prepare for fresh
|
||||
* dependency tracking.
|
||||
*
|
||||
* @param child - The child signal (computed or effect) that will capture dependencies
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const effect = createEffect('myEffect', () => { /* ... *\/ })
|
||||
* startCapturingParents(effect)
|
||||
* // Now any signal.get() calls will be captured as dependencies
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function startCapturingParents(child: Child) {
|
||||
inst.stack = new CaptureStackFrame(inst.stack, child)
|
||||
if (child.__debug_ancestor_epochs__) {
|
||||
const previousAncestorEpochs = child.__debug_ancestor_epochs__
|
||||
child.__debug_ancestor_epochs__ = null
|
||||
for (const p of child.parents) {
|
||||
p.__unsafe__getWithoutCapture(true)
|
||||
}
|
||||
logChangedAncestors(child, previousAncestorEpochs)
|
||||
}
|
||||
child.parentSet.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the parent dependency capture session and finalizes the dependency graph.
|
||||
*
|
||||
* This function cleans up the capture session by removing dependencies that are no
|
||||
* longer needed, detaching signals that should no longer be parents, and updating
|
||||
* the dependency arrays to reflect the current set of captured parents. It must be
|
||||
* called after `startCapturingParents` to complete the capture cycle.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* startCapturingParents(effect)
|
||||
* // ... signal.get() calls happen here ...
|
||||
* stopCapturingParents() // Finalizes the dependency graph
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function stopCapturingParents() {
|
||||
const frame = inst.stack!
|
||||
inst.stack = frame.below
|
||||
const { child, offset, maybeRemoved } = frame
|
||||
|
||||
if (offset < child.parents.length) {
|
||||
for (let i = offset; i < child.parents.length; i++) {
|
||||
const maybeRemovedParent = child.parents[i]
|
||||
if (!child.parentSet.has(maybeRemovedParent)) {
|
||||
detach(maybeRemovedParent, child)
|
||||
}
|
||||
}
|
||||
|
||||
child.parents.length = offset
|
||||
child.parentEpochs.length = offset
|
||||
}
|
||||
|
||||
if (maybeRemoved) {
|
||||
for (let i = 0; i < maybeRemoved.length; i++) {
|
||||
const maybeRemovedParent = maybeRemoved[i]
|
||||
if (!child.parentSet.has(maybeRemovedParent)) {
|
||||
detach(maybeRemovedParent, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (child.__debug_ancestor_epochs__) {
|
||||
captureAncestorEpochs(child, child.__debug_ancestor_epochs__)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally captures a signal as a parent dependency during an active capture session.
|
||||
*
|
||||
* This function is called whenever a signal's `.get()` method is invoked during a
|
||||
* capture session. It checks if the signal should be added as a dependency and manages
|
||||
* the parent-child relationship in the reactive graph. The function handles deduplication,
|
||||
* attachment/detachment, and tracks changes in dependency order.
|
||||
*
|
||||
* Note: This must be called after the parent signal is up to date.
|
||||
*
|
||||
* @param p - The signal that might be captured as a parent dependency
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // This is called internally when you do:
|
||||
* const value = someAtom.get() // maybeCaptureParent(someAtom) is called
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function maybeCaptureParent(p: Signal<any, any>) {
|
||||
const stack = inst.stack
|
||||
if (!stack) return
|
||||
const { child } = stack
|
||||
|
||||
// `add` returns false when the parent was already captured this run. In array mode both
|
||||
// `has` and `add` scan with indexOf, so going straight to `add` halves the scans per
|
||||
// captured parent.
|
||||
if (!child.parentSet.add(p)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (child.isActivelyListening) {
|
||||
attach(p, child)
|
||||
}
|
||||
|
||||
// Parents are recorded in deref order. If the slot at this offset held a different parent
|
||||
// last run, that parent may have moved later in the order or been dropped; only
|
||||
// stopCapturingParents can tell, so remember it for then.
|
||||
if (stack.offset < child.parents.length) {
|
||||
const maybeRemovedParent = child.parents[stack.offset]
|
||||
if (maybeRemovedParent !== p) {
|
||||
if (!stack.maybeRemoved) {
|
||||
stack.maybeRemoved = [maybeRemovedParent]
|
||||
} else {
|
||||
stack.maybeRemoved.push(maybeRemovedParent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.parents[stack.offset] = p
|
||||
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. Nothing is logged for the run that
|
||||
* calls it; from the next run on, each run logs the ancestors that changed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const name = atom('name', 'Bob')
|
||||
* react('greeting', () => {
|
||||
* whyAmIRunning()
|
||||
* print('Hello', name.get())
|
||||
* })
|
||||
*
|
||||
* name.set('Alice')
|
||||
*
|
||||
* // Effect(greeting) is executing because:
|
||||
* // ↳ Atom(name) changed
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function whyAmIRunning() {
|
||||
const child = inst.stack?.child
|
||||
if (!child) {
|
||||
throw new Error('whyAmIRunning() called outside of a reactive context')
|
||||
}
|
||||
child.__debug_ancestor_epochs__ = new Map()
|
||||
}
|
||||
|
||||
function captureAncestorEpochs(child: Child, ancestorEpochs: Map<Signal<any>, number>) {
|
||||
for (let i = 0; i < child.parents.length; i++) {
|
||||
const parent = child.parents[i]
|
||||
ancestorEpochs.set(parent, child.parentEpochs[i])
|
||||
if (isComputed(parent)) {
|
||||
captureAncestorEpochs(parent as any, ancestorEpochs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChangeTree = { [signalName: string]: ChangeTree } | null
|
||||
function collectChangedAncestors(
|
||||
child: Child,
|
||||
ancestorEpochs: Map<Signal<any>, number>
|
||||
): NonNullable<ChangeTree> {
|
||||
const changeTree: ChangeTree = {}
|
||||
for (let i = 0; i < child.parents.length; i++) {
|
||||
const parent = child.parents[i]
|
||||
if (!ancestorEpochs.has(parent)) {
|
||||
continue
|
||||
}
|
||||
if (parent.lastChangedEpoch !== ancestorEpochs.get(parent)) {
|
||||
if (isComputed(parent)) {
|
||||
changeTree[parent.name] = collectChangedAncestors(parent as any, ancestorEpochs)
|
||||
} else {
|
||||
changeTree[parent.name] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return changeTree
|
||||
}
|
||||
|
||||
function logChangedAncestors(child: Child, ancestorEpochs: Map<Signal<any>, number>) {
|
||||
const changeTree = collectChangedAncestors(child, ancestorEpochs)
|
||||
if (Object.keys(changeTree).length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Effect(${child.name}) was executed manually.`)
|
||||
return
|
||||
}
|
||||
|
||||
let str = isComputed(child)
|
||||
? `Computed(${child.name}) is recomputing because:`
|
||||
: `Effect(${child.name}) is executing because:`
|
||||
|
||||
function logParent(tree: NonNullable<ChangeTree>, indent: number) {
|
||||
const indentStr = '\n' + ' '.repeat(indent) + '↳ '
|
||||
for (const [name, val] of Object.entries(tree)) {
|
||||
if (val) {
|
||||
str += `${indentStr}Computed(${name}) changed`
|
||||
logParent(val, indent + 2)
|
||||
} else {
|
||||
str += `${indentStr}Atom(${name}) changed`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logParent(changeTree, 1)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(str)
|
||||
}
|
||||
7
packages/state/src/lib/constants.ts
Normal file
7
packages/state/src/lib/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Sentinel below any real epoch. Derived signals and effects start with their epochs set to this
|
||||
* so they are dirty until their first run; real epochs start at `GLOBAL_START_EPOCH + 1`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const GLOBAL_START_EPOCH = -1
|
||||
115
packages/state/src/lib/helpers.ts
Normal file
115
packages/state/src/lib/helpers.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Child, Signal } from './types'
|
||||
|
||||
function isChild(x: any): x is Child {
|
||||
return x && typeof x === 'object' && 'parents' in x
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the child's parents changed since the child last recorded their epochs. O(parents);
|
||||
* returns at the first changed parent, so parents after it are not brought up to date.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function haveParentsChanged(child: Child): boolean {
|
||||
for (let i = 0, n = child.parents.length; i < n; i++) {
|
||||
const parent = child.parents[i]
|
||||
// Bring the parent up to date first: a computed parent's `lastChangedEpoch` only moves when
|
||||
// it is re-derived.
|
||||
parent.__unsafe__getWithoutCapture(true)
|
||||
|
||||
if (parent.lastChangedEpoch !== child.parentEpochs[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `child` from `parent.children`. A computed parent that loses its last child stops
|
||||
* listening itself, recursively, so a signal's `children` set is empty whenever nothing downstream
|
||||
* is actively listening.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function detach(parent: Signal<any>, child: Child) {
|
||||
if (!parent.children.remove(child)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (parent.children.isEmpty && isChild(parent)) {
|
||||
for (let i = 0, n = parent.parents.length; i < n; i++) {
|
||||
detach(parent.parents[i], parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `child` to `parent.children`. A computed parent that gains its first child starts
|
||||
* listening itself, recursively, so that changes to any ancestor are traversed down to the child.
|
||||
*
|
||||
* `parent` must be up to date when it is attached: `Computed` assumes an actively-listening
|
||||
* computed has been traversed for every ancestor change since it was last checked, which only
|
||||
* holds from the moment it started listening. Capture attaches a parent right after reading it;
|
||||
* `EffectScheduler.attach` refreshes its parents first.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function attach(parent: Signal<any>, child: Child) {
|
||||
if (!parent.children.add(child)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isChild(parent)) {
|
||||
for (let i = 0, n = parent.parents.length; i < n; i++) {
|
||||
attach(parent.parents[i], parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default equality used for change detection: `===`, then `Object.is` (so `NaN` equals
|
||||
* `NaN`; `0` and `-0` are already equal by `===`), then the old value's own `.equals(b)` method
|
||||
* if it has one. Only the old value's `equals` is consulted.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function equals(a: any, b: any): boolean {
|
||||
return (
|
||||
a === b || Object.is(a, b) || Boolean(a && b && typeof a.equals === 'function' && a.equals(b))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or retrieves a singleton instance using a global symbol registry.
|
||||
* This ensures that the same instance is shared across all code that uses
|
||||
* the same key, even across different module boundaries.
|
||||
*
|
||||
* The singleton is stored on `globalThis` using a symbol created with
|
||||
* `Symbol.for()`, which ensures global uniqueness across realms.
|
||||
*
|
||||
* @param key - A unique string identifier for the singleton
|
||||
* @param init - A function that creates the initial value if it doesn't exist
|
||||
* @returns The singleton instance
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create a singleton logger
|
||||
* const logger = singleton('logger', () => new Logger())
|
||||
*
|
||||
* // Elsewhere in the codebase, get the same logger instance
|
||||
* const sameLogger = singleton('logger', () => new Logger())
|
||||
* // logger === sameLogger
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function singleton<T>(key: string, init: () => T): T {
|
||||
const symbol = Symbol.for(`com.tldraw.state/${key}`)
|
||||
const global = globalThis as any
|
||||
global[symbol] ??= init()
|
||||
return global[symbol]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const EMPTY_ARRAY: [] = singleton('empty_array', () => Object.freeze([]) as any)
|
||||
20
packages/state/src/lib/isComputed.ts
Normal file
20
packages/state/src/lib/isComputed.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Returns true if the given value is a computed signal.
|
||||
* This is a type guard function that can be used to check if a value is a computed signal instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* const double = computed('double', () => count.get() * 2)
|
||||
*
|
||||
* console.log(isComputed(count)) // false
|
||||
* console.log(isComputed(double)) // true
|
||||
* ```
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns True if the value is a computed signal, false otherwise
|
||||
* @public
|
||||
*/
|
||||
export function isComputed(value: any): boolean {
|
||||
return !!(value && value.__isComputed === true)
|
||||
}
|
||||
34
packages/state/src/lib/isSignal.ts
Normal file
34
packages/state/src/lib/isSignal.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { _Atom } from './Atom'
|
||||
import { _Computed } from './Computed'
|
||||
import { Signal } from './types'
|
||||
|
||||
/**
|
||||
* Type guard function that determines whether a value is a signal (either an Atom or Computed).
|
||||
*
|
||||
* This utility function is helpful when working with mixed data types and you need to
|
||||
* differentiate between regular values and reactive signal containers. It returns `true`
|
||||
* if the provided value is either an atomic signal created with `atom()` or a computed
|
||||
* signal created with `computed()`.
|
||||
*
|
||||
* @param value - The value to check, can be of any type
|
||||
* @returns `true` if the value is a Signal (Atom or Computed), `false` otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom, computed, isSignal } from '@tldraw/state'
|
||||
*
|
||||
* const count = atom('count', 5)
|
||||
* const doubled = computed('doubled', () => count.get() * 2)
|
||||
* const regularValue = 'hello'
|
||||
*
|
||||
* console.log(isSignal(count)) // true
|
||||
* console.log(isSignal(doubled)) // true
|
||||
* console.log(isSignal(regularValue)) // false
|
||||
* console.log(isSignal(null)) // false
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function isSignal(value: any): value is Signal<any> {
|
||||
return value instanceof _Atom || value instanceof _Computed
|
||||
}
|
||||
94
packages/state/src/lib/localStorageAtom.ts
Normal file
94
packages/state/src/lib/localStorageAtom.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
|
||||
import { Atom, atom, AtomOptions } from './Atom'
|
||||
import { react } from './EffectScheduler'
|
||||
|
||||
/**
|
||||
* Creates a new {@link Atom} that persists its value to localStorage.
|
||||
*
|
||||
* The atom is automatically synced with localStorage - changes to the atom are saved to localStorage,
|
||||
* and the initial value is read from localStorage if it exists. Returns both the atom and a cleanup
|
||||
* function that should be called to stop syncing when the atom is no longer needed. If you need to delete
|
||||
* the atom, you should do it manually after all cleanup functions have been called.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const [theme, cleanup] = localStorageAtom('theme', 'light')
|
||||
*
|
||||
* theme.get() // 'light' or value from localStorage if it exists
|
||||
*
|
||||
* theme.set('dark') // updates atom and saves to localStorage
|
||||
*
|
||||
* // When done:
|
||||
* cleanup() // stops syncing to localStorage
|
||||
* ```
|
||||
*
|
||||
* @param name - The localStorage key and atom name. This is used for both localStorage persistence
|
||||
* and debugging/profiling purposes.
|
||||
* @param initialValue - The initial value of the atom, used if no value exists in localStorage.
|
||||
* @param options - Optional atom configuration. See {@link AtomOptions}.
|
||||
* @returns A tuple containing the atom and a cleanup function to stop localStorage syncing.
|
||||
* @public
|
||||
*/
|
||||
export function localStorageAtom<Value, Diff = unknown>(
|
||||
name: string,
|
||||
initialValue: Value,
|
||||
options?: AtomOptions<Value, Diff>
|
||||
): [Atom<Value, Diff>, () => void] {
|
||||
// Try to restore the initial value from localStorage
|
||||
let _initialValue = initialValue
|
||||
|
||||
try {
|
||||
const value = getFromLocalStorage(name)
|
||||
if (value) {
|
||||
_initialValue = JSON.parse(value) as Value
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, the stored value is corrupted - delete it and use the provided initial value
|
||||
deleteFromLocalStorage(name)
|
||||
}
|
||||
|
||||
// Create the atom with the restored or initial value
|
||||
const outAtom = atom(name, _initialValue, options)
|
||||
|
||||
// Set up automatic syncing: whenever the atom changes, save it to localStorage
|
||||
const reactCleanup = react(`save ${name} to localStorage`, () => {
|
||||
setInLocalStorage(name, JSON.stringify(outAtom.get()))
|
||||
})
|
||||
|
||||
// Set up cross-tab sync: listen for storage events from other tabs
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
// Only handle events for this specific key
|
||||
if (event.key !== name) return
|
||||
|
||||
// If the value was deleted in another tab
|
||||
if (event.newValue === null) {
|
||||
outAtom.set(initialValue)
|
||||
return
|
||||
}
|
||||
|
||||
// If the value was changed in another tab, update the atom
|
||||
try {
|
||||
const newValue = JSON.parse(event.newValue) as Value
|
||||
outAtom.set(newValue)
|
||||
} catch {
|
||||
// If parsing fails, the stored value is corrupted; preserve the existing value
|
||||
}
|
||||
}
|
||||
|
||||
// The storage helpers above tolerate environments without localStorage (Node, SSR); do the
|
||||
// same here rather than throwing on `window`.
|
||||
const canListen = typeof window !== 'undefined'
|
||||
if (canListen) {
|
||||
window.addEventListener('storage', handleStorageEvent)
|
||||
}
|
||||
|
||||
// Combined cleanup function
|
||||
const cleanup = () => {
|
||||
reactCleanup()
|
||||
if (canListen) {
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return [outAtom, cleanup]
|
||||
}
|
||||
416
packages/state/src/lib/transactions.ts
Normal file
416
packages/state/src/lib/transactions.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { _Atom } from './Atom'
|
||||
import { GLOBAL_START_EPOCH } from './constants'
|
||||
import { singleton } from './helpers'
|
||||
import { Child, Signal } from './types'
|
||||
|
||||
interface Reactor {
|
||||
maybeScheduleEffect(): void
|
||||
lastTraversedEpoch: number
|
||||
}
|
||||
|
||||
class Transaction {
|
||||
asyncProcessCount = 0
|
||||
constructor(
|
||||
public readonly parent: Transaction | null,
|
||||
public readonly isSync: boolean
|
||||
) {}
|
||||
|
||||
initialAtomValues = new Map<_Atom, any>()
|
||||
|
||||
commit() {
|
||||
if (this.parent === null) {
|
||||
// For root transactions, flush changed atoms
|
||||
flushChanges(this.initialAtomValues.keys())
|
||||
return
|
||||
}
|
||||
// For transactions with parents, add the transaction's initial values to the parent's, so
|
||||
// an outer rollback can still undo them (T7). This must come before any "are we reacting?" special case: a nested
|
||||
// transaction committed by an effect during the reaction phase used to skip this fold and
|
||||
// its changes survived the outer rollback.
|
||||
// A parent that has recorded nothing yet adopts the map outright: this transaction is
|
||||
// finished with it, and the common nested case is a single inner transaction doing all
|
||||
// the writes.
|
||||
const parentValues = this.parent!.initialAtomValues
|
||||
if (parentValues.size === 0) {
|
||||
this.parent!.initialAtomValues = this.initialAtomValues
|
||||
return
|
||||
}
|
||||
this.initialAtomValues.forEach((value, atom) => {
|
||||
if (!parentValues.has(atom)) {
|
||||
parentValues.set(atom, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the transaction. Restores every atom changed in this transaction to its initial
|
||||
* value, then commits. Must be called while this is still `inst.currentTransaction`, so that
|
||||
* the restoring sets are recorded against it instead of each flushing effects on their own.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
abort() {
|
||||
inst.globalEpoch++
|
||||
|
||||
this.initialAtomValues.forEach((value, atom) => {
|
||||
atom.set(value)
|
||||
atom.historyBuffer?.clear()
|
||||
})
|
||||
|
||||
this.commit()
|
||||
}
|
||||
}
|
||||
|
||||
const inst = singleton('transactions', () => ({
|
||||
// The current epoch (global to all atoms).
|
||||
globalEpoch: GLOBAL_START_EPOCH + 1,
|
||||
// Whether any transaction is reacting.
|
||||
globalIsReacting: false,
|
||||
currentTransaction: null as Transaction | null,
|
||||
|
||||
cleanupReactors: null as null | Set<Reactor>,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Gets the current global epoch, which is incremented every time any atom changes.
|
||||
* This is used to track changes across the entire reactive system.
|
||||
*
|
||||
* @returns The current global epoch number
|
||||
* @public
|
||||
*/
|
||||
export function getGlobalEpoch() {
|
||||
return inst.globalEpoch
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a transaction (sync or async) is currently open. While one is, atom changes are
|
||||
* recorded but their children are not traversed until it commits.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getIsInTransaction() {
|
||||
return inst.currentTransaction !== null
|
||||
}
|
||||
|
||||
// The set `traverseChild` collects reactors into. Module-level rather than a closure so that a
|
||||
// flush over thousands of atoms doesn't allocate a visitor per atom; traversal never runs user
|
||||
// code, so nothing can re-enter and swap it mid-walk.
|
||||
let traverseReactors: Set<Reactor>
|
||||
|
||||
function traverseChild(child: Child) {
|
||||
if (child.lastTraversedEpoch === inst.globalEpoch) {
|
||||
return
|
||||
}
|
||||
|
||||
child.lastTraversedEpoch = inst.globalEpoch
|
||||
|
||||
if ('__isEffectScheduler' in child) {
|
||||
traverseReactors.add(child as unknown as Reactor)
|
||||
} else {
|
||||
;(child as any as Signal<any>).children.visit(traverseChild)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all of the reactors that need to run for an atom and run them. During the reaction
|
||||
* phase the affected effects are instead queued for the cleanup pass, so a change made by an
|
||||
* effect never interrupts the pass that is running (P4).
|
||||
*
|
||||
* @param atoms - The atoms to flush changes for.
|
||||
*/
|
||||
function flushChanges(atoms: Iterable<_Atom>) {
|
||||
if (inst.globalIsReacting) {
|
||||
for (const atom of atoms) {
|
||||
traverseAtomForCleanup(atom)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const outerTxn = inst.currentTransaction
|
||||
try {
|
||||
// clear the transaction stack. Transactions started by effects must be roots: the committing
|
||||
// transaction (if any) has already handed its changes to this flush.
|
||||
inst.currentTransaction = null
|
||||
inst.globalIsReacting = true
|
||||
|
||||
const reactors = new Set<Reactor>()
|
||||
traverseReactors = reactors
|
||||
for (const atom of atoms) {
|
||||
atom.children.visit(traverseChild)
|
||||
}
|
||||
|
||||
// Run each reactor.
|
||||
for (const r of reactors) {
|
||||
r.maybeScheduleEffect()
|
||||
}
|
||||
|
||||
let updateDepth = 0
|
||||
while (inst.cleanupReactors?.size) {
|
||||
if (updateDepth++ > 1000) {
|
||||
throw new Error('Reaction update depth limit exceeded')
|
||||
}
|
||||
const reactors = inst.cleanupReactors
|
||||
inst.cleanupReactors = null
|
||||
for (const r of reactors) {
|
||||
r.maybeScheduleEffect()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inst.cleanupReactors = null
|
||||
inst.globalIsReacting = false
|
||||
inst.currentTransaction = outerTxn
|
||||
traverseReactors = undefined! // free memory
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function atomDidChange(atom: _Atom, previousValue: any) {
|
||||
if (inst.currentTransaction) {
|
||||
// If we are in a transaction, then all we have to do is preserve
|
||||
// the value of the atom at the start of the transaction in case
|
||||
// we need to roll back. Children are not traversed until the
|
||||
// transaction commits, which is why `Computed` must not trust its
|
||||
// traversal-based cache shortcut while a transaction is open.
|
||||
if (!inst.currentTransaction.initialAtomValues.has(atom)) {
|
||||
inst.currentTransaction.initialAtomValues.set(atom, previousValue)
|
||||
}
|
||||
} else {
|
||||
// If there is no transaction, flush the changes immediately.
|
||||
flushChanges([atom])
|
||||
}
|
||||
}
|
||||
|
||||
function traverseAtomForCleanup(atom: _Atom) {
|
||||
traverseReactors = inst.cleanupReactors ??= new Set()
|
||||
atom.children.visit(traverseChild)
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function advanceGlobalEpoch() {
|
||||
inst.globalEpoch++
|
||||
}
|
||||
|
||||
/**
|
||||
* Batches state updates, deferring side effects until after the transaction completes.
|
||||
* Unlike {@link transact}, this function always creates a new transaction, allowing for nested transactions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const firstName = atom('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
*
|
||||
* react('greet', () => {
|
||||
* console.log(`Hello, ${firstName.get()} ${lastName.get()}!`)
|
||||
* })
|
||||
*
|
||||
* // 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. An aborted transaction still flushes effects: effects whose parents went through a change-and-restore round trip are checked again and, if a parent's value differs from what they last saw (an atom they read directly always will), run once more with the restored values.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const firstName = atom('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
*
|
||||
* react('greet', () => {
|
||||
* console.log(`Hello, ${firstName.get()} ${lastName.get()}!`)
|
||||
* })
|
||||
*
|
||||
* // Logs "Hello, John Doe!"
|
||||
*
|
||||
* transaction(() => {
|
||||
* firstName.set('Jane')
|
||||
* throw new Error('oops')
|
||||
* })
|
||||
*
|
||||
* // firstName.get() === 'John'
|
||||
* // Logs "Hello, John Doe!" again: effects whose parents were changed and restored still run,
|
||||
* // and observe the restored values.
|
||||
* ```
|
||||
*
|
||||
* 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('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
*
|
||||
* react('greet', () => {
|
||||
* console.log(`Hello, ${firstName.get()} ${lastName.get()}!`)
|
||||
* })
|
||||
*
|
||||
* // Logs "Hello, John Doe!"
|
||||
*
|
||||
* transaction((rollback) => {
|
||||
* firstName.set('Jane')
|
||||
* lastName.set('Smith')
|
||||
* rollback()
|
||||
* })
|
||||
*
|
||||
* // firstName.get() === 'John'
|
||||
* // lastName.get() === 'Doe'
|
||||
* // Logs "Hello, John Doe!" again, as above.
|
||||
* ```
|
||||
*
|
||||
* @param fn - The function to run in a transaction, called with a function to roll back the change.
|
||||
* @returns The return value of the function
|
||||
* @public
|
||||
*/
|
||||
export function transaction<T>(fn: (rollback: () => void) => T) {
|
||||
const txn = new Transaction(inst.currentTransaction, true)
|
||||
|
||||
inst.currentTransaction = txn
|
||||
|
||||
try {
|
||||
let result = undefined as T | undefined
|
||||
let rollback = false
|
||||
|
||||
try {
|
||||
result = fn(() => (rollback = true))
|
||||
} catch (e) {
|
||||
txn.abort()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (inst.currentTransaction !== txn) {
|
||||
throw new Error('Transaction boundaries overlap')
|
||||
}
|
||||
|
||||
if (rollback) {
|
||||
txn.abort()
|
||||
} else {
|
||||
txn.commit()
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
inst.currentTransaction = txn.parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link transaction}, but does not create a new transaction if there is already one in progress.
|
||||
* This is the preferred way to batch state updates when you don't need the rollback functionality.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = atom('count', 0)
|
||||
* const doubled = atom('doubled', 0)
|
||||
*
|
||||
* react('update doubled', () => {
|
||||
* console.log(`Count: ${count.get()}, Doubled: ${doubled.get()}`)
|
||||
* })
|
||||
*
|
||||
* // This batches both updates into a single reaction
|
||||
* transact(() => {
|
||||
* count.set(5)
|
||||
* doubled.set(count.get() * 2)
|
||||
* })
|
||||
* // Logs: "Count: 5, Doubled: 10"
|
||||
* ```
|
||||
*
|
||||
* @param fn - The function to run in a transaction
|
||||
* @returns The return value of the function
|
||||
* @public
|
||||
*/
|
||||
export function transact<T>(fn: () => T): T {
|
||||
if (inst.currentTransaction) {
|
||||
return fn()
|
||||
}
|
||||
return transaction(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defers the execution of asynchronous effects until they can be properly handled.
|
||||
* This function creates an asynchronous transaction context that batches state updates
|
||||
* across async operations while preventing conflicts with synchronous transactions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const data = atom('data', null)
|
||||
* const loading = atom('loading', false)
|
||||
*
|
||||
* await deferAsyncEffects(async () => {
|
||||
* loading.set(true)
|
||||
* const result = await fetch('/api/data')
|
||||
* const json = await result.json()
|
||||
* data.set(json)
|
||||
* loading.set(false)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @param fn - The async function to execute within the deferred context
|
||||
* @returns A promise that resolves to the return value of the function
|
||||
* @throws Will throw if called during a synchronous transaction
|
||||
* @internal
|
||||
*/
|
||||
export async function deferAsyncEffects<T>(fn: () => Promise<T>) {
|
||||
// Can't kick off async transactions during a sync transaction because
|
||||
// the async transaction won't finish until after the sync transaction
|
||||
// is done.
|
||||
if (inst.currentTransaction?.isSync) {
|
||||
throw new Error('deferAsyncEffects cannot be called during a sync transaction')
|
||||
}
|
||||
|
||||
// Can't kick off async transactions during a reaction phase at the moment,
|
||||
// because the transaction stack is cleared after the reaction phase.
|
||||
// So wait until the path ahead is clear
|
||||
while (inst.globalIsReacting) {
|
||||
await new Promise((r) => queueMicrotask(() => r(null)))
|
||||
}
|
||||
|
||||
const txn = inst.currentTransaction ?? new Transaction(null, false)
|
||||
|
||||
// don't think this can happen, but just in case
|
||||
if (txn.isSync) throw new Error('deferAsyncEffects cannot be called during a sync transaction')
|
||||
|
||||
inst.currentTransaction = txn
|
||||
txn.asyncProcessCount++
|
||||
|
||||
let result = undefined as T | undefined
|
||||
|
||||
// `undefined` means "did not throw"; a thrown `undefined` is stored as `null` so it still counts.
|
||||
let error = undefined as any
|
||||
try {
|
||||
// Run the function.
|
||||
result = await fn()
|
||||
} catch (e) {
|
||||
// Abort the transaction if the function throws.
|
||||
error = e ?? null
|
||||
}
|
||||
|
||||
if (--txn.asyncProcessCount > 0) {
|
||||
// Other processes still share this transaction; it settles when the last one finishes.
|
||||
if (typeof error !== 'undefined') {
|
||||
// If the rollback was triggered, abort the transaction.
|
||||
throw error
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Settle while this is still the current transaction (see `Transaction.abort`): clearing it
|
||||
// first made every restoring set inside `abort()` flush effects on its own, so effects
|
||||
// observed half-restored state.
|
||||
try {
|
||||
if (typeof error !== 'undefined') {
|
||||
// If the rollback was triggered, abort the transaction.
|
||||
txn.abort()
|
||||
throw error
|
||||
} else {
|
||||
txn.commit()
|
||||
return result
|
||||
}
|
||||
} finally {
|
||||
inst.currentTransaction = null
|
||||
}
|
||||
}
|
||||
228
packages/state/src/lib/types.ts
Normal file
228
packages/state/src/lib/types.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { ArraySet } from './ArraySet'
|
||||
|
||||
/**
|
||||
* A unique symbol used to indicate that a signal's value should be reset or that
|
||||
* there is insufficient history to compute diffs between epochs.
|
||||
*
|
||||
* This value is returned by {@link Signal.getDiffSince} when the requested epoch
|
||||
* is too far in the past and the diff sequence cannot be reconstructed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom, RESET_VALUE } from '@tldraw/state'
|
||||
*
|
||||
* const count = atom('count', 0, { historyLength: 3, computeDiff: (prev, next) => next - prev })
|
||||
* const oldEpoch = count.lastChangedEpoch
|
||||
*
|
||||
* // Make more changes than the history length can hold
|
||||
* count.set(1)
|
||||
* count.set(2)
|
||||
* count.set(3)
|
||||
* count.set(4)
|
||||
*
|
||||
* const diffs = count.getDiffSince(oldEpoch)
|
||||
* if (diffs === RESET_VALUE) {
|
||||
* console.log('Too many changes, need to reset state')
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const RESET_VALUE: unique symbol = Symbol.for('com.tldraw.state/RESET_VALUE')
|
||||
|
||||
/**
|
||||
* Type representing the the unique symbol RESET_VALUE symbol, used in type annotations
|
||||
* to indicate when a signal value should be reset or when diff computation
|
||||
* cannot proceed due to insufficient history.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RESET_VALUE = typeof RESET_VALUE
|
||||
|
||||
/**
|
||||
* A reactive value container that can change over time and track diffs between sequential values.
|
||||
*
|
||||
* Signals are the foundation of the \@tldraw/state reactive system. They automatically manage
|
||||
* dependencies and trigger updates when their values change. Any computed signal or effect
|
||||
* that reads from this signal will be automatically recomputed when the signal's value changes.
|
||||
*
|
||||
* There are two types of signal:
|
||||
* - **Atomic signals** - Created using `atom()`. These are mutable containers that can be
|
||||
* directly updated using `set()` or `update()` methods.
|
||||
* - **Computed signals** - Created using `computed()`. These derive their values from other
|
||||
* signals and are automatically recomputed when dependencies change.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom, computed } from '@tldraw/state'
|
||||
*
|
||||
* // Create an atomic signal
|
||||
* const count = atom('count', 0)
|
||||
*
|
||||
* // Create a computed signal that derives from the atom
|
||||
* const doubled = computed('doubled', () => count.get() * 2)
|
||||
*
|
||||
* console.log(doubled.get()) // 0
|
||||
* count.set(5)
|
||||
* console.log(doubled.get()) // 10
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface Signal<Value, Diff = unknown> {
|
||||
/**
|
||||
* A human-readable identifier for this signal, used primarily for debugging and performance profiling.
|
||||
*
|
||||
* The name is displayed in debug output from {@link whyAmIRunning} and other diagnostic tools.
|
||||
* It does not need to be globally unique within your application.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Gets the current value of the signal and establishes a dependency relationship.
|
||||
*
|
||||
* When called from within a computed signal or effect, this signal will be automatically
|
||||
* tracked as a dependency. If this signal's value changes, any dependent computations
|
||||
* or effects will be marked for re-execution.
|
||||
*
|
||||
* @returns The current value stored in the signal
|
||||
*/
|
||||
get(): Value
|
||||
|
||||
/**
|
||||
* The global epoch number when this signal's value last changed.
|
||||
*
|
||||
* Note that this represents when the value actually changed, not when it was last computed.
|
||||
* A computed signal may recalculate and produce the same value without changing its epoch.
|
||||
* This is used internally for dependency tracking and history management.
|
||||
*/
|
||||
lastChangedEpoch: number
|
||||
/**
|
||||
* Gets the sequence of diffs that occurred between a specific epoch and the current state.
|
||||
*
|
||||
* This method enables incremental synchronization by providing a list of changes that
|
||||
* have occurred since a specific point in time. If the requested epoch is too far in
|
||||
* the past or the signal doesn't have enough history, it returns the unique symbol RESET_VALUE
|
||||
* to indicate that a full state reset is required.
|
||||
*
|
||||
* @param epoch - The epoch timestamp to get diffs since
|
||||
* @returns An array of diff objects representing changes since the epoch, or the unique symbol RESET_VALUE if insufficient history is available
|
||||
*/
|
||||
getDiffSince(epoch: number): RESET_VALUE | Diff[]
|
||||
/**
|
||||
* Gets the current value of the signal without establishing a dependency relationship.
|
||||
*
|
||||
* This method bypasses the automatic dependency tracking system, making it useful for
|
||||
* performance-critical code paths where the overhead of dependency capture would be
|
||||
* problematic. Use with caution as it breaks the reactive guarantees of the system.
|
||||
*
|
||||
* **Warning**: This method should only be used when you're certain that you don't need
|
||||
* the calling context to react to changes in this signal.
|
||||
*
|
||||
* @param ignoreErrors - Whether to suppress errors during value retrieval (optional)
|
||||
* @returns The current value without establishing dependencies
|
||||
*/
|
||||
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value
|
||||
/** @internal */
|
||||
children: ArraySet<Child>
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal interface representing a child node in the signal dependency graph.
|
||||
*
|
||||
* This interface is used internally by the reactive system to manage dependencies
|
||||
* between signals, computed values, and effects. Each child tracks its parent
|
||||
* signals and maintains state needed for efficient dependency graph traversal
|
||||
* and change propagation.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface Child {
|
||||
/**
|
||||
* The epoch when this child was last traversed during dependency graph updates.
|
||||
* Used to prevent redundant traversals during change propagation.
|
||||
*/
|
||||
lastTraversedEpoch: number
|
||||
|
||||
/**
|
||||
* Set of parent signals that this child depends on.
|
||||
* Used for efficient lookup and cleanup operations.
|
||||
*/
|
||||
readonly parentSet: ArraySet<Signal<any, any>>
|
||||
|
||||
/**
|
||||
* Array of parent signals that this child depends on.
|
||||
* Maintained in parallel with parentSet for ordered access.
|
||||
*/
|
||||
readonly parents: Signal<any, any>[]
|
||||
|
||||
/**
|
||||
* Array of epochs corresponding to each parent signal.
|
||||
* Used to detect which parents have changed since last computation.
|
||||
*/
|
||||
readonly parentEpochs: number[]
|
||||
|
||||
/**
|
||||
* Human-readable name for this child, used in debugging output.
|
||||
*/
|
||||
readonly name: string
|
||||
|
||||
/**
|
||||
* Whether this child is currently subscribed to change notifications.
|
||||
* Used to optimize resource usage by unsubscribing inactive dependencies.
|
||||
*/
|
||||
isActivelyListening: boolean
|
||||
|
||||
/**
|
||||
* Debug information tracking ancestor epochs in the dependency graph.
|
||||
* Only populated in debug builds for diagnostic purposes.
|
||||
*/
|
||||
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A function type that computes the difference between two values of a signal.
|
||||
*
|
||||
* This function is used to generate incremental diffs that can be applied to
|
||||
* reconstruct state changes over time, so that downstream computeds and effects can
|
||||
* update incrementally instead of recomputing from scratch.
|
||||
*
|
||||
* The function should analyze the previous and current values and return a
|
||||
* diff object that represents the change. If the diff cannot be computed
|
||||
* (e.g., the values are too different or incompatible), it should return
|
||||
* the unique symbol RESET_VALUE to indicate that a full state reset is required.
|
||||
*
|
||||
* @param previousValue - The previous value of the signal
|
||||
* @param currentValue - The current value of the signal
|
||||
* @param lastComputedEpoch - For an atom, the epoch when the previous value was set. For a computed, the epoch at which it was last checked (the same value its compute function receives), so that `other.getDiffSince(lastComputedEpoch)` yields exactly the changes not yet accounted for.
|
||||
* @param currentEpoch - The epoch when the current value was set
|
||||
* @returns A diff object representing the change, or the unique symbol RESET_VALUE if no diff can be computed
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom, RESET_VALUE } from '@tldraw/state'
|
||||
*
|
||||
* // Simple numeric diff
|
||||
* const numberDiff: ComputeDiff<number, number> = (prev, curr) => curr - prev
|
||||
*
|
||||
* // Array diff with reset fallback
|
||||
* const arrayDiff: ComputeDiff<string[], { added: string[], removed: string[] }> = (prev, curr) => {
|
||||
* if (prev.length > 1000 || curr.length > 1000) {
|
||||
* return RESET_VALUE // Too complex, force reset
|
||||
* }
|
||||
* return {
|
||||
* added: curr.filter(item => !prev.includes(item)),
|
||||
* removed: prev.filter(item => !curr.includes(item))
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const count = atom('count', 0, { computeDiff: numberDiff })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ComputeDiff<Value, Diff> = (
|
||||
previousValue: Value,
|
||||
currentValue: Value,
|
||||
lastComputedEpoch: number,
|
||||
currentEpoch: number
|
||||
) => Diff | RESET_VALUE
|
||||
50
packages/state/src/lib/warnings.ts
Normal file
50
packages/state/src/lib/warnings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { warnOnce } from '@tldraw/utils'
|
||||
|
||||
/**
|
||||
* Logs a deprecation warning for the deprecated `@computed` getter decorator syntax.
|
||||
* This function is called internally when the library detects usage of `@computed`
|
||||
* on a getter method instead of the recommended method syntax.
|
||||
*
|
||||
* The warning is only shown once per application session to avoid spam in the console.
|
||||
* It provides clear guidance on how to migrate from the deprecated getter syntax
|
||||
* to the current method-based approach.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Deprecated pattern that triggers this warning:
|
||||
* class MyClass {
|
||||
* @computed
|
||||
* get value() {
|
||||
* return this.someAtom.get()
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Recommended pattern:
|
||||
* class MyClass {
|
||||
* @computed
|
||||
* getValue() {
|
||||
* return this.someAtom.get()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function logComputedGetterWarning() {
|
||||
warnOnce(
|
||||
`Using \`@computed\` as a decorator for getters is deprecated and will be removed in the near future. Please refactor to use \`@computed\` as a decorator for methods.
|
||||
|
||||
// Before
|
||||
@computed
|
||||
get foo() {
|
||||
return 'foo'
|
||||
}
|
||||
|
||||
// After
|
||||
@computed
|
||||
getFoo() {
|
||||
return 'foo'
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
7
packages/state/tsconfig.json
Normal file
7
packages/state/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
|
||||
"references": [{ "path": "../utils" }]
|
||||
}
|
||||
6
packages/state/vitest.config.ts
Normal file
6
packages/state/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// State package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
Reference in New Issue
Block a user