perf: streamline view data loading
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-10 08:06:52 +08:00
parent a5ece8ca42
commit 98578b4f36
7 changed files with 545 additions and 75 deletions
+146 -4
View File
@@ -153,7 +153,113 @@ export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
}
let habitGridCache: { week: string; habits: unknown[] } | null = null
let countdownCache: { items: unknown[]; archived: unknown[] } | null = null
let countdownCache: { items: unknown[]; archived: unknown[]; writtenAt: number } | null = null
let countdownInFlight: Promise<{ items: unknown[]; archived: unknown[] }> | null = null
let countdownCacheGeneration = 0
const requestGenerations = new Map<string, number>()
export function beginLatestRequest(key: string) {
const generation = (requestGenerations.get(key) ?? 0) + 1
requestGenerations.set(key, generation)
return generation
}
export function isLatestRequest(key: string, generation: number) {
return requestGenerations.get(key) === generation
}
export type RequestContext = { key: string; generation: number }
export function captureRequestContext(key: string): RequestContext {
return { key, generation: requestGenerations.get(key) ?? 0 }
}
export function commitIfRequestContextCurrent(context: RequestContext, commit: () => void) {
if (!isLatestRequest(context.key, context.generation)) return false
commit()
return true
}
export async function runLatestRequest<T>(
key: string,
request: () => Promise<T>,
callbacks: {
success: (value: T) => void
error: (reason: unknown) => void
finally: () => void
},
) {
const generation = beginLatestRequest(key)
try {
const value = await request()
if (isLatestRequest(key, generation)) callbacks.success(value)
} catch (reason) {
if (isLatestRequest(key, generation)) callbacks.error(reason)
} finally {
if (isLatestRequest(key, generation)) callbacks.finally()
}
}
export type MutationReconciler<TContext> = {
run<T>(
mutation: () => Promise<T>,
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: (value: T) => void,
): Promise<void>
}
export function createMutationReconciler<TContext>(
currentContext: () => TContext,
sameContext: (left: TContext, right: TContext) => boolean,
refresh: () => Promise<unknown>,
): MutationReconciler<TContext> {
let dirty = 0
let reconciled = 0
let refreshLoop: Promise<void> | null = null
const reconcile = (context: TContext) => {
if (!sameContext(context, currentContext())) return Promise.resolve()
dirty += 1
if (!refreshLoop) {
refreshLoop = (async () => {
while (reconciled < dirty && sameContext(context, currentContext())) {
const target = dirty
await refresh()
if (!sameContext(context, currentContext())) break
reconciled = target
}
})().finally(() => { refreshLoop = null })
}
return refreshLoop
}
return {
async run(mutation, onSuccess, onError, onCurrentSuccess) {
const context = currentContext()
let value: Awaited<ReturnType<typeof mutation>>
try {
value = await mutation()
} catch (reason) {
onError?.(reason)
return
}
onSuccess(value)
if (sameContext(context, currentContext())) onCurrentSuccess?.(value)
await reconcile(context)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
},
}
}
export function startPrimaryWithBackground<T>(
primary: Array<() => Promise<T>>,
background: () => Promise<unknown>,
) {
const pending = primary.map((start) => start())
void background().catch(() => undefined)
return Promise.all(pending)
}
export function readHabitGridCache<T>(week: string): T[] | null {
return habitGridCache?.week === week ? habitGridCache.habits as T[] : null
@@ -164,11 +270,47 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
}
export function readCountdownCache<T>() {
return countdownCache as { items: T[]; archived: T[] } | null
if (!countdownCache) return null
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
}
export function writeCountdownCache<T>(items: T[], archived: T[]) {
countdownCache = { items, archived }
export function writeCountdownCache<T>(items: T[], archived: T[], now = Date.now()) {
countdownCache = { items, archived, writtenAt: now }
}
export function invalidateCountdownCache() {
countdownCacheGeneration += 1
countdownCache = null
countdownInFlight = null
}
export function getCountdownCacheGeneration() {
return countdownCacheGeneration
}
export function isCountdownCacheGenerationCurrent(generation: number) {
return generation === countdownCacheGeneration
}
export function loadCountdownCache<T>(
fetcher: () => Promise<{ items: T[]; archived: T[] }>,
options: { now?: number; maxAge?: number; force?: boolean } = {},
) {
const now = options.now ?? Date.now()
const maxAge = options.maxAge ?? 30_000
if (!options.force && countdownCache && now - countdownCache.writtenAt < maxAge) {
return Promise.resolve(readCountdownCache<T>()!)
}
if (countdownInFlight) return countdownInFlight as Promise<{ items: T[]; archived: T[] }>
const generation = countdownCacheGeneration
const pending = fetcher().then((data) => {
if (generation === countdownCacheGeneration) writeCountdownCache(data.items, data.archived, now)
return data
}).finally(() => {
if (countdownInFlight === pending) countdownInFlight = null
})
countdownInFlight = pending as Promise<{ items: unknown[]; archived: unknown[] }>
return pending
}
export type HabitFormValues = {
+282
View File
@@ -0,0 +1,282 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import {
beginLatestRequest,
captureRequestContext,
commitIfRequestContextCurrent,
getCountdownCacheGeneration,
invalidateCountdownCache,
isCountdownCacheGenerationCurrent,
isLatestRequest,
loadCountdownCache,
readCountdownCache,
runLatestRequest,
startPrimaryWithBackground,
createMutationReconciler,
} from './mvp-utils'
const app = readFileSync('src/App.vue', 'utf8')
const habits = readFileSync('src/MvpPanel.vue', 'utf8')
describe('request generation protection', () => {
it('allows only the latest task request to commit data, loading, and errors', () => {
const first = beginLatestRequest('tasks')
const second = beginLatestRequest('tasks')
expect(isLatestRequest('tasks', first)).toBe(false)
expect(isLatestRequest('tasks', second)).toBe(true)
})
it('does not let a completed mutation supersede newer navigation state', async () => {
const task = beginLatestRequest('tasks')
const mutation = captureRequestContext('tasks')
const events: string[] = []
let finishMutation!: () => void
const pendingMutation = new Promise<void>((resolve) => { finishMutation = resolve }).then(() => {
commitIfRequestContextCurrent(mutation, () => events.push('mutation:refresh'))
})
const navigation = beginLatestRequest('tasks')
finishMutation()
await pendingMutation
expect(isLatestRequest('tasks', task)).toBe(false)
expect(isLatestRequest('tasks', navigation)).toBe(true)
expect(events).toEqual([])
})
it('keeps both success notifications and performs a final refresh for out-of-order mutations', async () => {
const events: string[] = []
const context = { view: 'today', list: 'inbox' }
let resolveFirst!: () => void
let resolveSecond!: () => void
let releaseRefresh!: () => void
const reconciler = createMutationReconciler(
() => context,
(left, right) => left.view === right.view && left.list === right.list,
() => new Promise<void>((resolve) => { events.push('refresh'); releaseRefresh = resolve }),
)
const first = reconciler.run(
() => new Promise<void>((resolve) => { resolveFirst = resolve }),
() => events.push('success:first'),
)
const second = reconciler.run(
() => new Promise<void>((resolve) => { resolveSecond = resolve }),
() => events.push('success:second'),
)
resolveSecond()
await Promise.resolve()
expect(events).toEqual(['success:second', 'refresh'])
resolveFirst()
await Promise.resolve()
expect(events).toEqual(['success:second', 'refresh', 'success:first'])
releaseRefresh()
await Promise.resolve()
expect(events).toEqual(['success:second', 'refresh', 'success:first', 'refresh'])
releaseRefresh()
await Promise.all([first, second])
})
it('refreshes again when the first refresh finishes before the second mutation commits', async () => {
const events: string[] = []
const context = { view: 'today' }
let resolveFirstMutation!: () => void
let resolveSecondMutation!: () => void
let resolveFirstRefresh!: () => void
const reconciler = createMutationReconciler(
() => context,
(left, right) => left.view === right.view,
() => {
events.push('refresh:start')
if (events.filter((event) => event === 'refresh:start').length === 1) {
return new Promise<void>((resolve) => { resolveFirstRefresh = resolve })
}
events.push('refresh:second-done')
return Promise.resolve()
},
)
const first = reconciler.run(
() => new Promise<void>((resolve) => { resolveFirstMutation = resolve }),
() => events.push('success:first'),
)
const second = reconciler.run(
() => new Promise<void>((resolve) => { resolveSecondMutation = resolve }),
() => events.push('success:second'),
)
resolveFirstMutation()
await Promise.resolve()
expect(events).toEqual(['success:first', 'refresh:start'])
resolveFirstRefresh()
await first
resolveSecondMutation()
await second
expect(events).toEqual(['success:first', 'refresh:start', 'success:second', 'refresh:start', 'refresh:second-done'])
})
it('keeps success feedback but skips reconciliation after navigation changes', async () => {
const events: string[] = []
let context = { view: 'today' }
let resolveMutation!: () => void
const reconciler = createMutationReconciler(
() => context,
(left, right) => left.view === right.view,
async () => { events.push('refresh') },
)
const mutation = reconciler.run(
() => new Promise<void>((resolve) => { resolveMutation = resolve }),
() => events.push('success'),
)
context = { view: 'tasks' }
resolveMutation()
await mutation
expect(events).toEqual(['success'])
})
it('prevents stale Trash success, error, and finally callbacks from committing', async () => {
const events: string[] = []
let finishOld!: (value: string) => void
const oldRequest = runLatestRequest('trash',
() => new Promise<string>((resolve) => { finishOld = resolve }),
{
success: (value) => events.push(`old:${value}`),
error: () => events.push('old:error'),
finally: () => events.push('old:finally'),
},
)
const currentRequest = runLatestRequest('trash',
async () => 'new',
{
success: (value) => events.push(`new:${value}`),
error: () => events.push('new:error'),
finally: () => events.push('new:finally'),
},
)
await currentRequest
finishOld('stale')
await oldRequest
expect(events).toEqual(['new:new', 'new:finally'])
})
it('prevents a stale Trash rejection from surfacing after a new view starts', async () => {
const events: string[] = []
let rejectOld!: (reason: Error) => void
const oldRequest = runLatestRequest('trash',
() => new Promise<void>((_resolve, reject) => { rejectOld = reject }),
{
success: () => events.push('old:success'),
error: () => events.push('old:error'),
finally: () => events.push('old:finally'),
},
)
beginLatestRequest('trash')
rejectOld(new Error('stale trash failure'))
await oldRequest
expect(events).toEqual([])
})
it('wires Trash loading through its own generation and invalidates it on exit', () => {
const trashBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView'))
const switchBlock = app.slice(app.indexOf('async function switchView'), app.indexOf('async function loadTodayView'))
expect(trashBlock).toContain("await runLatestRequest('trash', loadTrashPage")
expect(trashBlock).toContain('success: (data) => {')
expect(trashBlock).toContain('error: fail')
expect(trashBlock).toContain('finally: () => { loading.value = false }')
expect(switchBlock).toContain("if (view !== 'trash') beginLatestRequest('trash')")
})
it('starts Today requests in the same turn without awaiting summary', async () => {
const events: string[] = []
let finishTasks!: () => void
let finishOverdue!: () => void
let finishSummary!: () => void
const primary = startPrimaryWithBackground(
[
() => new Promise<void>((resolve) => { events.push('tasks:start'); finishTasks = resolve }),
() => new Promise<void>((resolve) => { events.push('overdue:start'); finishOverdue = resolve }),
],
() => new Promise<void>((resolve) => { events.push('summary:start'); finishSummary = resolve }),
).then(() => events.push('primary:done'))
expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start'])
finishTasks()
finishOverdue()
await primary
expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start', 'primary:done'])
finishSummary()
})
it('keeps Today primary completion independent from summary failure', async () => {
const primary = startPrimaryWithBackground(
[() => Promise.resolve(), () => Promise.resolve()],
() => Promise.reject(new Error('summary unavailable')),
)
await expect(primary).resolves.toEqual([undefined, undefined])
})
})
describe('habit request boundaries', () => {
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function deleteHabit'))
expect(mounted).not.toContain('loadArchivedHabits()')
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
expect(habits).toContain('if (showArchivedHabits.value && !archivedHabitsLoaded.value)')
expect(habits).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}")
})
it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => {
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
expect(toggleBlock).toContain('showArchivedHabits.value && !archivedHabitsLoaded.value')
expect(habits).toContain('v-if="archivedHabitsLoaded && !archivedHabits.length && !busy"')
expect(habits).not.toContain('v-if="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。')
})
})
describe('shared countdown cache', () => {
it('deduplicates prefetch and mount while a request is in flight', async () => {
invalidateCountdownCache()
let resolve!: (value: { items: unknown[]; archived: unknown[] }) => void
const fetcher = vi.fn(() => new Promise<{ items: unknown[]; archived: unknown[] }>((done) => { resolve = done }))
const first = loadCountdownCache(fetcher)
const second = loadCountdownCache(fetcher)
expect(fetcher).toHaveBeenCalledTimes(1)
resolve({ items: [{ id: 'one' }], archived: [] })
await expect(first).resolves.toEqual({ items: [{ id: 'one' }], archived: [] })
await expect(second).resolves.toEqual({ items: [{ id: 'one' }], archived: [] })
})
it('does not let an invalidated in-flight result overwrite a newer forced load', async () => {
invalidateCountdownCache()
const oldGeneration = getCountdownCacheGeneration()
let resolveOld!: (value: { items: unknown[]; archived: unknown[] }) => void
const oldLoad = loadCountdownCache<unknown>(() => new Promise<{ items: unknown[]; archived: unknown[] }>((resolve) => { resolveOld = resolve }))
invalidateCountdownCache()
const newGeneration = getCountdownCacheGeneration()
const fresh = { items: [{ id: 'new' }], archived: [{ id: 'new-archived' }] }
await expect(loadCountdownCache(async () => fresh, { force: true })).resolves.toEqual(fresh)
resolveOld({ items: [{ id: 'old' }], archived: [{ id: 'old-archived' }] })
const stale = await oldLoad
const componentState = { items: fresh.items, archived: fresh.archived }
if (isCountdownCacheGenerationCurrent(oldGeneration)) Object.assign(componentState, stale)
expect(isCountdownCacheGenerationCurrent(oldGeneration)).toBe(false)
expect(isCountdownCacheGenerationCurrent(newGeneration)).toBe(true)
expect(componentState).toEqual(fresh)
expect(readCountdownCache()).toEqual(fresh)
})
it('reuses fresh data and invalidates it after a write', async () => {
invalidateCountdownCache()
const fetcher = vi.fn(async () => ({ items: [{ id: 'one' }], archived: [] }))
await loadCountdownCache(fetcher, { now: 1000 })
await loadCountdownCache(fetcher, { now: 1500 })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(readCountdownCache()).toEqual({ items: [{ id: 'one' }], archived: [] })
invalidateCountdownCache()
await loadCountdownCache(fetcher, { now: 1600 })
expect(fetcher).toHaveBeenCalledTimes(2)
})
})