• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SNApp-notes / web / 26250957649

21 May 2026 08:21PM UTC coverage: 77.443% (-0.4%) from 77.798%
26250957649

push

github

jcubic
add debugging logs

711 of 980 branches covered (72.55%)

Branch coverage included in aggregate %.

25 of 39 new or added lines in 9 files covered. (64.1%)

6 existing lines in 1 file now uncovered.

1421 of 1773 relevant lines covered (80.15%)

32.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/components/notes/NotesContext.tsx
1
/**
2
 * @module components/notes/NotesContext
3
 * @description React Context for managing global notes state with URL synchronization.
4
 * Provides notes tree, selection state, save status, and CRUD operations to all child components.
5
 *
6
 * @dependencies
7
 * - `next/navigation` - usePathname and useRouter for URL-based routing
8
 * - `@/hooks/useNodeSelection` - Core state management hook for notes
9
 * - `@/types/tree` - NoteTreeNode type definition
10
 * - `@/types/notes` - SaveStatus type definition
11
 *
12
 * @remarks
13
 * **Features:**
14
 * - Global notes tree state (flat array of notes with selection flags)
15
 * - Selected note tracking with URL synchronization
16
 * - Save status management (saving, saved, error, unsaved)
17
 * - Note CRUD operations (update content, update name, mark dirty)
18
 * - Auto-select first note when navigating to root
19
 * - Next.js router integration for note navigation
20
 *
21
 * **URL Synchronization:**
22
 * - `/note/:id` routes automatically sync to context state
23
 * - `selectNote(id)` updates both state and URL
24
 * - Browser back/forward buttons work correctly
25
 * - Initial note selection from URL on mount
26
 *
27
 * **State Management:**
28
 * - Uses `useNodeSelection` hook for core logic
29
 * - Context wraps hook state for global access
30
 * - Initial state from server-side props (SSR-friendly)
31
 * - Automatically syncs when initial props change
32
 *
33
 * **Performance:**
34
 * - Memoized callbacks to prevent unnecessary re-renders
35
 * - Selective updates (only changed notes re-render)
36
 * - useCallback for all functions to stabilize references
37
 *
38
 * @example
39
 * ```tsx
40
 * import { NotesProvider, useNotesContext } from '@/components/notes/NotesContext';
41
 *
42
 * // Wrap app with provider
43
 * export default function NotesLayout({ children, initialNotes }) {
44
 *   return (
45
 *     <NotesProvider initialNotes={initialNotes}>
46
 *       {children}
47
 *     </NotesProvider>
48
 *   );
49
 * }
50
 *
51
 * // Use context in child component
52
 * function NoteEditor() {
53
 *   const {
54
 *     notes,
55
 *     selectedNoteId,
56
 *     updateNoteContent,
57
 *     saveStatus
58
 *   } = useNotesContext();
59
 *
60
 *   const selectedNote = notes.find(n => n.id === selectedNoteId);
61
 *
62
 *   return (
63
 *     <Editor
64
 *       value={selectedNote?.data?.content || ''}
65
 *       onChange={(content) => updateNoteContent(selectedNoteId!, content)}
66
 *       saveStatus={saveStatus}
67
 *     />
68
 *   );
69
 * }
70
 * ```
71
 */
72
'use client';
×
73

74
import {
75
  createContext,
76
  useContext,
77
  useEffect,
78
  useCallback,
79
  useMemo,
80
  useState,
81
  type ReactNode
82
} from 'react';
83
import { flushSync } from 'react-dom';
84
import { useParams, usePathname, useRouter } from 'next/navigation';
85
import type { NoteTreeNode } from '@/types/tree';
86
import type { SaveStatus } from '@/types/notes';
87
import { useNodeSelection } from '@/hooks/useNodeSelection';
88
import { clearEditorState } from '@/lib/localStorage';
89
import { debug } from '@/lib/debug';
90

91
/**
92
 * NotesContext value interface exposing all notes state and operations.
93
 *
94
 * @interface NotesContextValue
95
 * @property {NoteTreeNode[]} notes - Array of all notes with selection and dirty flags
96
 * @property {number | null} selectedNoteId - Currently selected note ID (null if none)
97
 * @property {SaveStatus} saveStatus - Current save state ('saving' | 'saved' | 'error' | 'unsaved')
98
 * @property {(notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void} setNotes - Update entire notes array
99
 * @property {(status: SaveStatus) => void} setSaveStatus - Update save status
100
 * @property {(noteId: number, content: string) => void} updateNoteContent - Update note content (marks as dirty)
101
 * @property {(noteId: number, name: string) => void} updateNoteName - Update note name (marks as dirty)
102
 * @property {(noteId: number, dirty: boolean) => void} markNoteDirty - Set note's dirty flag
103
 * @property {(noteId: number, updatedAt: Date) => void} updateNoteTimestamp - Update note's updatedAt timestamp
104
 * @property {(noteId: number, content: string) => void} setSavedContentHash - Set saved content hash for undo detection
105
 * @property {() => NoteTreeNode | null} getSelectedNote - Get currently selected note object
106
 * @property {(noteId: number) => NoteTreeNode | null} getNote - Get note by ID
107
 * @property {(noteId: number | null) => void} selectNote - Select note and navigate to URL
108
 * @property {number | null} newNoteId - ID of newly created note in edit mode (null if none)
109
 * @property {(noteId: number | null) => void} setNewNoteId - Set new note ID for immediate edit mode
110
 * @property {boolean} pendingSave - Whether a save is queued waiting for note creation to complete
111
 * @property {() => void} requestSave - Request a save (queues if note creation is in progress)
112
 * @property {() => void} executePendingSave - Execute any pending save (called after note creation)
113
 */
114
interface NotesContextValue {
115
  notes: NoteTreeNode[];
116
  selectedNoteId: number | null;
117
  saveStatus: SaveStatus;
118
  setNotes: (notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void;
119
  setSaveStatus: (status: SaveStatus) => void;
120
  updateNoteContent: (noteId: number, content: string) => void;
121
  updateNoteName: (noteId: number, name: string) => void;
122
  markNoteDirty: (noteId: number, dirty: boolean) => void;
123
  updateNoteTimestamp: (noteId: number, updatedAt: Date) => void;
124
  setContentHash: (noteId: number, content: string) => void;
125
  getSelectedNote: () => NoteTreeNode | null;
126
  getNote: (noteId: number) => NoteTreeNode | null;
127
  selectNote: (noteId: number | null) => void;
128
  newNoteId: number | null;
129
  setNewNoteId: (noteId: number | null) => void;
130
  isCreatingNote: boolean;
131
  setIsCreatingNote: (isCreating: boolean) => void;
132
  pendingSave: boolean;
133
  requestSave: () => void;
134
  executePendingSave: () => void;
135
}
136

137
const NotesContext = createContext<NotesContextValue | undefined>(undefined);
×
138

139
/**
140
 * Hook to access NotesContext value in child components.
141
 *
142
 * @hook
143
 * @returns {NotesContextValue} Notes context value with state and operations
144
 * @throws {Error} If used outside of NotesProvider
145
 *
146
 * @remarks
147
 * Must be used within a component wrapped by `<NotesProvider>`.
148
 * Throws error if context is undefined (not within provider).
149
 *
150
 * @example
151
 * ```tsx
152
 * function NotesList() {
153
 *   const { notes, selectNote } = useNotesContext();
154
 *
155
 *   return (
156
 *     <ul>
157
 *       {notes.map(note => (
158
 *         <li key={note.id} onClick={() => selectNote(note.id)}>
159
 *           {note.name}
160
 *         </li>
161
 *       ))}
162
 *     </ul>
163
 *   );
164
 * }
165
 * ```
166
 */
167
export function useNotesContext() {
168
  const context = useContext(NotesContext);
×
169
  if (context === undefined) {
×
170
    throw new Error('useNotesContext must be used within a NotesProvider');
×
171
  }
172
  return context;
×
173
}
174

175
/**
176
 * Props for NotesProvider component.
177
 *
178
 * @interface NotesProviderProps
179
 * @property {ReactNode} children - Child components to wrap with context
180
 * @property {NoteTreeNode[]} [initialNotes=[]] - Initial notes array (from server)
181
 * @property {number | null} [initialSelectedNoteId=null] - Initial selected note ID
182
 */
183
interface NotesProviderProps {
184
  children: ReactNode;
185
  initialNotes?: NoteTreeNode[];
186
}
187

188
/**
189
 * Notes context provider with URL synchronization and auto-selection.
190
 *
191
 * @component
192
 * @param {NotesProviderProps} props - Provider configuration
193
 * @returns {JSX.Element} Provider wrapping children
194
 *
195
 * @remarks
196
 * **Initialization:**
197
 * - Accepts `initialNotes` from server-side props (SSR)
198
 * - Accepts `initialSelectedNoteId` to pre-select a note
199
 * - Syncs initial state when props change (e.g., after navigation)
200
 *
201
 * **URL Synchronization:**
202
 * - Watches pathname for `/note/:id` pattern
203
 * - Updates selection state when URL changes (e.g., browser back/forward)
204
 * - `selectNote()` updates both state and URL via Next.js router
205
 *
206
 * **Auto-selection:**
207
 * - If at root (`/`) with notes available and none selected, auto-selects first note
208
 * - Automatically navigates to `/note/:id` after auto-selection
209
 * - Prevents empty state when notes exist
210
 *
211
 * **State Management:**
212
 * - Uses `useNodeSelection` hook for core state logic
213
 * - Exposes state and operations via context value
214
 * - Memoized callbacks to prevent unnecessary re-renders
215
 *
216
 * **Operations:**
217
 * - `updateNoteContent`: Updates content and marks as dirty
218
 * - `updateNoteName`: Updates name and marks as dirty
219
 * - `markNoteDirty`: Sets dirty flag (true = unsaved changes)
220
 * - `selectNote`: Updates selection and navigates to URL
221
 * - `getSelectedNote`: Returns currently selected note object
222
 * - `getNote`: Returns note by ID
223
 *
224
 * @example
225
 * ```tsx
226
 * // In layout component
227
 * export default async function NotesLayout({ children }) {
228
 *   const notes = await getNotes();
229
 *   const selectedId = getSelectedNoteIdFromUrl();
230
 *
231
 *   return (
232
 *     <NotesProvider
233
 *       initialNotes={notes}
234
 *       initialSelectedNoteId={selectedId}
235
 *     >
236
 *       {children}
237
 *     </NotesProvider>
238
 *   );
239
 * }
240
 * ```
241
 */
242
export function NotesProvider({ children, initialNotes = [] }: NotesProviderProps) {
×
243
  const params = useParams();
×
244
  const pathname = usePathname();
×
245
  const router = useRouter();
×
246
  // list of notes that with marked selected node if it exists in URL
247
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
×
248

249
  // Use the hook that manages all the state
250
  const {
251
    notes,
252
    selectedNoteId,
253
    saveStatus,
254
    setNotes,
255
    setSaveStatus,
256
    updateSelection,
257
    updateDirtyFlag,
258
    updateNoteContent,
259
    updateNoteName,
260
    updateNoteTimestamp,
261
    setContentHash
×
262
  } = useNodeSelection(initialNotes, initSelectedNodeId);
263

264
  // Optimistic selection: a local state that is updated synchronously via
265
  // flushSync before router.push() runs, so the blue highlight paints on the
266
  // very next frame instead of waiting for the server round-trip to complete.
267
  // It mirrors selectedNoteId and is kept in sync by the useEffect below.
268
  const [optimisticSelectedNoteId, setOptimisticSelectedNoteId] = useState<number | null>(
×
269
    initSelectedNodeId
270
  );
271

272
  // Track newly created note for immediate edit mode
273
  const [newNoteId, setNewNoteId] = useState<number | null>(null);
×
274

275
  // Track if note creation is in progress (prevents save during optimistic update)
276
  const [isCreatingNote, setIsCreatingNote] = useState<boolean>(false);
×
277

278
  // Track if a save is pending (queued during note creation)
279
  const [pendingSave, setPendingSave] = useState<boolean>(false);
×
280

281
  // Request a save - if note creation is in progress, queue it
282
  const requestSave = useCallback(() => {
×
283
    if (isCreatingNote) {
×
284
      console.log('Save queued: note creation in progress');
×
285
      setPendingSave(true);
×
286
    }
287
  }, [isCreatingNote]);
288

289
  // Execute pending save (called from LeftPanel after note creation completes)
290
  // This is a no-op in context - the actual save is triggered via effect in content
291
  const executePendingSave = useCallback(() => {
×
292
    // Clear the pending flag - the actual save will be triggered
293
    // by an effect watching this flag in the content component
294
    setPendingSave(false);
×
295
  }, []);
296

297
  // Sync notes state when initialNotes prop changes (e.g., after redirect)
298
  useEffect(() => {
×
299
    if (initialNotes.length > 0 && notes.length === 0) {
×
300
      setNotes(initialNotes);
×
301
    }
302
  }, [initialNotes, notes.length, setNotes]);
303

304
  const markNoteDirty = updateDirtyFlag;
×
305

306
  const getSelectedNote = useCallback((): NoteTreeNode | null => {
×
307
    if (!optimisticSelectedNoteId) return null;
×
308
    return notes.find((note) => note.id === optimisticSelectedNoteId) || null;
×
309
  }, [notes, optimisticSelectedNoteId]);
310

311
  const getNote = useCallback(
×
312
    (noteId: number): NoteTreeNode | null => {
313
      return notes.find((note) => note.id === noteId) || null;
×
314
    },
315
    [notes]
316
  );
317

UNCOV
318
  const selectNote = useCallback(
×
319
    (noteId: number | null) => {
NEW
320
      debug('NotesContext.selectNote', {
×
321
        noteId,
322
        currentSelectedNoteId: selectedNoteId,
323
        optimisticSelectedNoteId,
324
        alreadyOnNote: noteId === selectedNoteId
325
      });
326
      if (noteId === selectedNoteId) {
×
NEW
327
        debug('NotesContext.selectNote', 'SKIPPED — same note, calling router.refresh()');
×
328
        router.refresh();
×
329
        return;
×
330
      }
331

332
      // Clear editor state when switching notes
333
      // This ensures cursor/scroll position is NOT restored on note switch
334
      // (only on page refresh)
335
      clearEditorState();
×
336

337
      // Update the optimistic selection synchronously so the blue highlight
338
      // paints on the very next frame. flushSync forces React to flush this
339
      // state update before returning, which means the DOM is updated before
340
      // router.push() schedules its (deferred) startTransition navigation.
341
      flushSync(() => {
×
342
        setOptimisticSelectedNoteId(noteId);
×
343
      });
344

345
      // Clear ?line= from URL before navigation. We use replaceState with the
346
      // current history state (which has __NA) so Next.js's patched replaceState
347
      // short-circuits and does NOT dispatch ACTION_RESTORE — avoiding a race
348
      // between the restore and the subsequent router.push.
349
      if (window.location.search.includes('line=')) {
×
350
        const cleanUrl = new URL(window.location.href);
×
351
        cleanUrl.searchParams.delete('line');
×
352
        window.history.replaceState(
×
353
          window.history.state,
354
          '',
355
          cleanUrl.pathname + cleanUrl.search + cleanUrl.hash
356
        );
357
      }
358

359
      if (noteId === null) {
×
360
        router.push('/', { scroll: false });
×
361
      } else {
362
        router.push(`/note/${noteId}`, { scroll: false });
×
363
      }
364
    },
365
    [setOptimisticSelectedNoteId, router, selectedNoteId, optimisticSelectedNoteId]
366
  );
367

UNCOV
368
  useEffect(() => {
×
369
    const urlNoteId = parseId(params);
×
NEW
370
    debug('NotesContext.syncURL', {
×
371
      urlNoteId,
372
      selectedNoteId,
373
      noteCount: notes.length,
NEW
374
      noteExists: urlNoteId !== null ? notes.some((n) => n.id === urlNoteId) : 'N/A'
×
375
    });
376
    if (urlNoteId !== null) {
×
377
      if (urlNoteId !== selectedNoteId) {
×
378
        const noteExists = notes.some((n) => n.id === urlNoteId);
×
379
        if (noteExists) {
×
NEW
380
          debug('NotesContext.syncURL', 'updating selection to', urlNoteId);
×
UNCOV
381
          updateSelection(urlNoteId);
×
382
        } else {
NEW
383
          debug('NotesContext.syncURL', 'note NOT in array, skipping');
×
384
        }
385
      } else {
NEW
386
        debug('NotesContext.syncURL', 'already selected, skipping');
×
387
      }
388
    }
389
  }, [params, updateSelection, notes, selectedNoteId]);
390

391
  // Keep optimisticSelectedNoteId in sync with the committed selectedNoteId
392
  // so that back/forward navigation (which goes through updateSelection above)
393
  // is always reflected correctly.
394
  useEffect(() => {
×
395
    setOptimisticSelectedNoteId(selectedNoteId);
×
396
  }, [selectedNoteId]);
397

UNCOV
398
  useEffect(() => {
×
UNCOV
399
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
×
400
      const firstNote = notes[0];
×
NEW
401
      debug('NotesContext.autoSelect', 'auto-selecting first note', firstNote.id);
×
UNCOV
402
      router.push(`/note/${firstNote.id}`);
×
403
    }
404
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
405

406
  const value: NotesContextValue = {
×
407
    notes,
408
    selectedNoteId: optimisticSelectedNoteId,
409
    saveStatus,
410
    setNotes,
411
    setSaveStatus,
412
    updateNoteContent,
413
    updateNoteName,
414
    markNoteDirty,
415
    updateNoteTimestamp,
416
    setContentHash,
417
    getSelectedNote,
418
    getNote,
419
    selectNote,
420
    newNoteId,
421
    setNewNoteId,
422
    isCreatingNote,
423
    setIsCreatingNote,
424
    pendingSave,
425
    requestSave,
426
    executePendingSave
427
  };
428

429
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
430
}
431

432
function parseId(params: ReturnType<typeof useParams>) {
433
  return params?.id ? parseInt(params.id as string, 10) : null;
×
434
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc