Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/services/createActivityTracker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from 'vitest'
import { createActivityTracker } from '@/services/createActivityTracker'

describe('createActivityTracker', () => {
test('given no work, idle resolves', async () => {
const tracker = createActivityTracker()

await expect(tracker.idle()).resolves.toBeUndefined()
})

test('given outstanding work, idle waits for it', async () => {
const tracker = createActivityTracker()
const { promise, resolve } = Promise.withResolvers<string>()
let settled = false

tracker.add(promise)

const waiting = tracker.idle().then(() => {
settled = true
})

expect(settled).toBe(false)

resolve('done')
await waiting

expect(settled).toBe(true)
})

test('given work that rejects, idle still resolves', async () => {
const tracker = createActivityTracker()

tracker.add(Promise.reject(new Error('nope')))

await expect(tracker.idle()).resolves.toBeUndefined()
})

test('given work registered while idle is waiting, waits for that too', async () => {
const tracker = createActivityTracker()
const first = Promise.withResolvers<string>()
const second = Promise.withResolvers<string>()
let settled = false

tracker.add(first.promise)

const waiting = tracker.idle().then(() => {
settled = true
})

tracker.add(second.promise)
first.resolve('one')
await Promise.resolve()

expect(settled).toBe(false)

second.resolve('two')
await waiting

expect(settled).toBe(true)
})

test('wrap tracks a call for its whole duration', async () => {
const tracker = createActivityTracker()
const { promise, resolve } = Promise.withResolvers<string>()
let settled = false

const wrapped = tracker.wrap(() => promise)
const call = wrapped()

const waiting = tracker.idle().then(() => {
settled = true
})

expect(settled).toBe(false)

resolve('done')
await waiting

expect(settled).toBe(true)
await expect(call).resolves.toBe('done')
})
})
39 changes: 39 additions & 0 deletions src/services/createActivityTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type ActivityTracker = {
add: (...work: Promise<unknown>[]) => void,
wrap: <TArgs extends unknown[], TResult>(fn: (...args: TArgs) => Promise<TResult>) => (...args: TArgs) => Promise<TResult>,
idle: () => Promise<void>,
}

export function createActivityTracker(): ActivityTracker {
const outstanding = new Set<Promise<unknown>>()

const add: ActivityTracker['add'] = (...work) => {
work.forEach((promise) => {
outstanding.add(promise)

void promise.catch(() => undefined).finally(() => outstanding.delete(promise))
})
}

const wrap: ActivityTracker['wrap'] = (fn) => (...args) => {
const result = fn(...args)

add(result)

return result
}

// Loops because settling one promise can register more: a loader that pushes starts a navigation
// whose own values must also settle.
const idle: ActivityTracker['idle'] = async () => {
while (outstanding.size > 0) {
await Promise.allSettled([...outstanding])
}
}

return {
add,
wrap,
idle,
}
}
Loading