From 8af681b27f82d3b7014af7bfbfc9c784a18cb388 Mon Sep 17 00:00:00 2001 From: AngryJay91 <16958800+AngryJay91@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:23:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(docs):=20S-DOCS1=20v2=20=E2=80=94=20No?= =?UTF-8?q?tion-style=20always-editable=20+=20autosave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove editing mode toggle (editing state, Edit/Save/Cancel buttons, click-to-edit) - Always mount DocEditor as single tiptap instance; editable prop is permission-based - Wire useDocSync debounce autosave (1500ms) via always-on editing gate (selectedDoc !== null) - Add SaveStatusIndicator: shows Saving/Saved/Unsaved/Error/Conflict/Remote-changed - Title always rendered as (no heading-vs-input conditional) - Export getDocSaveStatusText pure helper for unit tests (8 tests, all pass) - Drop removed props (mode, onModeChange) from DocEditor; fix page-old.tsx callsite Co-Authored-By: Claude Sonnet 4.6 --- .../docs/docs-shell-client.test.tsx | 53 ++++ .../docs/docs-shell-client.tsx | 299 +++++++----------- .../src/app/(authenticated)/docs/page-old.tsx | 5 - apps/web/src/components/docs/doc-editor.tsx | 77 ++--- 4 files changed, 196 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/docs/docs-shell-client.test.tsx diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.test.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.test.tsx new file mode 100644 index 00000000..724a2af2 --- /dev/null +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { DocsShellClient, getDocSaveStatusText } from './docs-shell-client'; + +// Minimal i18n stub — maps keys used by the save-status indicator +const t = (key: string): string => + ({ + statusSaving: 'Saving...', + statusSaved: 'Saved', + statusUnsaved: 'Unsaved changes', + statusError: 'Save failed', + statusConflict: 'Conflict: another user edited this document', + statusRemoteChanged: 'This document was updated remotely', + })[key] ?? key; + +describe('DocsShellClient', () => { + it('exports DocsShellClient as a function', () => { + expect(typeof DocsShellClient).toBe('function'); + }); +}); + +describe('getDocSaveStatusText', () => { + it('returns null for idle status — no indicator shown when nothing is happening', () => { + expect(getDocSaveStatusText('idle', t)).toBeNull(); + }); + + it('returns saving text while autosave is in flight', () => { + expect(getDocSaveStatusText('saving', t)).toBe('Saving...'); + }); + + it('returns saved text after successful autosave', () => { + expect(getDocSaveStatusText('saved', t)).toBe('Saved'); + }); + + it('returns unsaved text when doc is dirty and autosave has not fired yet', () => { + expect(getDocSaveStatusText('unsaved', t)).toBe('Unsaved changes'); + }); + + it('returns error text when the PATCH request fails', () => { + expect(getDocSaveStatusText('error', t)).toBe('Save failed'); + }); + + it('returns conflict text when server returns 409', () => { + expect(getDocSaveStatusText('conflict', t)).toBe( + 'Conflict: another user edited this document', + ); + }); + + it('returns remote-changed text when poll detects a newer server version', () => { + expect(getDocSaveStatusText('remote-changed', t)).toBe( + 'This document was updated remotely', + ); + }); +}); diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx index 38d4b52c..3dee369c 100644 --- a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx @@ -5,10 +5,10 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { DocTree } from '@/components/docs/doc-tree'; import { DocEditor } from '@/components/docs/doc-editor'; -import { DocContentRenderer } from '@/components/docs/doc-content-renderer'; +import { useDocSync, type SaveStatus } from '@/components/docs/use-doc-sync'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Plus, Edit2, Eye, Save, X, Trash2 } from 'lucide-react'; +import { Plus, X, Trash2 } from 'lucide-react'; interface Doc { id: string; @@ -36,9 +36,38 @@ interface DocsShellClientProps { projectId?: string; } +/** Pure helper — exported for unit tests */ +export function getDocSaveStatusText(status: SaveStatus, t: (key: string) => string): string | null { + const map: Partial> = { + saving: t('statusSaving'), + saved: t('statusSaved'), + unsaved: t('statusUnsaved'), + error: t('statusError'), + conflict: t('statusConflict'), + 'remote-changed': t('statusRemoteChanged'), + }; + return map[status] ?? null; +} + +const SAVE_STATUS_CLASS: Partial> = { + saving: 'text-[color:var(--operator-muted)]', + saved: 'text-emerald-500/70', + unsaved: 'text-amber-500/70', + error: 'text-rose-500', + conflict: 'text-rose-500', + 'remote-changed': 'text-amber-500', +}; + +function SaveStatusIndicator({ status, t }: { status: SaveStatus; t: ReturnType }) { + const text = getDocSaveStatusText(status, t); + if (!text) return null; + + return {text}; +} + /** - * Docs shell with 2-panel layout (tree + content) - * Inline editing support + * Docs shell with 2-panel layout (tree + content). + * Notion-style: always-editable tiptap, autosave via useDocSync. */ export function DocsShellClient({ projectId }: DocsShellClientProps) { const router = useRouter(); @@ -49,11 +78,13 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const [tree, setTree] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); const [loading, setLoading] = useState(true); - const [editing, setEditing] = useState(false); - const [editContent, setEditContent] = useState(''); - const [editTitle, setEditTitle] = useState(''); - const [editContentFormat, setEditContentFormat] = useState<'markdown' | 'html'>('markdown'); - const [editorMode, setEditorMode] = useState<'write' | 'preview'>('write'); + + // Always-editable content states + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [contentFormat, setContentFormat] = useState<'markdown' | 'html'>('markdown'); + + // Create form states const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newContent, setNewContent] = useState(''); @@ -61,6 +92,21 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const [newParentId, setNewParentId] = useState(null); const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const handleDocSaved = useCallback((doc: DocDetail) => { + setSelectedDoc(doc); + setTree((prev) => + prev.map((d) => (d.id === doc.id ? { ...d, title: doc.title } : d)) + ); + }, []); + + const { status: saveStatus } = useDocSync({ + docId: selectedDoc?.id ?? null, + savePayload: { title, content, content_format: contentFormat }, + serverUpdatedAt: selectedDoc?.updated_at ?? null, + editing: selectedDoc !== null, + onSaved: handleDocSaved, + }); + const fetchTree = useCallback(async () => { if (!projectId) return; @@ -86,9 +132,9 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const { data } = await res.json(); setSelectedDoc(data); - setEditTitle(data.title); - setEditContent(data.content); - setEditContentFormat(data.content_format || 'markdown'); + setTitle(data.title); + setContent(data.content); + setContentFormat(data.content_format || 'markdown'); } catch (error) { console.error('Failed to fetch doc:', error); setSelectedDoc(null); @@ -97,61 +143,14 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const handleSelectDoc = useCallback((slug: string) => { void fetchDoc(slug); - // Update URL const params = new URLSearchParams(searchParams); params.set('slug', slug); router.replace(`?${params.toString()}`); }, [fetchDoc, router, searchParams]); - const handleSave = useCallback(async () => { - if (!selectedDoc || !projectId) return; - - try { - const res = await fetch(`/api/docs/${selectedDoc.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: editTitle, - content: editContent, - content_format: editContentFormat, - }), - }); - - if (!res.ok) throw new Error('Failed to save doc'); - - const { data } = await res.json(); - setSelectedDoc(data); - setEditing(false); - - // Update tree - setTree((prev) => - prev.map((doc) => - doc.id === data.id ? { ...doc, title: data.title } : doc - ) - ); - } catch (error) { - console.error('Failed to save doc:', error); - } - }, [selectedDoc, projectId, editTitle, editContent, editContentFormat]); - - const handleCancelEdit = useCallback(() => { - if (selectedDoc) { - setEditTitle(selectedDoc.title); - setEditContent(selectedDoc.content); - setEditContentFormat(selectedDoc.content_format || 'markdown'); - } - setEditing(false); - }, [selectedDoc]); - const handleReorder = useCallback(async (docId: string, newSortOrder: number, siblings: Doc[]) => { - // Optimistic update setTree((prev) => - prev.map((doc) => { - if (doc.id === docId) { - return { ...doc, sort_order: newSortOrder }; - } - return doc; - }) + prev.map((doc) => (doc.id === docId ? { ...doc, sort_order: newSortOrder } : doc)) ); try { @@ -161,37 +160,30 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { body: JSON.stringify({ sort_order: newSortOrder }), }); - if (!res.ok) { - // Rollback on error - await fetchTree(); - } + if (!res.ok) await fetchTree(); } catch (error) { console.error('Failed to reorder doc:', error); - // Rollback on error await fetchTree(); } }, [fetchTree]); - const handleRename = useCallback(async (docId: string, newTitle: string) => { - // Optimistic update + const handleRename = useCallback(async (docId: string, newName: string) => { setTree((prev) => - prev.map((doc) => (doc.id === docId ? { ...doc, title: newTitle } : doc)) + prev.map((doc) => (doc.id === docId ? { ...doc, title: newName } : doc)) ); try { const res = await fetch(`/api/docs/${docId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle }), + body: JSON.stringify({ title: newName }), }); if (!res.ok) { await fetchTree(); - } else { - const { data } = await res.json(); - if (selectedDoc && selectedDoc.id === docId) { - setSelectedDoc({ ...selectedDoc, title: newTitle }); - } + } else if (selectedDoc?.id === docId) { + setSelectedDoc((prev) => prev ? { ...prev, title: newName } : prev); + setTitle(newName); } } catch (error) { console.error('Failed to rename doc:', error); @@ -200,20 +192,15 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { }, [selectedDoc, fetchTree]); const handleDeleteDoc = useCallback(async (docId: string) => { - // Optimistic update setTree((prev) => prev.filter((doc) => doc.id !== docId)); try { - const res = await fetch(`/api/docs/${docId}`, { - method: 'DELETE', - }); + const res = await fetch(`/api/docs/${docId}`, { method: 'DELETE' }); if (!res.ok) { await fetchTree(); - } else { - if (selectedDoc && selectedDoc.id === docId) { - setSelectedDoc(null); - } + } else if (selectedDoc?.id === docId) { + setSelectedDoc(null); } } catch (error) { console.error('Failed to delete doc:', error); @@ -221,21 +208,19 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { } }, [selectedDoc, fetchTree]); - const generateSlug = useCallback((title: string): string => { - return title + const generateSlug = useCallback((s: string): string => { + return s .toLowerCase() .trim() - .replace(/\s+/g, '-') // Replace spaces with hyphens - .replace(/[^\w\u3131-\uD79D-]/g, '') // Remove special characters, keep alphanumeric, Korean, and hyphens - .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen - .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens + .replace(/\s+/g, '-') + .replace(/[^\w\u3131-\uD79D-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); }, []); - const handleTitleChange = useCallback((title: string) => { - setNewTitle(title); - if (!slugManuallyEdited) { - setNewSlug(generateSlug(title)); - } + const handleNewTitleChange = useCallback((t: string) => { + setNewTitle(t); + if (!slugManuallyEdited) setNewSlug(generateSlug(t)); }, [slugManuallyEdited, generateSlug]); const handleSlugChange = useCallback((slug: string) => { @@ -273,7 +258,6 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const { data } = await res.json(); - // Add to tree setTree((prev) => [ { id: data.id, @@ -287,8 +271,10 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { ...prev, ]); - // Select new doc setSelectedDoc(data); + setTitle(data.title); + setContent(data.content); + setContentFormat(data.content_format || 'markdown'); setShowCreate(false); setNewTitle(''); setNewSlug(''); @@ -296,7 +282,6 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { setNewParentId(null); setSlugManuallyEdited(false); - // Update URL const params = new URLSearchParams(searchParams); params.set('slug', data.slug); router.replace(`?${params.toString()}`); @@ -310,17 +295,12 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { if (!confirm(t('confirmDelete'))) return; try { - const res = await fetch(`/api/docs/${selectedDoc.id}`, { - method: 'DELETE', - }); + const res = await fetch(`/api/docs/${selectedDoc.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete doc'); - // Remove from tree setTree((prev) => prev.filter((doc) => doc.id !== selectedDoc.id)); setSelectedDoc(null); - - // Clear URL router.replace('/docs'); } catch (error) { console.error('Failed to delete doc:', error); @@ -333,9 +313,7 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { useEffect(() => { const slug = searchParams.get('slug'); - if (slug) { - void fetchDoc(slug); - } + if (slug) void fetchDoc(slug); }, [searchParams, fetchDoc]); if (loading) { @@ -399,7 +377,7 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { handleTitleChange(e.target.value)} + onChange={(e) => handleNewTitleChange(e.target.value)} placeholder={t('titlePlaceholder')} /> @@ -440,86 +418,47 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) {
- {editing ? ( - setEditTitle(e.target.value)} - className="text-2xl font-semibold border-none bg-transparent px-0 focus-visible:ring-0" - placeholder={t('titlePlaceholder')} - /> - ) : ( -

- {selectedDoc.title} -

- )} + setTitle(e.target.value)} + className="text-2xl font-semibold border-none bg-transparent px-0 focus-visible:ring-0" + placeholder={t('titlePlaceholder')} + />
-
- {editing ? ( - <> - - - - ) : ( - <> - - - - )} +
+ +
- {/* Content */} + {/* Content — always-editable tiptap */}
- {editing ? ( - - ) : ( - - )} +
) : ( diff --git a/apps/web/src/app/(authenticated)/docs/page-old.tsx b/apps/web/src/app/(authenticated)/docs/page-old.tsx index 8ce7b6f7..98ffccbd 100644 --- a/apps/web/src/app/(authenticated)/docs/page-old.tsx +++ b/apps/web/src/app/(authenticated)/docs/page-old.tsx @@ -783,17 +783,12 @@ export default function DocsPage() { void; onContentFormatChange: (format: ContentFormat) => void; - onModeChange: (mode: EditorMode) => void; labels: { contentFormat: string; markdown: string; html: string; - editorMode: string; - write: string; - preview: string; toolbar: string; hint: string; placeholder: string; @@ -68,6 +61,7 @@ export function DocEditor({ CalloutNode, SlashCommandExtension, ], + editable, content: contentFormat === 'markdown' ? markdownToHtml(value) : value, onUpdate: ({ editor: e }) => { if (suppressUpdateRef.current) return; @@ -80,6 +74,12 @@ export function DocEditor({ }, }); + // Sync editable prop changes + useEffect(() => { + if (!editor) return; + editor.setEditable(editable); + }, [editor, editable]); + // Sync external value changes into the editor useEffect(() => { if (!editor) return; @@ -116,41 +116,24 @@ export function DocEditor({ return (
-
-
- {labels.contentFormat} -
- {(['markdown', 'html'] as const).map((format) => ( - - ))} -
-
-
- {labels.editorMode} -
- {(['write', 'preview'] as const).map((nextMode) => ( - - ))} -
+
+ {labels.contentFormat} +
+ {(['markdown', 'html'] as const).map((format) => ( + + ))}
- {mode === 'write' && editor ? ( + {editor ? ( <>
{labels.hint}

- ) : ( -
- {value.trim() ? ( - - ) : ( -
{labels.placeholder}
- )} -
- )} + ) : null}
); From f8bca25e250f1b73bfcd218e08d928db9d241a5b Mon Sep 17 00:00:00 2001 From: AngryJay91 <16958800+AngryJay91@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:31:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(docs):=20S-DOCS1=20v2=20PO=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=E2=80=94=20debounce=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20+=20handleRename=20=EC=A4=91=EB=B3=B5=EC=A0=80=EC=9E=A5=20+?= =?UTF-8?q?=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract createAutosaveScheduler pure factory from useDocSync (exported for tests) - Add use-doc-sync.test.ts: 6 fake-timer tests covering debounce coalescing + PATCH mock (AC 7: "tiptap onUpdate → debounce → PATCH" & "빠른 연속 입력 1회 병합") - Fix handleRename double-save: parse full PATCH response and setSelectedDoc(data) so updated_at propagates to useDocSync → resets lastSavedSnapshot → no spurious autosave - Add TODO comment on editable={true} for future RBAC integration point Co-Authored-By: Claude Sonnet 4.6 --- .../docs/docs-shell-client.tsx | 9 +- .../src/components/docs/use-doc-sync.test.ts | 140 ++++++++++++++++++ apps/web/src/components/docs/use-doc-sync.ts | 33 ++++- 3 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/docs/use-doc-sync.test.ts diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx index 3dee369c..3f936b9f 100644 --- a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx @@ -182,8 +182,11 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { if (!res.ok) { await fetchTree(); } else if (selectedDoc?.id === docId) { - setSelectedDoc((prev) => prev ? { ...prev, title: newName } : prev); - setTitle(newName); + // Parse full response so updated_at propagates to useDocSync → resets lastSavedSnapshot + // preventing a spurious autosave PATCH after handleRename's own PATCH. + const { data } = (await res.json()) as { data: DocDetail }; + setSelectedDoc(data); + setTitle(data.title); } } catch (error) { console.error('Failed to rename doc:', error); @@ -439,7 +442,7 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('fires the callback once after the configured delay', () => { + const scheduler = createAutosaveScheduler(1500); + const fn = vi.fn(); + + scheduler.schedule(fn); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('coalesces rapid calls — N keystrokes produce exactly 1 callback (debounce)', () => { + const scheduler = createAutosaveScheduler(1500); + const fn = vi.fn(); + + // Simulate 5 rapid schedule() calls at 200 ms intervals (within the 1500 ms window) + for (let i = 0; i < 5; i++) { + scheduler.schedule(fn); + vi.advanceTimersByTime(200); + } + + // Still within the delay — callback not yet fired + expect(fn).not.toHaveBeenCalled(); + + // Advance past the final debounce window + vi.advanceTimersByTime(1500); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('does not fire after cancel()', () => { + const scheduler = createAutosaveScheduler(1500); + const fn = vi.fn(); + + scheduler.schedule(fn); + scheduler.cancel(); + + vi.advanceTimersByTime(2000); + expect(fn).not.toHaveBeenCalled(); + }); + + it('allows a new schedule after cancel()', () => { + const scheduler = createAutosaveScheduler(1500); + const fn = vi.fn(); + + scheduler.schedule(fn); + scheduler.cancel(); + + scheduler.schedule(fn); + vi.advanceTimersByTime(1500); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// PATCH fetch behavior — simulate the save() path that useDocSync calls +// --------------------------------------------------------------------------- + +describe('useDocSync save PATCH behavior', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('sends PATCH to /api/docs/:id with content payload after debounce fires', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { id: 'doc-1', updated_at: '2026-01-02T00:00:00Z' } }), + }); + vi.stubGlobal('fetch', mockFetch); + + const scheduler = createAutosaveScheduler(1500); + const payload = { title: 'Hello', content: '# World', content_format: 'markdown' }; + + scheduler.schedule(async () => { + await fetch('/api/docs/doc-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }); + + vi.advanceTimersByTime(1500); + // Allow the async PATCH callback to resolve + await vi.runAllTimersAsync(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + '/api/docs/doc-1', + expect.objectContaining({ method: 'PATCH' }), + ); + + const sentBody = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string); + expect(sentBody).toEqual(payload); + }); + + it('sends only 1 PATCH when content changes 5 times within the debounce window', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { id: 'doc-1', updated_at: '2026-01-02T00:00:00Z' } }), + }); + vi.stubGlobal('fetch', mockFetch); + + const scheduler = createAutosaveScheduler(1500); + + for (let i = 0; i < 5; i++) { + const content = `# Edit ${i}`; + scheduler.schedule(async () => { + await fetch('/api/docs/doc-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + }); + vi.advanceTimersByTime(200); + } + + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + // The sent content should be from the last scheduled call + const sentBody = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string); + expect(sentBody.content).toBe('# Edit 4'); + }); +}); diff --git a/apps/web/src/components/docs/use-doc-sync.ts b/apps/web/src/components/docs/use-doc-sync.ts index 471da94f..4936a34a 100644 --- a/apps/web/src/components/docs/use-doc-sync.ts +++ b/apps/web/src/components/docs/use-doc-sync.ts @@ -2,6 +2,31 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; export type SaveStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'conflict' | 'remote-changed' | 'error'; +/** + * Pure debounce scheduler — exported for unit tests. + * Each `schedule(fn)` cancels the previous pending call so rapid invocations + * coalesce into a single execution after `delay` ms. + */ +export function createAutosaveScheduler(delay: number) { + let timer: ReturnType | null = null; + + return { + schedule(fn: () => void): void { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn(); + }, delay); + }, + cancel(): void { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }, + }; +} + interface UseDocSyncOptions { docId: string | null; savePayload: Record; @@ -153,11 +178,9 @@ export function useDocSync({ useEffect(() => { if (!editing || !isDirty || conflictRef.current || remoteChangedRef.current) return; - const timer = window.setTimeout(() => { - void save(); - }, autosaveDelay); - - return () => window.clearTimeout(timer); + const scheduler = createAutosaveScheduler(autosaveDelay); + scheduler.schedule(() => { void save(); }); + return () => scheduler.cancel(); }, [autosaveDelay, currentSnapshot, editing, isDirty, save]); useEffect(() => {