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

SNApp-notes / web / 23667783842

27 Mar 2026 09:14PM UTC coverage: 85.862% (+0.01%) from 85.852%
23667783842

push

github

jcubic
fix tests and update workflow

782 of 963 branches covered (81.2%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 1 file covered. (100.0%)

30 existing lines in 3 files now uncovered.

1538 of 1739 relevant lines covered (88.44%)

2313.65 hits per line

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

76.67
/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

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

136
const NotesContext = createContext<NotesContextValue | undefined>(undefined);
99✔
137

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

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

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

246
  // list of notes that with marked selected node if it exists in URL
247
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
3,524✔
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);
3,524✔
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>(
3,524✔
269
    initSelectedNodeId
270
  );
271

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

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

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

281
  // Request a save - if note creation is in progress, queue it
282
  const requestSave = useCallback(() => {
3,524✔
UNCOV
283
    if (isCreatingNote) {
×
UNCOV
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(() => {
3,524✔
292
    // Clear the pending flag - the actual save will be triggered
293
    // by an effect watching this flag in the content component
UNCOV
294
    setPendingSave(false);
×
295
  }, []);
296

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

304
  const markNoteDirty = updateDirtyFlag;
3,524✔
305

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

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

318
  // Note selection with Next.js router
319
  const selectNote = useCallback(
3,524✔
320
    (noteId: number | null) => {
321
      // If already on this note, refresh server data to ensure content is up-to-date
322
      // (avoids stale content after page reload without resetting editor state)
323
      if (noteId === selectedNoteId) {
11!
UNCOV
324
        router.refresh();
×
UNCOV
325
        return;
×
326
      }
327

328
      // Clear editor state when switching notes
329
      // This ensures cursor/scroll position is NOT restored on note switch
330
      // (only on page refresh)
331
      clearEditorState();
11✔
332

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

341
      // Navigate using Next.js router
342
      // Use pathname without query params to clear line parameter
343
      if (noteId === null) {
11!
UNCOV
344
        router.push('/', { scroll: false });
×
345
      } else {
346
        router.push(`/note/${noteId}`, { scroll: false });
11✔
347
      }
348
    },
349
    [setOptimisticSelectedNoteId, router, selectedNoteId]
350
  );
351

352
  // Sync URL to state on URL changes
353
  useEffect(() => {
3,524✔
354
    const urlNoteId = parseId(params);
1,357✔
355

356
    // Always update selection when URL has a note ID, but only if:
357
    // 1. The note exists in our notes array (prevents issues when URL changes before notes array is updated)
358
    // 2. The note is different from currently selected (optimization)
359
    if (urlNoteId !== null) {
1,357✔
360
      if (urlNoteId !== selectedNoteId) {
1,220✔
361
        const noteExists = notes.some((n) => n.id === urlNoteId);
51✔
362
        if (noteExists) {
49!
363
          updateSelection(urlNoteId);
49✔
364
        }
365
      }
366
    }
367
  }, [params, updateSelection, notes, selectedNoteId]);
368

369
  // Keep optimisticSelectedNoteId in sync with the committed selectedNoteId
370
  // so that back/forward navigation (which goes through updateSelection above)
371
  // is always reflected correctly.
372
  useEffect(() => {
3,524✔
373
    setOptimisticSelectedNoteId(selectedNoteId);
137✔
374
  }, [selectedNoteId]);
375

376
  // Auto-select first note when at root with notes available
377
  // Skip when note creation is in progress to prevent race conditions
378
  useEffect(() => {
3,524✔
379
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
1,331✔
380
      const firstNote = notes[0];
77✔
381
      router.push(`/note/${firstNote.id}`);
77✔
382
    }
383
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
384

385
  const value: NotesContextValue = {
3,524✔
386
    notes,
387
    selectedNoteId: optimisticSelectedNoteId,
388
    saveStatus,
389
    setNotes,
390
    setSaveStatus,
391
    updateNoteContent,
392
    updateNoteName,
393
    markNoteDirty,
394
    updateNoteTimestamp,
395
    setContentHash,
396
    getSelectedNote,
397
    getNote,
398
    selectNote,
399
    newNoteId,
400
    setNewNoteId,
401
    isCreatingNote,
402
    setIsCreatingNote,
403
    pendingSave,
404
    requestSave,
405
    executePendingSave
406
  };
407

UNCOV
408
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
409
}
410

411
function parseId(params: ReturnType<typeof useParams>) {
412
  return params?.id ? parseInt(params.id as string, 10) : null;
2,209✔
413
}
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