fix(submit): make the draft chain survive continuous writing, reloads and flaky networks
Autosave only ran after 4s of idle, and every keystroke reset that timer, so a writer who never stopped had zero saves (measured: 15s of typing = 0 requests). Saving also never rebound the URL, so a new draft could not be found again after a reload, the local backup was cleared on every autosave, and a failed save had no retry and stayed failed once the network came back. Client - autosave: keep the idle debounce but add a 30s max-wait window that is not reset by further keystrokes, and retry failures at 5s / 15s / 60s - bind `?edit=<id>` into the URL once the server returns a post id, so reloads, new tabs and history all reopen the same draft - only clear the local backup when the save settled and nothing changed since - key the local backup by draft id instead of the constant `jio-studio-0` - restore snapshots through hydrateBlocks so code blocks come back editable - never disable publish while an autosave is in flight, and mark autosave states separately (自动保存中… / 已自动保存 / 自动保存失败,将重试) - on 409, adopt the server rev and tell the author instead of failing silently - status bar reports the real save state instead of a bogus Markdown hint Server - draft saves no longer rewrite the lifecycle: pending / future / publish / private posts keep their status, so an autosave cannot pull a submitted post out of the review queue - drafts and autosaves stop consuming the publish flood quota, which is meant to cap actual publishing (measured: 4 drafts used to burn 4 slots) - autosave skips wp_save_post_revision for both plain drafts and submission versions; manual saves and submits still create a revision to roll back to - media-only drafts (image / table / code / hr) count as content - the response now carries the resulting status so the client can tell "just submitted" from "still under review" Tests cover the max-wait window, retry schedule, URL binding, backup clearing, status labelling and every server-side draft rule.
This commit is contained in:
1 parent
ec582ac9f9
commit
a5386f72c6
10 files changed
+330
-49
No files matched your search
@@ -270,7 +270,9 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /保存草稿/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /预览/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown 语法输入中/);
|
||||
// 状态栏展示真实保存状态:编辑器是 TinyMCE 富文本,不支持 Markdown 语法。
|
||||
assert.doesNotMatch(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /saveStateLabel\(saveState\)/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /<SaveState value=\{saveState\}/);
|
||||
assert.match(appSource, /draftRef\.current\.postId/);
|
||||
const barStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
|
||||
@@ -698,3 +700,77 @@ test('creator snapshot keeps every download display field', () => {
|
||||
assert.match(rest, /'copyKey'\s*=>/);
|
||||
assert.match(rest, /'copyVal'\s*=>/);
|
||||
});
|
||||
|
||||
// 草稿链路:自动保存不能因为「不停手」而永远不触发,也不能在成功后把本地副本删掉。
|
||||
test('autosave keeps a max-wait window and retries after failure', () => {
|
||||
const hook = read('assets/creator-studio/src/hooks/use-autosave.ts');
|
||||
|
||||
// 只靠防抖的话持续输入永远不会落盘(每次按键都会重置定时器)。
|
||||
assert.match(hook, /maxWait\s*=\s*30000/);
|
||||
assert.match(hook, /deadlineTimer/);
|
||||
// 上限窗口不能随每次按键重置。
|
||||
assert.match(hook, /if \(!deadlineTimer\.current\) \{/);
|
||||
// 失败后按退避重试,而不是停在「保存失败」。
|
||||
assert.match(hook, /RETRY_DELAYS = \[5000, 15000, 60000\]/);
|
||||
assert.match(hook, /retryTimer\.current = window\.setTimeout/);
|
||||
});
|
||||
|
||||
test('draft save binds the id into the url and only clears the local backup once settled', () => {
|
||||
const app = read('assets/creator-studio/src/index.tsx');
|
||||
|
||||
// 首次保存后地址栏要能回到同一篇草稿,否则刷新就丢。
|
||||
assert.match(app, /function bindDraftUrl/);
|
||||
assert.match(app, /window\.history\.replaceState/);
|
||||
assert.match(app, /url\.searchParams\.set\('edit', String\(target\)\)/);
|
||||
assert.match(app, /bindDraftUrl\(updated\)/);
|
||||
// 本地副本只在「保存成功且之后没有再改动」时才清。
|
||||
assert.match(app, /if \(ok && !dirtyRef\.current\) \{/);
|
||||
// 存储键跟着草稿 ID 走,两篇草稿不会互相覆盖。
|
||||
assert.match(app, /const storageKeyFor = /);
|
||||
assert.match(app, /key: storageKeyFor\(draft\)/);
|
||||
// 恢复快照时要还原成编辑器里的可编辑形态。
|
||||
assert.match(app, /const content = hydrateBlocks\(parsed\.content\)/);
|
||||
// 自动保存不阻塞发布按钮。
|
||||
assert.match(app, /saveState\.state === 'saving' && !saveState\.autosave/);
|
||||
// 冲突要同步 rev 并告知作者。
|
||||
assert.match(app, /error\?\.status === 409/);
|
||||
assert.match(app, /current_rev/);
|
||||
});
|
||||
|
||||
test('autosave state is labelled differently from a manual save', () => {
|
||||
const saveState = read('assets/creator-studio/src/components/save-state.tsx');
|
||||
|
||||
assert.match(saveState, /autosave\?: boolean/);
|
||||
assert.match(saveState, /AUTOSAVE_LABELS/);
|
||||
assert.match(saveState, /自动保存中…/);
|
||||
assert.match(saveState, /已自动保存/);
|
||||
assert.match(saveState, /export function saveStateLabel/);
|
||||
});
|
||||
|
||||
// 服务端:草稿不能改写文章生命周期、不能吃发布配额、自动保存不建 revision。
|
||||
test('draft saves never demote a submitted post or consume the publish quota', () => {
|
||||
const save = read('src/Core/FrontSubmitSave.php');
|
||||
|
||||
// 自动保存标记从 REST 一路透传到 persist()。
|
||||
assert.match(save, /\$is_auto\s*=\s*\$is_draft && !empty\(\$data\['autosave'\]\)/);
|
||||
// pending / future / publish 的草稿保存保持原状态。
|
||||
assert.match(save, /\$status = in_array\(\$current_status, \['pending', 'future', 'publish', 'private'\], true\)/);
|
||||
// 草稿与自动保存不扣防刷配额,只有正式投稿计数。
|
||||
assert.match(save, /if \(!\$is_draft && !\$is_edit && !current_user_can\('manage_options'\)/);
|
||||
// 自动保存不建 revision。
|
||||
assert.match(save, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||||
// 只有媒体的草稿也算有内容。
|
||||
assert.match(save, /private function draft_has_content/);
|
||||
assert.match(save, /img\|table\|pre\|hr\|iframe\|video\|audio\|figure/);
|
||||
// 响应要带上真实状态,前端才能区分「刚提交」与「仍在审核中」。
|
||||
assert.match(save, /'status'\s*=>\s*\$status/);
|
||||
assert.match(save, /已保存,文章仍在审核队列中/);
|
||||
|
||||
// 改稿版本同样抑制自动保存的 revision。
|
||||
const version = read('src/Core/SubmissionVersion.php');
|
||||
assert.match(version, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||||
assert.match(version, /\$is_auto = !empty\(\$data\['autosave'\]\)/);
|
||||
|
||||
// REST 侧要把 autosave 传给 persist()。
|
||||
assert.match(read('src/Core/CreatorRest.php'), /'autosave'\s*=>\s*\$is_auto/);
|
||||
});
|
||||
@@ -193,7 +193,16 @@ namespace {
|
||||
function user_can($user_id, $capability, ...$args): bool { return false; }
|
||||
function add_filter($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; }
|
||||
function remove_filter($tag, $callback, $priority = 10): bool { return true; }
|
||||
function add_action($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; }
|
||||
function remove_action($tag, $callback, $priority = 10): bool { return true; }
|
||||
function do_action($tag, ...$args): void { $GLOBALS['front_pending_state']['actions'][] = $tag; }
|
||||
function get_post_status($post = null): string {
|
||||
if ($post instanceof WP_Post) {
|
||||
return (string) $post->post_status;
|
||||
}
|
||||
$found = $GLOBALS['front_pending_state']['posts'][(int) $post] ?? null;
|
||||
return $found instanceof WP_Post ? (string) $found->post_status : false;
|
||||
}
|
||||
function current_time($type): string { return '2026-10-30 12:00:00'; }
|
||||
function wp_set_post_categories($post_id, $categories): void {}
|
||||
function get_terms($args = []) {
|
||||
|
||||
Reference in new issue
Block a user