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

SNApp-notes / web / 25705753638

12 May 2026 12:33AM UTC coverage: 77.798% (-8.1%) from 85.872%
25705753638

push

github

jcubic
refactor CI/CD

705 of 968 branches covered (72.83%)

Branch coverage included in aggregate %.

1401 of 1739 relevant lines covered (80.56%)

31.05 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

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);
×
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() {
167
  const context = useContext(NotesContext);
×
168
  if (context === undefined) {
×
169
    throw new Error('useNotesContext must be used within a NotesProvider');
×
170
  }
171
  return context;
×
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) {
×
242
  const params = useParams();
×
243
  const pathname = usePathname();
×
244
  const router = useRouter();
×
245
  // list of notes that with marked selected node if it exists in URL
246
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
×
247

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

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

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

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

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

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

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

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

303
  const markNoteDirty = updateDirtyFlag;
×
304

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

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

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

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

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

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

354
      if (noteId === null) {
×
355
        router.push('/', { scroll: false });
×
356
      } else {
357
        router.push(`/note/${noteId}`, { scroll: false });
×
358
      }
359
    },
360
    [setOptimisticSelectedNoteId, router, selectedNoteId]
361
  );
362

363
  // Sync URL to state on URL changes
364
  useEffect(() => {
×
365
    const urlNoteId = parseId(params);
×
366

367
    // Always update selection when URL has a note ID, but only if:
368
    // 1. The note exists in our notes array (prevents issues when URL changes before notes array is updated)
369
    // 2. The note is different from currently selected (optimization)
370
    if (urlNoteId !== null) {
×
371
      if (urlNoteId !== selectedNoteId) {
×
372
        const noteExists = notes.some((n) => n.id === urlNoteId);
×
373
        if (noteExists) {
×
374
          updateSelection(urlNoteId);
×
375
        }
376
      }
377
    }
378
  }, [params, updateSelection, notes, selectedNoteId]);
379

380
  // Keep optimisticSelectedNoteId in sync with the committed selectedNoteId
381
  // so that back/forward navigation (which goes through updateSelection above)
382
  // is always reflected correctly.
383
  useEffect(() => {
×
384
    setOptimisticSelectedNoteId(selectedNoteId);
×
385
  }, [selectedNoteId]);
386

387
  // Auto-select first note when at root with notes available
388
  // Skip when note creation is in progress to prevent race conditions
389
  useEffect(() => {
×
390
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
×
391
      const firstNote = notes[0];
×
392
      router.push(`/note/${firstNote.id}`);
×
393
    }
394
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
395

396
  const value: NotesContextValue = {
×
397
    notes,
398
    selectedNoteId: optimisticSelectedNoteId,
399
    saveStatus,
400
    setNotes,
401
    setSaveStatus,
402
    updateNoteContent,
403
    updateNoteName,
404
    markNoteDirty,
405
    updateNoteTimestamp,
406
    setContentHash,
407
    getSelectedNote,
408
    getNote,
409
    selectNote,
410
    newNoteId,
411
    setNewNoteId,
412
    isCreatingNote,
413
    setIsCreatingNote,
414
    pendingSave,
415
    requestSave,
416
    executePendingSave
417
  };
418

419
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
420
}
421

422
function parseId(params: ReturnType<typeof useParams>) {
423
  return params?.id ? parseInt(params.id as string, 10) : null;
×
424
}
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