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

SNApp-notes / web / 21139744376

19 Jan 2026 01:43PM UTC coverage: 86.034% (+0.05%) from 85.98%
21139744376

push

github

jcubic
fix tests

769 of 941 branches covered (81.72%)

Branch coverage included in aggregate %.

1498 of 1694 relevant lines covered (88.43%)

2229.08 hits per line

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

80.46
/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);
100✔
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() {
100✔
167
  const context = useContext(NotesContext);
9,772✔
168
  if (context === undefined) {
9,772!
169
    throw new Error('useNotesContext must be used within a NotesProvider');
×
170
  }
171
  return context;
9,772✔
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) {
100!
242
  const params = useParams();
3,218✔
243
  const pathname = usePathname();
3,218✔
244
  const router = useRouter();
3,218✔
245

246
  // list of notes that with marked selected node if it exists in URL
247
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
3,218✔
248
  const updatedNotes = useMemo(
3,218✔
249
    () => selectNode(initialNotes, initSelectedNodeId),
652✔
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);
3,218✔
267

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

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

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

277
  // Request a save - if note creation is in progress, queue it
278
  const requestSave = useCallback(() => {
3,218✔
279
    if (isCreatingNote) {
×
280
      console.log('Save queued: note creation in progress');
×
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(() => {
3,218✔
288
    // Clear the pending flag - the actual save will be triggered
289
    // by an effect watching this flag in the content component
290
    setPendingSave(false);
×
291
  }, []);
292

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

300
  const markNoteDirty = updateDirtyFlag;
3,218✔
301

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

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

314
  // Note selection with Next.js router
315
  const selectNote = useCallback(
3,218✔
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();
8✔
321

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

325
      // Navigate using Next.js router
326
      // Use pathname without query params to clear line parameter
327
      if (noteId === null) {
8!
328
        router.push('/', { scroll: false });
×
329
      } else {
330
        router.push(`/note/${noteId}`, { scroll: false });
8✔
331
      }
332
    },
333
    [updateSelection, router]
334
  );
335

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

345
    const urlNoteId = parseId(params);
1,342✔
346

347
    // Always update selection when URL has a note ID, but only if:
348
    // 1. The note exists in our notes array (prevents issues when URL changes before notes array is updated)
349
    // 2. The note is different from currently selected (optimization)
350
    if (urlNoteId !== null) {
1,342✔
351
      if (urlNoteId !== selectedNoteId) {
1,219✔
352
        const noteExists = notes.some((n) => n.id === urlNoteId);
45✔
353
        if (noteExists) {
45!
354
          updateSelection(urlNoteId);
45✔
355
        }
356
      }
357
    }
358
  }, [params, updateSelection, isCreatingNote, notes, selectedNoteId]);
359

360
  // Auto-select first note when at root with notes available
361
  // Skip when note creation is in progress to prevent race conditions
362
  useEffect(() => {
3,218✔
363
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
1,337✔
364
      const firstNote = notes[0];
75✔
365
      router.push(`/note/${firstNote.id}`);
75✔
366
    }
367
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
368

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

392
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
393
}
394

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