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
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user