feat: animate completed items out
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m25s

This commit is contained in:
2026-09-11 08:56:51 +08:00
parent 36952fdbcc
commit 8b475e4df5
7 changed files with 121 additions and 22 deletions
+19
View File
@@ -3,6 +3,25 @@ type CompletionId = string
type CompletionTimer = number
type CompletionFrame = number
export const COMPLETION_EXIT_DURATION = 320
export function shouldAnimateCompletionExit({ completing, showCompleted }: { completing: boolean; showCompleted: boolean }) {
return completing && !showCompleted
}
export function completionExitDelay(reducedMotion: boolean) {
return reducedMotion ? 0 : COMPLETION_EXIT_DURATION
}
export function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
}
export function waitForCompletionExit(reducedMotion = prefersReducedMotion()) {
const delay = completionExitDelay(reducedMotion)
return delay ? new Promise<void>((resolve) => window.setTimeout(resolve, delay)) : Promise.resolve()
}
export function createCompletionPulse(
activate: (id: CompletionId) => void,
deactivate: (id: CompletionId) => void,
+10 -4
View File
@@ -227,13 +227,15 @@ export async function runLatestRequest<T>(
return committed
}
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
export type MutationReconciler<TContext> = {
run<T>(
mutation: () => Promise<T>,
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: (value: T) => void,
): Promise<void>
onCurrentSuccess?: MutationSuccessCallback<T>,
): Promise<boolean>
}
export function createMutationReconciler<TContext>(
@@ -269,12 +271,16 @@ export function createMutationReconciler<TContext>(
value = await mutation()
} catch (reason) {
onError?.(reason)
return
return false
}
onSuccess(value)
if (sameContext(context, currentContext())) onCurrentSuccess?.(value)
if (sameContext(context, currentContext())) {
const currentSuccess = onCurrentSuccess?.(value)
if (currentSuccess instanceof Promise) await currentSuccess
}
await reconcile(context)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
return true
},
}
}
+25
View File
@@ -78,6 +78,31 @@ describe('request generation protection', () => {
await Promise.all([first, second])
})
it('waits for current-context success work before refreshing', async () => {
const events: string[] = []
let releaseExit!: () => void
const reconciler = createMutationReconciler(
() => ({ view: 'today' }),
(left, right) => left.view === right.view,
async () => { events.push('refresh') },
)
const mutation = reconciler.run(
async () => 'done',
() => events.push('success'),
undefined,
async () => {
events.push('exit:start')
await new Promise<void>((resolve) => { releaseExit = resolve })
events.push('exit:end')
},
)
await Promise.resolve()
expect(events).toEqual(['success', 'exit:start'])
releaseExit()
await mutation
expect(events).toEqual(['success', 'exit:start', 'exit:end', 'refresh'])
})
it('refreshes again when the first refresh finishes before the second mutation commits', async () => {
const events: string[] = []
const context = { view: 'today' }