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:
kate522 committed 2026-11-10 17:58:22 +08:00
1 parent ec582ac9f9
commit a5386f72c6
10 files changed
+330 -49

No files matched your search

@@ -1,5 +1,8 @@
/**
* 顶栏保存状态。状态文案即用户对“内容有没有保存”的唯一反馈。
* 保存状态。状态文案即用户对「内容有没有保存」的唯一反馈。
*
* 自动保存与手动保存共用同一套取值,但 `autosave` 标记让界面能区别对待:
* 自动保存不该抢走提交按钮的可用性,也不该把「保存中」渲染成用户主动操作。
*
* @package Jio
*/
@@ -8,7 +11,12 @@ import { __ } from '@wordpress/i18n';
export type SaveStatus = 'ready' | 'saving' | 'saved' | 'error' | 'conflict' | 'submitting' | 'submitted';
export type SaveStateValue = { state: SaveStatus; at: string };
export type SaveStateValue = {
state: SaveStatus;
at: string;
/** 当前状态是否来自后台自动保存(不阻塞提交按钮)。 */
autosave?: boolean;
};
const LABELS: Record<SaveStatus, string> = {
ready: __('尚未保存', 'jio'),
@@ -20,11 +28,23 @@ const LABELS: Record<SaveStatus, string> = {
submitted: __('已提交', 'jio'),
};
/** 自动保存专用的说法:避免让作者以为是自己点了保存。 */
const AUTOSAVE_LABELS: Partial<Record<SaveStatus, string>> = {
saving: __('自动保存中…', 'jio'),
saved: __('已自动保存', 'jio'),
error: __('自动保存失败,将重试', 'jio'),
};
export function saveStateLabel(value: SaveStateValue): string {
const autosave = value.autosave ? AUTOSAVE_LABELS[value.state] : '';
return autosave || LABELS[value.state] || LABELS.ready;
}
export function SaveState({ value }: { value: SaveStateValue }) {
return (
<span className={`jio-creator__save is-${value.state}`} role="status" aria-live="polite">
<i className="fa fas fa-circle" aria-hidden="true" />
<span>{LABELS[value.state] || LABELS.ready}</span>
<span>{saveStateLabel(value)}</span>
{value.state === 'saved' && value.at ? <time>{value.at}</time> : null}
</span>
);
@@ -9,7 +9,7 @@
import { __, sprintf } from '@wordpress/i18n';
import { SaveState, type SaveStateValue } from './save-state';
import { SaveState, saveStateLabel, type SaveStateValue } from './save-state';
type Props = {
charCount: number;
@@ -93,6 +93,8 @@ export function StatusBar({
}: Props) {
const savedAt = saveState.at ? relativeTime(saveState.at) : '';
const status = STATUS_LABELS[saveState.state] || STATUS_LABELS.ready;
// 时间和状态各显示一次会重复(「17:04:15 · 草稿」),有具体时间时以时间为准。
const meta = savedAt || status;
return (
<footer className="jio-creator__status" role="contentinfo">
@@ -100,13 +102,13 @@ export function StatusBar({
<span className="jio-creator__status-count">
{__('字数', 'jio')}:{charCount}
</span>
<span className="jio-creator__status-mode">{__('Markdown 语法输入中', 'jio')}</span>
<span className="jio-creator__status-mode">{saveStateLabel(saveState)}</span>
</div>
<div className="jio-creator__status-meta">
<SaveState value={saveState} />
<span className="jio-creator__status-dot" aria-hidden="true">·</span>
<span className="jio-creator__status-time">{savedAt || status}</span>
<span className="jio-creator__status-time">{meta}</span>
</div>
<div className="jio-creator__status-actions">
+73 -18
View File
@@ -1,9 +1,14 @@
/**
* 自动保存节奏控制。
*
* - 云端自动保存 4 秒防抖,保存进行中不叠加请求;
* - 本地快照 1.5 秒防抖,由调用方写入 localStorage;
* - 页面隐藏/失焦时立即冲刷一次,避免切走丢稿。
* 三种触发并行,任一命中就保存:
* - **空闲防抖**:停手 4 秒后保存(沿用原有体验);
* - **最长等待**:首次改动后最多 30 秒必定落一次盘。只靠防抖的话,连续写作
* 永远不会触发 —— 每次按键都会重置定时器,实测连写 15 秒一次都没保存;
* - **失焦 / 页面隐藏**:立即冲刷。
*
* 保存失败后按 5s → 15s → 60s 退避重试,超过三次才停下来等下一次触发,
* 避免断网时不停地打接口,也避免网络恢复后状态一直卡在「保存失败」。
*
* 回调放进 ref,保证 schedule 引用稳定:否则每次输入产生的重渲染都会重置
* 定时器,自动保存永远不会触发。
@@ -19,40 +24,75 @@ type Options = {
onLocal: () => void;
remoteDelay?: number;
localDelay?: number;
/** 首次改动后最长多久必须保存一次(ms)。 */
maxWait?: number;
};
export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, localDelay = 1500 }: Options) {
/** 失败重试的退避间隔(ms),长度即最大重试次数。 */
const RETRY_DELAYS = [5000, 15000, 60000];
export function useAutosave({
enabled,
onSave,
onLocal,
remoteDelay = 4000,
localDelay = 1500,
maxWait = 30000,
}: Options) {
const remoteTimer = useRef<number | null>(null);
const localTimer = useRef<number | null>(null);
const retryTimer = useRef<number | null>(null);
const deadlineTimer = useRef<number | null>(null);
const inFlight = useRef(false);
const retryCount = useRef(0);
const saveRef = useRef(onSave);
const localRef = useRef(onLocal);
saveRef.current = onSave;
localRef.current = onLocal;
const clear = useCallback(() => {
if (remoteTimer.current) {
window.clearTimeout(remoteTimer.current);
remoteTimer.current = null;
}
if (localTimer.current) {
window.clearTimeout(localTimer.current);
localTimer.current = null;
const clearTimer = useCallback((timer: { current: number | null }) => {
if (timer.current) {
window.clearTimeout(timer.current);
timer.current = null;
}
}, []);
const flush = useCallback(async () => {
/** 只清「空闲防抖」相关的定时器;最长等待窗口要跨多次按键保留。 */
const clear = useCallback(() => {
clearTimer(remoteTimer);
clearTimer(localTimer);
}, [clearTimer]);
const flush = useCallback(async (): Promise<boolean> => {
clearTimer(retryTimer);
clearTimer(deadlineTimer);
if (inFlight.current) {
return;
// 保存正在进行,内容会在下一次 schedule 里重新排队。
return false;
}
inFlight.current = true;
let ok = false;
try {
await saveRef.current();
ok = await saveRef.current();
} finally {
inFlight.current = false;
}
}, []);
if (ok) {
retryCount.current = 0;
return true;
}
// 失败后按退避重试;重试期间本地副本已经写过,不会丢稿。
const delay = RETRY_DELAYS[retryCount.current];
if (delay !== undefined && enabled) {
retryCount.current += 1;
retryTimer.current = window.setTimeout(() => {
retryTimer.current = null;
void flush();
}, delay);
}
return false;
}, [clearTimer, enabled]);
const schedule = useCallback(() => {
clear();
@@ -64,7 +104,22 @@ export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, loca
remoteTimer.current = window.setTimeout(() => {
void flush();
}, remoteDelay);
}, [clear, enabled, flush, localDelay, remoteDelay]);
// 连续写作时防抖永远等不到空闲,这里补一个从「第一次未保存改动」起算的
// 上限。它不随每次按键重置,否则等于没有。
if (!deadlineTimer.current) {
deadlineTimer.current = window.setTimeout(() => {
deadlineTimer.current = null;
void flush();
}, maxWait);
}
}, [clear, enabled, flush, localDelay, maxWait, remoteDelay]);
const cancel = useCallback(() => {
clear();
clearTimer(retryTimer);
clearTimer(deadlineTimer);
retryCount.current = 0;
}, [clear, clearTimer]);
useEffect(() => {
if (!enabled) {
@@ -87,5 +142,5 @@ export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, loca
};
}, [clear, enabled, flush]);
return useMemo(() => ({ schedule, flush, cancel: clear }), [clear, flush, schedule]);
return useMemo(() => ({ schedule, flush, cancel }), [cancel, flush, schedule]);
}
@@ -2,7 +2,11 @@
* 本地草稿备份。
*
* 只保存标题与序列化后的正文,绝不保存 nonce、文件或付费凭据;恢复前由界面
* 询问用户,成功保存服务端后才清除。
* 询问用户,云端确认保存成功后才清除。
*
* 存储键跟着草稿 ID 走:新文章首次保存后会从 `jio-studio-0` 切到
* `jio-studio-<id>`,两篇草稿不会互相覆盖。切换键时重新检查一次,新键上
* 通常没有旧快照,因此不会重复弹恢复提示。
*
* @package Jio
*/
@@ -22,13 +26,15 @@ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export function useLocalRecovery({ key, snapshot, onFound }: Options) {
const latest = useRef(snapshot);
latest.current = snapshot;
const checked = useRef(false);
const onFoundRef = useRef(onFound);
onFoundRef.current = onFound;
const checkedKey = useRef<string | null>(null);
useEffect(() => {
if (checked.current) {
if (checkedKey.current === key) {
return;
}
checked.current = true;
checkedKey.current = key;
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
@@ -40,13 +46,18 @@ export function useLocalRecovery({ key, snapshot, onFound }: Options) {
window.localStorage.removeItem(key);
return;
}
if (typeof parsed.content === 'string' && parsed.content !== snapshot.content) {
onFound(String(parsed.at));
// 云端内容比本地快照新时不打扰作者;只有本地确实带着云端没有的内容才提示。
const differs = typeof parsed.content === 'string' && parsed.content !== snapshot.content;
const localTitle = typeof parsed.title === 'string' && parsed.title !== snapshot.title;
if (differs || localTitle) {
onFoundRef.current(String(parsed.at));
} else {
window.localStorage.removeItem(key);
}
} catch (error) {
window.localStorage.removeItem(key);
}
}, [key, onFound, snapshot.content]);
}, [key, snapshot.content, snapshot.title]);
const markLocalSaved = useCallback(() => {
try {
+59 -11
View File
@@ -46,7 +46,8 @@ import './styles/studio.scss';
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
/** 本地草稿的存储键:跟着草稿 ID 走,新文章保存后自动切到带 ID 的键。 */
const storageKeyFor = (draft: { postId: number }): string => `jio-studio-${draft.postId || bootstrap.sourceId || 0}`;
const EDITOR_ID = 'jio-creator-content';
function escapeAttribute(value: string): string {
@@ -72,6 +73,27 @@ function plainText(content: string): string {
return (container.textContent || '').replace(/\s+/g, '');
}
/**
* 把刚创建 / 刚保存的草稿 ID 写进地址栏。
*
* 用 `history.replaceState`,不刷新页面也不留一堆历史记录;已经绑定过的地址
* 不再改动,避免覆盖用户手动输入的其它查询参数。
*/
function bindDraftUrl(draft: { postId: number; versionId: number }): void {
if (typeof window === 'undefined' || !window.history?.replaceState) {
return;
}
const url = new URL(window.location.href);
const current = Number(url.searchParams.get('edit') || 0);
// 新文章经服务端保存后才拿到 ID;已发布文章的改稿绑定的是源文章 ID。
const target = draft.postId > 0 ? draft.postId : 0;
if (target <= 0 || current === target) {
return;
}
url.searchParams.set('edit', String(target));
window.history.replaceState(window.history.state, '', url.toString());
}
function Studio(): JSX.Element {
const [draft, setDraft] = useState<Draft>(() => initialDraft(bootstrap));
const draftRef = useRef(draft);
@@ -214,7 +236,7 @@ function Studio(): JSX.Element {
if (!hasDocument) {
return true;
}
setSaveState({ state: 'saving', at: '' });
setSaveState({ state: 'saving', at: '', autosave });
try {
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
const updated = {
@@ -226,14 +248,31 @@ function Studio(): JSX.Element {
if ((window as any).jioSubmitData) {
(window as any).jioSubmitData.postId = updated.postId;
}
// 首次保存后把文章 ID 写进地址栏:刷新、重开标签页、回退都要能回到
// 同一篇草稿,否则新文章一刷新就分不到同一篇,草稿等于丢了。
bindDraftUrl(updated);
const settled = editRevisionRef.current === requestRevision;
dirtyRef.current = !settled;
draftRef.current = updated;
setDraft(updated);
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleString() : '', autosave });
return settled;
} catch (error: any) {
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
if (error?.status === 409) {
// 改稿版本已在别处更新:把本地 rev 对齐到服务端,下一次保存就能继续,
// 同时明确告诉作者这件事,而不是让他以为稿子坏了。
const currentRev = Number(error?.data?.data?.current_rev ?? 0);
if (currentRev > 0) {
draftRef.current = { ...draftRef.current, rev: currentRev };
}
setSaveState({ state: 'conflict', at: '', autosave });
setNotice({
tone: 'error',
text: __('这篇改稿已在其他页面更新,已同步到最新版本,请确认内容后继续编辑。', 'jio'),
});
return false;
}
setSaveState({ state: 'error', at: '', autosave });
return false;
}
},
@@ -311,7 +350,7 @@ function Studio(): JSX.Element {
}, [save]);
const { markLocalSaved, clear } = useLocalRecovery({
key: STORAGE_KEY,
key: storageKeyFor(draft),
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
onFound: (savedAt: string) => setRecovery({ savedAt }),
});
@@ -320,7 +359,9 @@ function Studio(): JSX.Element {
enabled: !!capabilities.isLoggedIn,
onSave: async () => {
const ok = await save(true);
if (ok) {
// 本地副本只有等这次保存真正落盘、且之后没有再被改动时才清:
// 保存进行中又输入的内容还没上传,清掉就等于丢稿。
if (ok && !dirtyRef.current) {
clear();
}
return ok;
@@ -440,7 +481,7 @@ function Studio(): JSX.Element {
const restore = useCallback(() => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
const raw = window.localStorage.getItem(storageKeyFor(draftRef.current));
if (raw) {
const parsed = JSON.parse(raw);
const patch: Partial<Draft> = {};
@@ -448,10 +489,12 @@ function Studio(): JSX.Element {
if (typeof parsed.content === 'string') patch.content = parsed.content;
patchDraft(patch);
if (typeof parsed.content === 'string') {
// 快照存的是保存格式(pre > code),编辑器要可编辑的形态。
const content = hydrateBlocks(parsed.content);
if (editorReadyRef.current) {
editorRef.current?.setContent(parsed.content);
editorRef.current?.setContent(content);
} else {
pendingRestoreContentRef.current = parsed.content;
pendingRestoreContentRef.current = content;
}
}
}
@@ -836,7 +879,12 @@ function Studio(): JSX.Element {
charCount={charCount}
saveState={saveState}
isPublishedEdit={isPublishedEdit}
saving={!editorReady || saveState.state === 'saving' || saveState.state === 'submitting'}
saving={
!editorReady
|| saveState.state === 'submitting'
// 自动保存不该禁用发布:它随时可能在进行,按钮闪断会让作者点空。
|| (saveState.state === 'saving' && !saveState.autosave)
}
onSaveDraft={() => void save(false)}
onPreview={() => void preview()}
onPublish={() => setPublishOpen(true)}
@@ -859,7 +907,7 @@ function Studio(): JSX.Element {
canPublishNow={!!capabilities.canPublishNow}
isPublishedEdit={isPublishedEdit}
captcha={!!bootstrap.captcha?.enabled}
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
saving={saveState.state === 'submitting' || (saveState.state === 'saving' && !saveState.autosave)}
canSubmit={(draft.title || '').trim().length >= Number(limits.titleMin || 1)}
onClose={() => setPublishOpen(false)}
onSaveDraft={() => void save(false)}