Skip to content

fix: frontend state management bugs and performance#715

Open
2witstudios wants to merge 4 commits intomasterfrom
ppg/frontend-state-fixes
Open

fix: frontend state management bugs and performance#715
2witstudios wants to merge 4 commits intomasterfrom
ppg/frontend-state-fixes

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 24, 2026

Summary

  • Fix data loss bug: Migrate CanvasPageView from useDocumentStore (shared saveTimeoutId) to useDocumentManagerStore (per-document Map<string, DocumentState>)
  • Fix SWR initial fetch blocked: Add hasLoadedRef guard to useDevices and usePageAgents so isPaused never blocks the first fetch
  • Fix SheetView socket churn: Move documentState.content/isDirty reads into refs so the socket useEffect doesn't re-subscribe on every keystroke
  • Add React.memo to PageTreeItem: Most heavily-rendered component (one per tree node) now skips re-render when props are unchanged
  • Fix stale tab titles: EditableTitle now syncs renamed page title to both useOpenTabsStore and useTabsStore
  • Fix permission fetch race: Add AbortController to permission checks in DocumentView and SheetView so stale responses don't overwrite isReadOnly
  • Fix CanvasPageView save lifecycle: Reset isDirty after successful debounced save; force-save dirty content on unmount
  • Fix save error propagation (review follow-up): saveContent now rethrows so failed saves leave isDirty=true
  • Fix isDirty race condition (review follow-up): Version counter ensures only the latest save can clear dirty state
  • Fix stale content on revisit (review follow-up): Initialize effect refreshes cached doc from props; unmount clears document cache

Test plan

  • Open two documents simultaneously, edit both, verify both save correctly (no lost data)
  • Start editing a document, then navigate to Devices page — verify devices list loads
  • Open a sheet, type rapidly, verify socket listener doesn't thrash (check console for re-subscribe logs)
  • Expand a large page tree, verify smooth scrolling without jank
  • Rename a page via the title header, verify tab title updates in both tab bars
  • Rapidly switch between pages, verify permission state is correct for each page
  • Edit canvas, navigate away within 1s — verify content is saved (force-save on unmount)
  • Edit canvas, simulate network failure — verify isDirty stays true and content isn't lost
  • Edit canvas rapidly while save is in-flight — verify newer edits aren't cleared by stale save response
  • Navigate away from canvas and back — verify fresh content is shown, not stale cache

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tab titles automatically sync with page title updates
    • Permission notifications alert users when edit access is unavailable
  • Bug Fixes

    • Auto-save captures unsaved changes on window blur and page unmount
    • Improved request cancellation to prevent redundant operations
  • Improvements

    • Optimized document state management, debounced saves, and real-time sync
    • Memoized sidebar page items for rendering performance
    • Smarter pausing for background data refreshes after initial load
    • Deprecated legacy per-page document store in favor of a manager
  • Tests

    • Updated tests to cover new load/pausing behavior for device fetching

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 22 minutes and 58 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between e1e70a3 and e6147c6.

📒 Files selected for processing (9)
  • apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
  • apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx
  • apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
  • apps/web/src/hooks/__tests__/useDevices.test.ts
  • apps/web/src/hooks/page-agents/usePageAgents.ts
  • apps/web/src/hooks/useDevices.ts
  • apps/web/src/stores/useDocumentStore.ts
📝 Walkthrough

Walkthrough

Memoizes a sidebar item, migrates canvas/sheet views to per-page document manager with debounced saves and unmount flush, syncs edited page titles to open tabs, adds AbortController-backed permission checks with toast, and changes SWR pausing to require an initial successful load before pausing during editing.

Changes

Cohort / File(s) Summary
Sidebar component memoization
apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
Wraps PageTreeItem in React.memo, adds direct React import, converts export from function declaration to a memoized const.
Per-page document state & autosave
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx, apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx, apps/web/src/stores/useDocumentStore.ts
Switches to useDocumentManagerStore/per-page document state, adds debounced save with save/version refs, unmount flush (force-save), server-originated update handler to avoid save loops, content/isDirty refs in SheetView, and marks old document store deprecated in a comment.
Tab title synchronization
apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx
Adds useOpenTabsStore and useTabsStore usage to propagate title updates to open tabs and update tab metadata for tabs matching the page id.
Permission checks and AbortController
apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx, apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
Adds AbortController to permission fetches with cleanup and AbortError handling; shows a toast when user cannot edit.
SWR pausing logic (hasLoadedRef)
apps/web/src/hooks/page-agents/usePageAgents.ts, apps/web/src/hooks/useDevices.ts, apps/web/src/hooks/__tests__/useDevices.test.ts
Introduces hasLoadedRef and onSuccess so SWR isPaused only pauses during editing after the first successful load; tests updated to simulate initial load and assert new semantics.
Tests & minor edits
apps/web/src/hooks/__tests__/useDevices.test.ts, apps/web/src/stores/useDocumentStore.ts
Updates tests to reflect hasLoadedRef behavior; adds deprecation comment to useDocumentStore.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CanvasPageView as CanvasPageView
    participant DocumentManagerStore as DocumentManagerStore
    participant SaveScheduler as SaveScheduler
    participant API as API

    User->>CanvasPageView: Edit content
    CanvasPageView->>DocumentManagerStore: setContent(content) & mark isDirty
    CanvasPageView->>SaveScheduler: schedule debounced save (1s)
    SaveScheduler-->>CanvasPageView: timer fires (if no new edits)
    CanvasPageView->>API: saveContent(latest content)
    API-->>CanvasPageView: save success
    CanvasPageView->>DocumentManagerStore: clear isDirty, update lastSaved
    User->>CanvasPageView: Navigate away / unmount
    CanvasPageView->>CanvasPageView: if isDirty -> force save before unmount
Loading
sequenceDiagram
    actor User
    participant EditableTitle as EditableTitle
    participant API as API
    participant OpenTabsStore as OpenTabsStore
    participant TabsStore as TabsStore

    User->>EditableTitle: Submit new title
    EditableTitle->>API: PATCH /pages/:id {title}
    API-->>EditableTitle: 200 OK (updated title)
    EditableTitle->>OpenTabsStore: updateTabTitle(pageId, newTitle)
    EditableTitle->>TabsStore: iterate tabs -> update metadata for matching path/pageId
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hopped through code with whiskers all bright,

Memo’d a leaf and made saves sleep tight,
Titles now bounce to tabs in a hop,
Abort guards the fetch so toasts don’t flop,
SWR waits once — then edits can stop.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: frontend state management bugs and performance' directly and clearly summarizes the pull request's main objective—addressing state management issues and performance concerns across multiple frontend components.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ppg/frontend-state-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c02eca689

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@2witstudios
Copy link
Owner Author

Re: Codex review on CanvasPageView cleanup (P1: flush pending edits)

Addressed in commit 7c02f1d. The cleanup effect was split into two:

  1. Initialization effect (depends on page.id, page.content) — creates the document in the manager store
  2. Force-save on unmount (empty deps, runs only on TRUE unmount) — clears the debounce timer AND force-saves dirty content via saveContentRef

Additionally, the debounced save timeout now resets isDirty: false on success, so the store state stays accurate throughout the save lifecycle.

@2witstudios
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 52-73: The debounce currently catches all errors so the
success-path updateDocument call clears isDirty even when save fails; remove the
empty catch here (or rethrow the error) so failures from saveContent(page.id,
newContent) propagate and prevent updating isDirty. Update the setContent
callback (inside the setTimeout) to either await saveContent without a try/catch
or, if you must catch to log, rethrow the caught error; ensure saveContent still
throws on failure so useDocumentManagerStore.getState().updateDocument(page.id,
{ isDirty: false, lastSaved: ... }) only runs when saveContent resolves
successfully.
- Around line 88-92: The mounted effect calls
useDocumentManagerStore.getState().createDocument(page.id, ...) but
createDocument is a no-op if the doc exists, causing stale content when
revisiting; fix by first reading the cached document via
useDocumentManagerStore.getState().getDocument(page.id) (or equivalent getter)
and if it exists compare its text to the fresh page.content and call an update
method (e.g., updateDocument or createDocument with overwrite) when they differ;
additionally add a cleanup in the same useEffect that removes or clears the
document on unmount (e.g.,
useDocumentManagerStore.getState().deleteDocument(page.id) or resetDocument) so
documents don't persist across remounts when out-of-band server changes occur.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6247d21 and 7c02f1d.

📒 Files selected for processing (8)
  • apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
  • apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx
  • apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
  • apps/web/src/hooks/page-agents/usePageAgents.ts
  • apps/web/src/hooks/useDevices.ts
  • apps/web/src/stores/useDocumentStore.ts

@2witstudios
Copy link
Owner Author

Addressing Review Feedback (d556e65)

Thread 1: Codex Bot P1 — Flush pending canvas edits before clearing debounce timer

The codex bot reviewed commit 8c02eca which didn't have the force-save on unmount. Commit 7c02f1d added it, and d556e65 further strengthens it:

  • saveContent now rethrows so the debounce catch block leaves isDirty=true on network failure
  • saveVersionRef counter prevents a stale in-flight save from clearing isDirty when newer edits exist
  • Unmount cleanup flushes pending debounce, force-saves if dirty, then clears the document from the cache

Thread 2: CodeRabbit Critical — Debounced save can clear isDirty even when save fails

Fixed. saveContent now re-throws after logging/toasting. The catch block in the debounce no longer clears isDirty, so failed saves leave the document dirty for retry or unmount force-save.

Thread 3: CodeRabbit Major — Stale content shown when revisiting canvas page

Fixed. The initialize effect now checks for an existing cached document and refreshes it from the prop if the content differs and the doc isn't dirty. On unmount, clearDocument is called after force-save to prevent stale cache across remounts.

CI Fix: useDevices.test.ts (2 failing tests)

Updated tests to match the hasLoadedRef behavior: isPaused only blocks revalidation after the initial fetch completes (via onSuccess). Before first load, isPaused returns false even when editing is active — this is the intended behavior to prevent blocking initial data hydration.

User Review: P1 — Keep canvas dirty state until latest save confirmed

Fixed via saveVersionRef. Each call to setContent increments the version counter. After a save completes, isDirty is only cleared if the version hasn't changed — meaning no newer edits arrived while the save was in flight. Combined with the error rethrow, this ensures isDirty stays true whenever edits are unsaved, whether due to newer typing or network failure.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/web/src/hooks/__tests__/useDevices.test.ts (1)

75-75: Guard onSuccess with toBeDefined before calling it via non-null assertion.

Both lines call swrConfig.onSuccess!() without first asserting the field exists. If onSuccess is ever removed from useDevices.ts, the tests will throw an unguarded runtime error instead of a clean, descriptive assertion failure.

🛡️ Proposed fix (shown for line 75; apply the same pattern at line 101)
+      expect(swrConfig.onSuccess).toBeDefined();
       swrConfig.onSuccess!();

Also applies to: 101-101

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/__tests__/useDevices.test.ts` at line 75, Add a guard
asserting that swrConfig.onSuccess is defined before invoking it in the test:
replace the direct non-null assertion call with an assertion like
expect(swrConfig.onSuccess).toBeDefined(); followed by the call to
swrConfig.onSuccess!(); Apply this change for both places where
swrConfig.onSuccess is invoked in the useDevices.test.ts tests so the test fails
with a clear assertion if the handler is removed.
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (1)

246-251: React.memo comparator is appropriate but note the coupling with unmount cleanup.

The comparator correctly limits re-renders to changes in page.id and page.content. However, because it allows re-renders on page.id change (not just remounts), the empty-deps unmount effect (lines 112-124) must use refs to access the current page.id — see related comment above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 246 - 251, The memo comparator on CanvasPageView only prevents
renders when page.id and page.content are unchanged, but because
CanvasPageView's empty-deps unmount effect (the cleanup run on component
unmount) currently closes over the original page.id, update that effect to read
the current page id from a ref instead of the stale prop: add a pageIdRef (e.g.,
const pageIdRef = useRef(page.id)), update pageIdRef.current on each render or
in a useEffect that depends on page.id, and then reference pageIdRef.current
inside the cleanup callback so cleanup uses the latest page id when the
component unmounts; keep the React.memo comparator as-is (export default
React.memo(CanvasPageView, ...)).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 112-124: The cleanup effect captures a stale page.id; update the
component to track the current page id in a ref (e.g., pageIdRef) and keep it in
sync whenever props.page.id changes, then use pageIdRef.current inside the
useEffect cleanup instead of the closed-over page.id; specifically, create
pageIdRef (like saveContentRef), set pageIdRef.current = page.id in a short
effect that runs on page.id changes, and in the existing useEffect cleanup
replace references to page.id with pageIdRef.current when calling
saveContentRef.current(...) and
useDocumentManagerStore.getState().clearDocument(...).
- Around line 23-32: CanvasPageView never registers editing with the global
editing store; add a useEffect inside the CanvasPageView component that calls
useEditingStore.getState().startEditing() when the component enters edit mode
(e.g., when activeTab === 'edit' or when the editor is focused) and returns a
cleanup that calls useEditingStore.getState().stopEditing() (or the appropriate
end method) to unregister on unmount or when leaving edit mode; place this
effect in CanvasPageView near the other refs/hooks (alongside
containerRef/saveTimeoutRef) so SWR revalidation is suppressed while editing.

---

Nitpick comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 246-251: The memo comparator on CanvasPageView only prevents
renders when page.id and page.content are unchanged, but because
CanvasPageView's empty-deps unmount effect (the cleanup run on component
unmount) currently closes over the original page.id, update that effect to read
the current page id from a ref instead of the stale prop: add a pageIdRef (e.g.,
const pageIdRef = useRef(page.id)), update pageIdRef.current on each render or
in a useEffect that depends on page.id, and then reference pageIdRef.current
inside the cleanup callback so cleanup uses the latest page id when the
component unmounts; keep the React.memo comparator as-is (export default
React.memo(CanvasPageView, ...)).

In `@apps/web/src/hooks/__tests__/useDevices.test.ts`:
- Line 75: Add a guard asserting that swrConfig.onSuccess is defined before
invoking it in the test: replace the direct non-null assertion call with an
assertion like expect(swrConfig.onSuccess).toBeDefined(); followed by the call
to swrConfig.onSuccess!(); Apply this change for both places where
swrConfig.onSuccess is invoked in the useDevices.test.ts tests so the test fails
with a clear assertion if the handler is removed.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c02f1d and d556e65.

📒 Files selected for processing (2)
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/hooks/__tests__/useDevices.test.ts

@2witstudios
Copy link
Owner Author

Addressing Round 2 Review Feedback (e1e70a3)

Thread 4: CodeRabbit — Missing useEditingStore registration

Fixed. Added useEditingStore.getState().startEditing(page.id, 'document') when isDirty is true, per the CLAUDE.md UI Refresh Protection guidelines. This prevents SWR revalidation from disrupting canvas edits. The editing session is cleaned up both on isDirty transitioning to false and on unmount.

Thread 5: CodeRabbit — Stale page.id in unmount cleanup

Fixed. Added pageIdRef (same pattern as saveContentRef) that stays in sync via a short effect. The unmount cleanup now reads pageIdRef.current instead of the closed-over page.id.

Note: The parent (CenterPanel.tsx:174) renders <CanvasPageView key={\canvas-${page.id}`} page={page} />`, so the component always remounts when page changes — but the ref pattern is a defensive improvement regardless.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (1)

140-173: ⚠️ Potential issue | 🟡 Minor

Missing AbortController — stale fetch can overwrite freshly-initialized content on rapid remount.

fetchWithAuth on line 155 is not aborted when the component unmounts or the socket listener is torn down. The unmount cleanup calls clearDocument, so a late response arriving after unmount is a safe no-op. However, if the parent remounts the component with the same page.id before the fetch resolves (the init effect will have re-created the document), the stale response will call updateContentFromServer on the newly-created document, potentially replacing fresher content with an older snapshot.

The PR already uses AbortController in DocumentView and SheetView for permission checks; apply the same pattern here.

🛠️ Proposed fix
  useEffect(() => {
    if (!socket) return;
+   let abortController: AbortController | null = null;

    const handleContentUpdate = async (eventData: PageEventPayload) => {
      if (eventData.socketId && eventData.socketId === socket.id) return;
      if (eventData.pageId === page.id) {
        console.log(`[Canvas] Received content update for page ${page.id}`);
        try {
+         abortController = new AbortController();
-         const response = await fetchWithAuth(`/api/pages/${page.id}`);
+         const response = await fetchWithAuth(`/api/pages/${page.id}`, { signal: abortController.signal });
          if (response.ok) {
            const updatedPage = await response.json();
            const newContent = typeof updatedPage.content === 'string' ? updatedPage.content : '';
            updateContentFromServer(newContent);
          }
        } catch (error) {
+         if (error instanceof DOMException && error.name === 'AbortError') return;
          console.error('Failed to fetch updated canvas content:', error);
        }
      }
    };

    socket.on('page:content-updated', handleContentUpdate);
    return () => {
      socket.off('page:content-updated', handleContentUpdate);
+     abortController?.abort();
    };
  }, [socket, page.id, updateContentFromServer]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 140 - 173, The socket handler handleContentUpdate must use an
AbortController so stale fetches don't overwrite newly-initialized documents:
inside the useEffect create a controller-per-invocation (e.g., create a new
AbortController at the top of handleContentUpdate or push it into an array
controllers declared in the effect scope), pass controller.signal into
fetchWithAuth, and on cleanup for the effect abort all active controllers and
remove the socket listener via socket.off('page:content-updated',
handleContentUpdate); also make the catch ignore AbortError (or skip calling
updateContentFromServer if the fetch was aborted) to avoid applying stale
content; reference handleContentUpdate, fetchWithAuth, updateContentFromServer,
and the socket.on/off setup when making the change.
🧹 Nitpick comments (2)
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (2)

82-93: updateContentFromServer should also bump saveVersionRef to invalidate any in-flight debounced save.

clearTimeout only cancels a timer that hasn't fired yet. If the debounced callback is already mid-execution (i.e., awaiting saveContent(...)) when a server update arrives, clearTimeout is a no-op for it. That in-flight save will still complete, pass the saveVersionRef.current === version guard, and overwrite lastSaved with a stale timestamp — even though the authoritative content now came from the server.

Bumping the version counter prevents this:

♻️ Proposed fix
  const updateContentFromServer = useCallback((newContent: string) => {
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
      saveTimeoutRef.current = null;
    }
+   // Invalidate any in-flight debounced save so its version guard fails
+   saveVersionRef.current++;
    useDocumentManagerStore.getState().updateDocument(page.id, {
      content: newContent,
      isDirty: false,
      lastSaved: Date.now(),
      lastUpdateTime: Date.now(),
    });
  }, [page.id]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 82 - 93, The updateContentFromServer handler currently clears the
debounce timer but does not increment the in-flight save version, so an
already-running save (awaiting saveContent) can still pass the
saveVersionRef.current === version check and overwrite server-updated state;
inside updateContentFromServer (function name) after clearing saveTimeoutRef
(saveTimeoutRef.current) and before calling updateDocument, increment or bump
saveVersionRef.current (e.g., ++saveVersionRef.current or assign a new token) so
any in-flight save will fail its version guard (saveVersionRef and the
saveContent version check) and be prevented from writing stale
lastSaved/lastUpdateTime.

259-264: Custom memo comparator silently suppresses re-renders for any new TreePage fields added in the future.

The comparator is correct for the current component surface (page.id and page.content are the only page fields consumed). Consider adding a brief comment so future contributors know why only these two fields are compared, to avoid accidentally introducing silent stale-prop bugs if new page fields (e.g., page.title, page.permissions) are later referenced in the component.

♻️ Suggested comment
 export default React.memo(
   CanvasPageView,
+  // Only page.id and page.content drive this component's rendering and effects.
+  // If you add consumption of other page fields, extend this comparator accordingly.
   (prevProps, nextProps) =>
     prevProps.page.id === nextProps.page.id &&
     prevProps.page.content === nextProps.page.content
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 259 - 264, The custom React.memo comparator on CanvasPageView
currently only compares prevProps.page.id and prevProps.page.content which will
silently prevent re-renders if new TreePage fields are later used; update the
memo call by adding a concise comment above the comparator explaining that the
comparator intentionally limits checks to page.id and page.content (and that any
time a new page field like page.title or page.permissions is consumed by
CanvasPageView the comparator must be updated to include it or be removed), and
ensure the comment references CanvasPageView, prevProps/nextProps and the
page.id/page.content checks so future contributors understand why only those
fields are compared.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 140-173: The socket handler handleContentUpdate must use an
AbortController so stale fetches don't overwrite newly-initialized documents:
inside the useEffect create a controller-per-invocation (e.g., create a new
AbortController at the top of handleContentUpdate or push it into an array
controllers declared in the effect scope), pass controller.signal into
fetchWithAuth, and on cleanup for the effect abort all active controllers and
remove the socket listener via socket.off('page:content-updated',
handleContentUpdate); also make the catch ignore AbortError (or skip calling
updateContentFromServer if the fetch was aborted) to avoid applying stale
content; reference handleContentUpdate, fetchWithAuth, updateContentFromServer,
and the socket.on/off setup when making the change.

---

Nitpick comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 82-93: The updateContentFromServer handler currently clears the
debounce timer but does not increment the in-flight save version, so an
already-running save (awaiting saveContent) can still pass the
saveVersionRef.current === version check and overwrite server-updated state;
inside updateContentFromServer (function name) after clearing saveTimeoutRef
(saveTimeoutRef.current) and before calling updateDocument, increment or bump
saveVersionRef.current (e.g., ++saveVersionRef.current or assign a new token) so
any in-flight save will fail its version guard (saveVersionRef and the
saveContent version check) and be prevented from writing stale
lastSaved/lastUpdateTime.
- Around line 259-264: The custom React.memo comparator on CanvasPageView
currently only compares prevProps.page.id and prevProps.page.content which will
silently prevent re-renders if new TreePage fields are later used; update the
memo call by adding a concise comment above the comparator explaining that the
comparator intentionally limits checks to page.id and page.content (and that any
time a new page field like page.title or page.permissions is consumed by
CanvasPageView the comparator must be updated to include it or be removed), and
ensure the comment references CanvasPageView, prevProps/nextProps and the
page.id/page.content checks so future contributors understand why only those
fields are compared.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d556e65 and e1e70a3.

📒 Files selected for processing (1)
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx

2witstudios and others added 4 commits February 25, 2026 18:24
- Migrate CanvasPageView from useDocumentStore to useDocumentManagerStore
  to fix shared saveTimeoutId data loss bug with parallel documents
- Add hasLoadedRef guard to useDevices and usePageAgents SWR isPaused
  to prevent blocking initial data fetch when editing is active
- Move SheetView socket listener deps to refs to stop re-subscribing
  on every keystroke (documentState.content was in useEffect deps)
- Wrap PageTreeItem with React.memo to prevent re-rendering every tree
  node on any parent state change
- Update EditableTitle to sync renamed page title to both tab stores
  (useOpenTabsStore and useTabsStore)
- Add AbortController to permission check fetches in DocumentView and
  SheetView to prevent stale responses overwriting isReadOnly state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address two issues from code review:
- isDirty flag now reset to false after debounced save succeeds
- Dirty content is force-saved on component unmount, matching the
  pattern used in DocumentView and SheetView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rded isDirty, stale content

- saveContent now rethrows errors so debounced save won't clear isDirty on failure
- Add saveVersionRef counter so only the latest save can mark document clean,
  preventing a race where an older in-flight save clears isDirty while newer
  edits are unsaved
- Initialize effect now refreshes cached doc from props when content differs
  and doc isn't dirty, fixing stale content on page revisit
- Unmount cleanup now calls clearDocument after force-save to prevent stale
  cache across remounts
- Update useDevices tests to match hasLoadedRef behavior: isPaused only
  blocks revalidation after initial fetch completes via onSuccess

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…leanup

- Register editing state with useEditingStore when content is dirty,
  preventing SWR revalidation from disrupting canvas edits (per CLAUDE.md
  UI Refresh Protection guidelines)
- Add pageIdRef to avoid stale page.id capture in empty-deps unmount
  cleanup, making the pattern resilient even without key-based remounting
- Clean up useEditingStore session on unmount alongside document cache
- Remove unused eslint-disable directive

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios force-pushed the ppg/frontend-state-fixes branch from e1e70a3 to e6147c6 Compare February 26, 2026 00:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant