fix(frontend): save pending code drafts before page exit
This commit is contained in:
@@ -43,6 +43,7 @@ check-frontend:
|
|||||||
cd frontend && $(PNPM) lint
|
cd frontend && $(PNPM) lint
|
||||||
cd frontend && $(PNPM) format:check
|
cd frontend && $(PNPM) format:check
|
||||||
cd frontend && $(PNPM) typecheck
|
cd frontend && $(PNPM) typecheck
|
||||||
|
cd frontend && $(PNPM) test
|
||||||
cd frontend && $(PNPM) build
|
cd frontend && $(PNPM) build
|
||||||
|
|
||||||
contracts:
|
contracts:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Vue 3 + TypeScript + Vite + Element Plus,使用 Vue Router 与 Pinia。
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm dev
|
pnpm dev
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -22,3 +23,5 @@ pnpm build
|
|||||||
已有代码按实际需要落位;`components/` 和 `stores/` 是后续扩展位置,不放空状态实现。
|
已有代码按实际需要落位;`components/` 和 `stores/` 是后续扩展位置,不放空状态实现。
|
||||||
API 类型更新执行根目录 `make contracts`;前端通过 `pnpm check:api` 拒绝过期类型。
|
API 类型更新执行根目录 `make contracts`;前端通过 `pnpm check:api` 拒绝过期类型。
|
||||||
登录凭据、模型密钥和数据库连接不能进入 `VITE_` 环境变量。
|
登录凭据、模型密钥和数据库连接不能进入 `VITE_` 环境变量。
|
||||||
|
|
||||||
|
`pnpm test` 验证代码草稿在刷新、切到后台与页面离开时的保存行为,已纳入 `make check-frontend`。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "node --test tests/*.test.ts",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
|||||||
@@ -67,11 +67,22 @@ export function useCodeDraft(problemId: string) {
|
|||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(save, 400)
|
timer = setTimeout(save, 400)
|
||||||
},
|
},
|
||||||
{ deep: true },
|
// Mark pending changes before a lifecycle event can run in the same tick.
|
||||||
|
{ deep: true, flush: 'sync' },
|
||||||
)
|
)
|
||||||
onScopeDispose(() => {
|
function flushPendingDraft() {
|
||||||
if (state.value === 'changed') save()
|
if (state.value === 'changed') save()
|
||||||
|
}
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'hidden') flushPendingDraft()
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', flushPendingDraft)
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
onScopeDispose(() => {
|
||||||
|
flushPendingDraft()
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
|
window.removeEventListener('pagehide', flushPendingDraft)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
})
|
})
|
||||||
return { language, code, state, save }
|
return { language, code, state, save }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test, type TestContext } from 'node:test'
|
||||||
|
import { effectScope, nextTick } from 'vue'
|
||||||
|
import { codeTemplates, useCodeDraft, type Language } from '../src/composables/useCodeDraft.ts'
|
||||||
|
|
||||||
|
type SavedDraft = { version: number; language: Language; drafts: Record<Language, string> }
|
||||||
|
|
||||||
|
function browser(t: TestContext) {
|
||||||
|
const page = new EventTarget()
|
||||||
|
const document = Object.assign(new EventTarget(), {
|
||||||
|
visibilityState: 'visible' as DocumentVisibilityState,
|
||||||
|
})
|
||||||
|
const storage = {
|
||||||
|
items: new Map<string, string>(),
|
||||||
|
writes: 0,
|
||||||
|
failWrites: false,
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.items.get(key) ?? null
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.writes++
|
||||||
|
if (this.failWrites) throw new Error('Storage unavailable')
|
||||||
|
this.items.set(key, value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const globals = { window: page, document, localStorage: storage }
|
||||||
|
const descriptors = Object.keys(globals).map(
|
||||||
|
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||||
|
)
|
||||||
|
for (const [name, value] of Object.entries(globals)) {
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
|
||||||
|
}
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const scopes: ReturnType<typeof effectScope>[] = []
|
||||||
|
t.after(() => {
|
||||||
|
for (const scope of scopes) scope.stop()
|
||||||
|
for (const [name, descriptor] of descriptors) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
||||||
|
else Reflect.deleteProperty(globalThis, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
document,
|
||||||
|
storage,
|
||||||
|
open(problemId = '1001') {
|
||||||
|
const scope = effectScope()
|
||||||
|
scopes.push(scope)
|
||||||
|
return { scope, ...scope.run(() => useCodeDraft(problemId))! }
|
||||||
|
},
|
||||||
|
read(problemId = '1001'): SavedDraft | null {
|
||||||
|
return JSON.parse(storage.getItem('kf-oj:code-draft:' + problemId) ?? 'null')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pagehide saves the latest edit before the debounce and restores it after reload', (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const draft = env.open()
|
||||||
|
draft.code.value = '// latest edit before refresh'
|
||||||
|
env.page.dispatchEvent(new Event('pagehide'))
|
||||||
|
assert.equal(env.read()?.drafts.cpp, draft.code.value)
|
||||||
|
assert.equal(draft.state.value, 'saved')
|
||||||
|
t.mock.timers.tick(400)
|
||||||
|
assert.equal(env.storage.writes, 1)
|
||||||
|
draft.scope.stop()
|
||||||
|
assert.equal(env.open().code.value, '// latest edit before refresh')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hiding the page flushes pending edits; remaining visible does not', async (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const draft = env.open()
|
||||||
|
draft.code.value = '// edited while visible'
|
||||||
|
await nextTick()
|
||||||
|
env.document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
assert.equal(env.storage.writes, 0)
|
||||||
|
env.document.visibilityState = 'hidden'
|
||||||
|
env.document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
assert.equal(env.read()?.drafts.cpp, draft.code.value)
|
||||||
|
t.mock.timers.tick(400)
|
||||||
|
assert.equal(env.storage.writes, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normal typing still debounces writes and saves the most recent edit', async (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const draft = env.open()
|
||||||
|
draft.code.value = '// first edit'
|
||||||
|
await nextTick()
|
||||||
|
t.mock.timers.tick(399)
|
||||||
|
assert.equal(env.storage.writes, 0)
|
||||||
|
draft.code.value = '// second edit'
|
||||||
|
await nextTick()
|
||||||
|
t.mock.timers.tick(399)
|
||||||
|
assert.equal(env.storage.writes, 0)
|
||||||
|
t.mock.timers.tick(1)
|
||||||
|
assert.equal(env.read()?.drafts.cpp, '// second edit')
|
||||||
|
assert.equal(env.storage.writes, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaving a Vue page saves even an edit made in the same tick', (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const draft = env.open()
|
||||||
|
draft.code.value = '// latest edit before navigation'
|
||||||
|
draft.scope.stop()
|
||||||
|
assert.equal(env.read()?.drafts.cpp, '// latest edit before navigation')
|
||||||
|
t.mock.timers.tick(400)
|
||||||
|
assert.equal(env.storage.writes, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disposing the scope removes page lifecycle listeners', (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const removePage = t.mock.method(env.page, 'removeEventListener')
|
||||||
|
const removeDocument = t.mock.method(env.document, 'removeEventListener')
|
||||||
|
env.open().scope.stop()
|
||||||
|
assert.deepEqual(
|
||||||
|
removePage.mock.calls.map((call) => call.arguments[0]),
|
||||||
|
['pagehide'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
removeDocument.mock.calls.map((call) => call.arguments[0]),
|
||||||
|
['visibilitychange'],
|
||||||
|
)
|
||||||
|
env.page.dispatchEvent(new Event('pagehide'))
|
||||||
|
env.document.visibilityState = 'hidden'
|
||||||
|
env.document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
assert.equal(env.storage.writes, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pagehide preserves the selected language and keeps problem drafts separate', (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const first = env.open('1001')
|
||||||
|
first.code.value = '// C++ draft'
|
||||||
|
first.language.value = 'python'
|
||||||
|
first.code.value = '# Python draft'
|
||||||
|
const second = env.open('1002')
|
||||||
|
second.code.value = '// another problem'
|
||||||
|
env.page.dispatchEvent(new Event('pagehide'))
|
||||||
|
assert.deepEqual(env.read('1001'), {
|
||||||
|
version: 1,
|
||||||
|
language: 'python',
|
||||||
|
drafts: { cpp: '// C++ draft', python: '# Python draft', rust: codeTemplates.rust },
|
||||||
|
})
|
||||||
|
assert.equal(env.read('1002')?.drafts.cpp, '// another problem')
|
||||||
|
first.scope.stop()
|
||||||
|
const restored = env.open('1001')
|
||||||
|
assert.equal(restored.language.value, 'python')
|
||||||
|
assert.equal(restored.code.value, '# Python draft')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed lifecycle save reports unavailable storage without throwing', (t) => {
|
||||||
|
const env = browser(t)
|
||||||
|
const draft = env.open()
|
||||||
|
env.storage.failWrites = true
|
||||||
|
draft.code.value = '// cannot persist yet'
|
||||||
|
assert.doesNotThrow(() => env.page.dispatchEvent(new Event('pagehide')))
|
||||||
|
assert.equal(draft.state.value, 'unavailable')
|
||||||
|
t.mock.timers.tick(400)
|
||||||
|
assert.equal(env.storage.writes, 1)
|
||||||
|
env.storage.failWrites = false
|
||||||
|
draft.code.value = '// storage recovered'
|
||||||
|
env.page.dispatchEvent(new Event('pagehide'))
|
||||||
|
assert.equal(draft.state.value, 'saved')
|
||||||
|
assert.equal(env.read()?.drafts.cpp, '// storage recovered')
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
@@ -15,5 +16,5 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"types": ["vite/client", "node"]
|
"types": ["vite/client", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user