diff --git a/apps/web/src/components/page-editor/usePageEditor.ts b/apps/web/src/components/page-editor/usePageEditor.ts
index d87f348..a0b0402 100644
--- a/apps/web/src/components/page-editor/usePageEditor.ts
+++ b/apps/web/src/components/page-editor/usePageEditor.ts
@@ -19,6 +19,15 @@ import { SlashCommand } from './slashCommands';
import { createMention } from './mentions';
import type { MentionItem } from './mentionTypes';
+/** Treat empty variants and surrounding whitespace as the same value, so an
+ * autosave round-trip that echoes back semantically-identical HTML doesn't
+ * count as an external change. */
+function normalize(html: string | undefined | null): string {
+ const s = (html ?? '').trim();
+ if (s === '' || s === '
' || s === '
') return '';
+ return s;
+}
+
export interface UsePageEditorOptions {
/** Initial HTML to seed the editor with on mount. */
initialHtml?: string;
@@ -54,6 +63,11 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null {
const mentionItemsRef = useRef(mentionItems ?? []);
const getMentionItems = () => mentionItemsRef.current;
+ // The last HTML we synced from `initialHtml`. Used to recognise an autosave
+ // round-trip (the server echoing our own content back) so we don't reseed the
+ // document and jump the caret mid-edit.
+ const lastSyncedRef = useRef(normalize(initialHtml));
+
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -117,9 +131,13 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null {
// selection state during autosave round-trips.
useEffect(() => {
if (!editor || initialHtml === undefined) return;
- const current = editor.getHTML();
- if (current === initialHtml) return;
+ const incoming = normalize(initialHtml);
+ // Skip when the incoming HTML already matches what's shown, or matches what
+ // we last synced — e.g. an autosave echoing our own content back — so we
+ // only reseed on a genuine external change (load, route swap, restore).
+ if (incoming === normalize(editor.getHTML()) || incoming === lastSyncedRef.current) return;
editor.commands.setContent(initialHtml || '', { emitUpdate: false });
+ lastSyncedRef.current = incoming;
}, [editor, initialHtml]);
// Toggle editability when the readOnly prop flips (e.g. archive/unlock).
diff --git a/apps/web/src/pages/PageDetailPage.tsx b/apps/web/src/pages/PageDetailPage.tsx
index 0ef56c4..ccff175 100644
--- a/apps/web/src/pages/PageDetailPage.tsx
+++ b/apps/web/src/pages/PageDetailPage.tsx
@@ -88,6 +88,15 @@ export function PageDetailPage() {
const [workspace, setWorkspace] = useState(null);
const [project, setProject] = useState(null);
const [page, setPage] = useState(null);
+ // Latest page, readable from async callbacks without re-deriving their
+ // identity — lets an in-flight save detect that the route changed and avoid
+ // writing the previous page's content onto the newly-opened one.
+ const pageRef = useRef(page);
+ pageRef.current = page;
+ // Seed HTML for the editor. Updated only on genuine document swaps (load,
+ // route change, version restore) — never from an autosave round-trip — so the
+ // editor is never reseeded (and the caret never jumps) while the user types.
+ const [seedHtml, setSeedHtml] = useState('');
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
@@ -132,13 +141,18 @@ export function PageDetailPage() {
async (html: string) => {
if (!workspaceSlug || !page) return;
if (html === lastSavedHtml.current) return;
+ const savingPageId = page.id;
setBodyStatus({ kind: 'saving' });
try {
const updated = await pageService.updateContent(workspaceSlug, page.id, html);
+ // The route may have changed while the request was in flight; the save
+ // still persisted, but don't touch the current page's state.
+ if (pageRef.current?.id !== savingPageId) return;
lastSavedHtml.current = html;
setPage(updated);
setBodyStatus({ kind: 'saved', at: Date.now() });
} catch (err) {
+ if (pageRef.current?.id !== savingPageId) return;
setBodyStatus({
kind: 'error',
message: err instanceof Error ? err.message : t('page.saveFailed', 'Save failed'),
@@ -173,7 +187,7 @@ export function PageDetailPage() {
}, [saveBodyNow]);
const editor = usePageEditor({
- initialHtml: page?.description_html ?? '',
+ initialHtml: seedHtml,
placeholder: t('page.editorPlaceholder', 'Start writing… or press “/” for commands'),
readOnly: editorReadOnly,
onUpdate: onEditorUpdate,
@@ -236,6 +250,7 @@ export function PageDetailPage() {
setProject(p ?? null);
setPage(pg);
setTitleInput(pg.name ?? '');
+ setSeedHtml(pg.description_html ?? '');
lastSavedHtml.current = pg.description_html ?? '';
setIsFavorite(favIds.includes(pg.id));
setNotFound(false);
@@ -259,12 +274,15 @@ export function PageDetailPage() {
if (!workspaceSlug || !page) return;
const trimmed = next.trim();
if (trimmed === page.name) return;
+ const savingPageId = page.id;
setTitleStatus({ kind: 'saving' });
try {
const updated = await pageService.update(workspaceSlug, page.id, { name: trimmed });
+ if (pageRef.current?.id !== savingPageId) return;
setPage(updated);
setTitleStatus({ kind: 'saved', at: Date.now() });
} catch (err) {
+ if (pageRef.current?.id !== savingPageId) return;
setTitleStatus({
kind: 'error',
message: err instanceof Error ? err.message : t('page.saveFailed', 'Save failed'),
@@ -298,19 +316,34 @@ export function PageDetailPage() {
}
};
- // Save on unmount/navigation if there are unsaved changes.
+ // Refs to the latest save handlers + title, so the flush effect below can run
+ // them without taking them as deps (which would re-fire it on every autosave).
+ const saveBodyNowRef = useRef(saveBodyNow);
+ saveBodyNowRef.current = saveBodyNow;
+ const saveTitleNowRef = useRef(saveTitleNow);
+ saveTitleNowRef.current = saveTitleNow;
+ const titleInputRef = useRef(titleInput);
+ titleInputRef.current = titleInput;
+
+ // Flush any pending body/title save when the page changes or on unmount, so
+ // edits made in the last debounce window aren't dropped and a stale timer
+ // can't later fire against the newly-opened page. The route guards in
+ // saveBodyNow/saveTitleNow keep the flushed save from overwriting it.
useEffect(() => {
return () => {
if (bodySaveTimer.current) {
window.clearTimeout(bodySaveTimer.current);
bodySaveTimer.current = null;
+ const html = editorRef.current?.getHTML();
+ if (html !== undefined) void saveBodyNowRef.current(html);
}
if (titleSaveTimer.current) {
window.clearTimeout(titleSaveTimer.current);
titleSaveTimer.current = null;
+ void saveTitleNowRef.current(titleInputRef.current);
}
};
- }, []);
+ }, [workspaceSlug, projectId, pageId]);
// Click outside the options dropdown closes it.
useEffect(() => {
@@ -396,7 +429,9 @@ export function PageDetailPage() {
const updated = await pageService.restoreVersion(workspaceSlug, page.id, versionId);
setPage(updated);
lastSavedHtml.current = updated.description_html ?? '';
- editor?.commands.setContent(updated.description_html ?? '', { emitUpdate: false });
+ // Reseed the editor with the restored content via the seed prop (a genuine
+ // document swap), which the editor's sync effect applies.
+ setSeedHtml(updated.description_html ?? '');
setBodyStatus({ kind: 'saved', at: Date.now() });
setPreviewVersion(null);
void loadVersions();