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:
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user