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
256 lines
5.9 KiB
TypeScript
256 lines
5.9 KiB
TypeScript
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')
|
|
})
|
|
})
|