- {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(() => {