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

SNApp-notes / web / 21097437569

17 Jan 2026 04:30PM UTC coverage: 85.98% (-0.5%) from 86.452%
21097437569

push

github

jcubic
attempt to fix the root cause of broken E2E tests

768 of 941 branches covered (81.62%)

Branch coverage included in aggregate %.

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

41 existing lines in 5 files now uncovered.

1495 of 1691 relevant lines covered (88.41%)

1801.57 hits per line

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

80.23
/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 { useParams, usePathname, useRouter } from 'next/navigation';
84
import type { NoteTreeNode } from '@/types/tree';
85
import type { SaveStatus } from '@/types/notes';
86
import { useNodeSelection } from '@/hooks/useNodeSelection';
87
import { selectNode } from '@/lib/utils';
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);
103✔
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() {
103✔
167
  const context = useContext(NotesContext);
8,975✔
168
  if (context === undefined) {
8,975!
UNCOV
169
    throw new Error('useNotesContext must be used within a NotesProvider');
×
170
  }
171
  return context;
8,975✔
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) {
103!
242
  const params = useParams();
2,956✔
243
  const pathname = usePathname();
2,956✔
244
  const router = useRouter();
2,956✔
245

246
  // list of notes that with marked selected node if it exists in URL
247
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
2,956✔
248
  const updatedNotes = useMemo(
2,956✔
249
    () => selectNode(initialNotes, initSelectedNodeId),
650✔
250
    [initialNotes, initSelectedNodeId]
251
  );
252

253
  // Use the hook that manages all the state
254
  const {
255
    notes,
256
    selectedNoteId,
257
    saveStatus,
258
    setNotes,
259
    setSaveStatus,
260
    updateSelection,
261
    updateDirtyFlag,
262
    updateNoteContent,
263
    updateNoteName,
264
    updateNoteTimestamp,
265
    setContentHash
266
  } = useNodeSelection(updatedNotes, initSelectedNodeId);
2,956✔
267

268
  // Track newly created note for immediate edit mode
269
  const [newNoteId, setNewNoteId] = useState<number | null>(null);
2,956✔
270

271
  // Track if note creation is in progress (prevents save during optimistic update)
272
  const [isCreatingNote, setIsCreatingNote] = useState<boolean>(false);
2,956✔
273

274
  // Track if a save is pending (queued during note creation)
275
  const [pendingSave, setPendingSave] = useState<boolean>(false);
2,956✔
276

277
  // Request a save - if note creation is in progress, queue it
278
  const requestSave = useCallback(() => {
2,956✔
UNCOV
279
    if (isCreatingNote) {
×
UNCOV
280
      console.log('Save queued: note creation in progress');
×
UNCOV
281
      setPendingSave(true);
×
282
    }
283
  }, [isCreatingNote]);
284

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

293
  // Sync notes state when initialNotes prop changes (e.g., after redirect)
294
  useEffect(() => {
2,956✔
295
    if (initialNotes.length > 0 && notes.length === 0) {
137!
UNCOV
296
      setNotes(initialNotes);
×
297
    }
298
  }, [initialNotes, notes.length, setNotes]);
299

300
  const markNoteDirty = updateDirtyFlag;
2,956✔
301

302
  const getSelectedNote = useCallback((): NoteTreeNode | null => {
2,956✔
303
    if (!selectedNoteId) return null;
3,434✔
304
    return notes.find((note) => note.id === selectedNoteId) || null;
2,950!
305
  }, [notes, selectedNoteId]);
306

307
  const getNote = useCallback(
2,956✔
308
    (noteId: number): NoteTreeNode | null => {
309
      return notes.find((note) => note.id === noteId) || null;
2,503!
310
    },
311
    [notes]
312
  );
313

314
  // Note selection with Next.js router
315
  const selectNote = useCallback(
2,956✔
316
    (noteId: number | null) => {
317
      // Clear editor state when switching notes
318
      // This ensures cursor/scroll position is NOT restored on note switch
319
      // (only on page refresh)
320
      clearEditorState();
6✔
321

322
      // Update state immediately
323
      updateSelection(noteId);
6✔
324

325
      // Navigate using Next.js router
326
      if (noteId === null) {
6!
UNCOV
327
        router.push('/');
×
328
      } else {
329
        router.push(`/note/${noteId}`);
6✔
330
      }
331
    },
332
    [updateSelection, router]
333
  );
334

335
  // Sync URL to state on URL changes
336
  // Skip when note creation is in progress to prevent race conditions during ID remapping
337
  useEffect(() => {
2,956✔
338
    if (isCreatingNote) {
1,253✔
339
      // During note creation, the URL might be changing due to ID remapping
340
      // Let the LeftPanel handle the selection update after server confirms
341
      return;
23✔
342
    }
343

344
    const urlNoteId = parseId(params);
1,230✔
345

346
    if (urlNoteId !== null && urlNoteId !== selectedNoteId) {
1,230✔
347
      // Only update selection if the note exists in our notes array
348
      // This prevents issues when URL changes before notes array is updated
349
      const noteExists = notes.some((n) => n.id === urlNoteId);
45✔
350
      if (noteExists) {
45!
351
        updateSelection(urlNoteId);
45✔
352
      }
353
    }
354
  }, [params, updateSelection, isCreatingNote, notes, selectedNoteId]);
355

356
  // Auto-select first note when at root with notes available
357
  // Skip when note creation is in progress to prevent race conditions
358
  useEffect(() => {
2,956✔
359
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
1,229✔
360
      const firstNote = notes[0];
78✔
361
      router.push(`/note/${firstNote.id}`);
78✔
362
    }
363
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
364

365
  const value: NotesContextValue = {
2,956✔
366
    notes,
367
    selectedNoteId,
368
    saveStatus,
369
    setNotes,
370
    setSaveStatus,
371
    updateNoteContent,
372
    updateNoteName,
373
    markNoteDirty,
374
    updateNoteTimestamp,
375
    setContentHash,
376
    getSelectedNote,
377
    getNote,
378
    selectNote,
379
    newNoteId,
380
    setNewNoteId,
381
    isCreatingNote,
382
    setIsCreatingNote,
383
    pendingSave,
384
    requestSave,
385
    executePendingSave
386
  };
387

UNCOV
388
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
389
}
390

391
function parseId(params: ReturnType<typeof useParams>) {
392
  return params?.id ? parseInt(params.id as string, 10) : null;
1,938✔
393
}
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