Skip to content

Feat/setup y.js undo manager#21

Merged
Kripu77 merged 10 commits intomainfrom
feat/setup-y.js-undo-manager
Mar 3, 2026
Merged

Feat/setup y.js undo manager#21
Kripu77 merged 10 commits intomainfrom
feat/setup-y.js-undo-manager

Conversation

@Kripu77
Copy link
Owner

@Kripu77 Kripu77 commented Mar 2, 2026

Summary by CodeRabbit

  • New Features

    • Shareable collaboration flow with enable/disable controls and a "Start Collaboration" dialog (copyable room link)
    • Dedicated test demo page for collaborative board editing
  • Bug Fixes

    • Stricter validation and safer handling of board elements to reduce sync errors
    • More reliable undo/redo behavior during collaborative sessions
  • Tests

    • Extensive unit and end-to-end tests covering collaboration, undo/redo, clear sync, and dialog behavior

@Kripu77 Kripu77 marked this pull request as ready for review March 2, 2026 11:06
@vercel
Copy link

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thinkix Ready Ready Preview, Comment Mar 3, 2026 10:03am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

Adds a context-based SyncBus, Yjs UndoManager integration, session-scoped collaboration state, a CollaborationStartDialog, mock Yjs test utilities, validation/logging utilities, z-index constants, new tests (unit/e2e/component), and multiple consumers updated to use the new hooks and providers.

Changes

Cohort / File(s) Summary
SyncBus provider & hooks
packages/collaboration/src/sync-bus.tsx, packages/collaboration/src/index.ts, tests/unit/sync-bus.test.ts
Replaces singleton SyncBus with a React provider pattern: SyncBusProvider, useSyncBus, useOptionalSyncBus, and related tests updated to use provider-based API.
Yjs UndoManager & undo API
packages/collaboration/src/adapter/yjs-provider.tsx, packages/collaboration/src/adapter/collaboration-context.tsx, packages/collaboration/src/adapter/index.ts, packages/collaboration/src/types.ts
Integrates Yjs UndoManager into collaboration context; exposes undoState, undo, redo, and UndoState type; adapter/context signatures updated to surface undo/redo.
Collaboration hooks & session
packages/collaboration/src/hooks/use-collaboration-session.ts, packages/collaboration/src/hooks/use-undo-redo.ts, packages/collaboration/src/hooks/index.ts, app/page.tsx
Adds useCollaborationSession (sessionStorage per-room flags) and useUndoRedo (abstracts undo/redo between collaboration and local modes); app/page wired to use session hook and new collaboration flow.
Collaboration UI component
features/collaboration/components/collaboration-start-dialog.tsx, features/collaboration/index.ts, tests/components/collaboration-start-dialog.test.tsx
New CollaborationStartDialog component (copyable room URL, success/error states) exported and covered by unit tests.
Collaborative board & canvas updates
features/collaboration/components/collaborative-board.tsx, features/collaboration/components/room.tsx, features/board/components/BoardCanvas.tsx
Boards now obtain SyncBus via hooks, generator hash improved (content-based), gating of sync operations behind optional SyncBus and element validation; Room wrapped with SyncBusProvider.
Toolbar & undo UI
features/toolbar/components/AppMenu.tsx, features/toolbar/components/UndoRedoButtons.tsx
AppMenu and Undo/Redo buttons updated to use optional SyncBus and useUndoRedo hook; logging replaced with logger; save/load/clear flows emit local sync updates when available.
Mock & test utils
packages/collaboration/src/adapter/mock-yjs-provider.tsx, packages/collaboration/src/test-utils.ts, packages/collaboration/package.json, tsconfig.json
Adds MockYjsProvider, MockRoom, useMockYjsCollaboration, re-exports in test-utils, package export and tsconfig path alias added for test utilities.
Tests: unit & e2e additions/updates
tests/unit/clear-board-sync.test.ts, tests/unit/yjs-undo-manager.test.ts, tests/e2e/collaboration.test.ts, tests/e2e/collaboration-undo-clear.spec.ts, tests/unit/sync-bus.test.ts
Extensive new/updated unit and e2e tests covering undo/redo, clear-board sync, sync-bus behavior, and collaboration scenarios; tests adjusted to new provider/hooks and mock utilities.
Logging & shared utilities
packages/shared/src/logger.ts, packages/shared/src/index.ts, packages/collaboration/src/logger.ts, packages/collaboration/src/types.ts
Introduces a Logger utility and logger export; adds isValidBoardElement and validateBoardElements runtime validators and UndoState type.
Constants & minor config
shared/constants/styles.ts, vitest.config.mts, tests/e2e/helpers/browser.ts, app/test/collaboration/page.tsx, app/page.tsx
Adds Z_INDEX constants, removes vitest coverage thresholds, adds safeClose helper for e2e cleanup, and a test collaboration page; app/page updated to integrate collaboration session, dialog, and handlers.

Sequence Diagram(s)

sequenceDiagram
  participant User as User (Browser)
  participant App as App/page.tsx
  participant Sync as SyncBusProvider
  participant Yjs as YjsRoom
  participant Board as BoardCanvas

  User->>App: Click "Start Collaboration"
  App->>Sync: create SyncBus via SyncBusProvider
  App->>Yjs: Mount YjsRoom (inside SyncBusProvider)
  Yjs->>Sync: subscribeToLocal/RemoteChanges
  App->>Board: Render BoardCanvas with syncBusContext
  Board->>Sync: emitLocalChange(elements)
  Sync->>Yjs: forward remote/local notifications
  Yjs->>Board: emitRemoteChange -> update elements
  Board->>User: Update canvas
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped from singleton to provider's ring,
I nudged undo stacks so actions can swing,
Mock rooms chatter on soft Broadcast wings,
A dialog shares a link—copy and sing,
Collaboration blooms — carrots for spring! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/setup y.js undo manager' directly addresses the main objective of this PR: integrating Y.js UndoManager across the collaboration system.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/setup-y.js-undo-manager

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

⚠️ Test Results

Metric Coverage
Lines 0%
Functions 93.3%
Branches 78.86%
Statements 91.26%
Average 65.9%

📦 Download Coverage Report

How to view coverage report
  1. Download the coverage artifact from the link above
  2. Extract the ZIP file
  3. Open index.html in your browser

Commit: 4275346938d0f0143f2e896324059ad41651ca1d

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: 13

🧹 Nitpick comments (10)
packages/collaboration/src/adapter/collaboration-context.tsx (1)

50-52: Prefer reusing UndoState type instead of duplicating the shape.

Line 50 duplicates a type that now exists in ../types. Reusing it avoids drift and keeps the API consistent.

Suggested refactor
 import type {
   CollaborationUser,
   Cursor,
   ViewportState,
   ConnectionStatus,
   UserPresence,
   SyncState,
   BoardElement,
+  UndoState,
 } from '../types';
@@
-  undoState: { canUndo: boolean; canRedo: boolean; undoStackSize: number; redoStackSize: number };
+  undoState: UndoState;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/collaboration/src/adapter/collaboration-context.tsx` around lines 50
- 52, The inline undoState property type should be replaced with the shared
UndoState type from ../types to avoid duplication: change the undoState
declaration to use UndoState (instead of { canUndo: boolean; canRedo: boolean;
undoStackSize: number; redoStackSize: number }), add the necessary import for
UndoState, and leave the undo and redo method signatures as-is (undo, redo) so
the CollaborationContext type reuses the centralized type definition.
packages/collaboration/src/sync-bus.tsx (1)

75-78: Memoize context value to prevent unnecessary re-renders.

The value object is recreated on every render of SyncBusProvider, which could cause unnecessary re-renders in consuming components even though syncBus and emitLocalChange are stable.

♻️ Proposed fix using useMemo
+import {
+  createContext,
+  useContext,
+  useCallback,
+  useState,
+  useMemo,
+  type ReactNode,
+} from 'react';

 export function SyncBusProvider({ children }: { children: ReactNode }) {
   const [syncBus] = useState(createSyncBus);

   const emitLocalChange = useCallback((elements: BoardElement[]) => {
     syncBus.emitLocalChange(elements);
   }, [syncBus]);

-  const value = {
-    syncBus,
-    emitLocalChange,
-  };
+  const value = useMemo(() => ({
+    syncBus,
+    emitLocalChange,
+  }), [syncBus, emitLocalChange]);

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

In `@packages/collaboration/src/sync-bus.tsx` around lines 75 - 78, The context
`value` object in SyncBusProvider is recreated every render which can trigger
unnecessary re-renders in consumers; wrap creation of the `value` object in a
memo hook (useMemo) keyed on `syncBus` and `emitLocalChange` so the same
reference is returned when those dependencies haven’t changed, i.e. memoize the
object assigned to `value` in the `SyncBusProvider` component to
reference-stabilize `syncBus` and `emitLocalChange`.
tests/e2e/collaboration-undo-clear.spec.ts (2)

78-85: Extract duplicated safeClose helper to reduce code duplication.

The safeClose function is defined identically in multiple tests. Consider extracting it to a shared utility or defining it once at the describe block level.

♻️ Proposed refactor
+async function safeCloseContexts(...contexts: import('@playwright/test').BrowserContext[]) {
+  for (const context of contexts) {
+    try {
+      await context.close().catch(() => {});
+    } catch {
+      // Ignore cleanup errors
+    }
+  }
+}
+
 test.describe('Collaboration Undo/Redo', () => {
   // ... in tests, replace inline safeClose with:
-    const safeClose = async () => {
-      try {
-        await context1.close().catch(() => {});
-        await context2.close().catch(() => {});
-      } catch {
-        // Ignore cleanup errors
-      }
-    };
+    const safeClose = () => safeCloseContexts(context1, context2);

Also applies to: 160-167

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

In `@tests/e2e/collaboration-undo-clear.spec.ts` around lines 78 - 85, The
safeClose helper is duplicated across tests: consolidate the async function that
closes context1 and context2 (currently named safeClose) into a single shared
definition—either move it to the top of the relevant describe block or into a
test utilities module and import it; update all usages in the e2e
collaboration-undo-clear tests (and the other occurrence) to call the shared
safeClose to remove duplication and keep the try/await .catch behavior intact.

219-219: Fragile menu button selector may match wrong element.

Using .getByRole('button').filter({ has: page.locator('svg') }).first() is fragile and may select an unintended button if the UI changes. Consider using a more specific test ID or accessible name for the menu button.

♻️ Proposed fix using test ID
-      const menuButton = page1.getByRole('button').filter({ has: page1.locator('svg') }).first();
+      const menuButton = page1.getByTestId('app-menu-button');

The AppMenu.tsx already has data-testid="app-menu-button" on line 204, so this selector would be more reliable.

Also applies to: 285-285, 324-324

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

In `@tests/e2e/collaboration-undo-clear.spec.ts` at line 219, Replace the fragile
SVG-based button selector (e.g., the menuButton assignment using
page1.getByRole('button').filter({ has: page1.locator('svg') }).first()) with
the stable test-id selector that AppMenu provides; use
page.getByTestId('app-menu-button') or
page.locator('[data-testid="app-menu-button"]') (and .first() if needed) so the
test targets the intended element reliably, and update the same pattern in the
other occurrences in this spec.
features/toolbar/components/AppMenu.tsx (1)

123-125: Inconsistent error logging: some handlers use console.error while handleOpenFile uses logger.error.

The handleOpenFile function (line 110) was updated to use logger.error, but handleSaveFile, handleExportSvg, handleExportPng, and handleExportJpg still use console.error. For consistency and to benefit from the logger's analytics integration, consider updating all error handlers.

♻️ Proposed fix for consistent logging
   const handleSaveFile = async () => {
     // ...
     } catch (error) {
-      console.error('Failed to save file:', error);
+      logger.error('Failed to save file', error instanceof Error ? error : undefined);
       posthog.captureException(error);
     }
   };

   const handleExportSvg = async () => {
     // ...
     } catch (error) {
-      console.error('Failed to export SVG:', error);
+      logger.error('Failed to export SVG', error instanceof Error ? error : undefined);
       posthog.captureException(error);
     }
   };

   const handleExportPng = async (transparent: boolean) => {
     // ...
     } catch (error) {
-      console.error('Failed to export PNG:', error);
+      logger.error('Failed to export PNG', error instanceof Error ? error : undefined);
       posthog.captureException(error);
     }
   };

   const handleExportJpg = async () => {
     // ...
     } catch (error) {
-      console.error('Failed to export JPG:', error);
+      logger.error('Failed to export JPG', error instanceof Error ? error : undefined);
       posthog.captureException(error);
     }
   };

Also applies to: 137-139, 152-153, 165-167

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

In `@features/toolbar/components/AppMenu.tsx` around lines 123 - 125, Update the
inconsistent error logging by replacing console.error calls with the centralized
logger.error in the error handlers for handleSaveFile, handleExportSvg,
handleExportPng, and handleExportJpg so they match handleOpenFile; ensure each
catch block calls logger.error with a clear message and the caught error object,
and continue to call posthog.captureException(error) after logging for
analytics.
packages/shared/src/logger.ts (2)

1-1: Consider exporting LogLevel type for downstream consumers.

The LogLevel type is used internally but not exported. Downstream code using createLogger may need to reference this type for type-safe log level handling.

♻️ Proposed fix
-type LogLevel = 'error' | 'warn' | 'info' | 'debug';
+export type LogLevel = 'error' | 'warn' | 'info' | 'debug';

Also applies to: 91-91

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

In `@packages/shared/src/logger.ts` at line 1, The LogLevel type is currently
internal but should be exported for downstream type safety; update the
declaration of the LogLevel alias to be exported and ensure any usages (e.g.,
the createLogger function signature) import/consume the exported type so
external modules can reference it (look for the LogLevel type and the
createLogger symbol in this file and export LogLevel alongside existing
exports).

18-24: Cache the false result to avoid repeated environment checks.

When getIsDevelopment() returns false on line 23, the result is not cached. Subsequent calls will re-execute the environment checks unnecessarily.

♻️ Proposed fix
   if (typeof window !== 'undefined') {
     const hostname = window.location.hostname;
     cachedIsDev = hostname === 'localhost' || hostname === '127.0.0.1';
     return cachedIsDev;
   }
-  return false;
+  cachedIsDev = false;
+  return cachedIsDev;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/logger.ts` around lines 18 - 24, getIsDevelopment
currently returns false without updating the cachedIsDev variable, causing
repeated environment checks; update the function (getIsDevelopment) to assign
cachedIsDev = false before the final return so the false result is cached,
ensuring subsequent calls use cachedIsDev and skip re-evaluating window/location
logic (refer to cachedIsDev and getIsDevelopment to locate where to set the
cached value).
tests/components/collaboration-start-dialog.test.tsx (1)

94-109: Please unskip the timeout reset case with fake timers.

The “Copied → Copy” reset is core UX behavior. This can be made deterministic with vi.useFakeTimers() and vi.advanceTimersByTime(2000) instead of skipping.

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

In `@tests/components/collaboration-start-dialog.test.tsx` around lines 94 - 109,
Unskip the test "reverts to \"Copy\" text after timeout" for
CollaborationStartDialog and make it deterministic by enabling fake timers
(vi.useFakeTimers()) before rendering, using fireEvent.click(copyButton),
asserting 'Copied' with waitFor, then advancing timers with
vi.advanceTimersByTime(2000) instead of setTimeout/promise, and finally
asserting 'Copy' with waitFor; restore real timers afterwards with
vi.useRealTimers() to avoid affecting other tests. Use the existing symbols:
CollaborationStartDialog, defaultProps, render, screen, fireEvent, waitFor, and
the vitest timer helpers
vi.useFakeTimers/vi.advanceTimersByTime/vi.useRealTimers.
tests/e2e/collaboration.test.ts (1)

62-69: Add explicit assertions for shared-room join success.

This test currently navigates both pages but does not assert room membership/board readiness, so it can pass without validating the intended behavior.

✅ Suggested assertion block
       await Promise.all([
         page1.waitForLoadState('domcontentloaded'),
         page2.waitForLoadState('domcontentloaded'),
       ]);
+      await expect(page1).toHaveURL(new RegExp(`\\broom=${roomId}\\b`));
+      await expect(page2).toHaveURL(new RegExp(`\\broom=${roomId}\\b`));
+      await Promise.all([waitForTestBoard(page1), waitForTestBoard(page2)]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/collaboration.test.ts` around lines 62 - 69, After both pages
navigate to roomUrl, add explicit assertions to verify each client actually
joined the shared room and the board is ready: for page1 and page2 use
wait-for-selector/assertions (e.g., presence of the board canvas element, a
participant list entry, or a room-id element) and assert the expected text/state
(e.g., that both pages show the same room identifier or a participant count >0
and that the board-ready indicator is visible). Reference the existing test
variables page1, page2, and roomUrl and ensure these checks run after the
waitForLoadState calls so the test fails if join or board initialization did not
occur.
packages/collaboration/src/hooks/use-undo-redo.ts (1)

8-21: Reuse the shared undo-state contract to avoid drift.

This file redefines undo/redo state fields locally. Consider reusing the shared collaboration undo-state type and only extending it with isCollaborationMode.

♻️ Proposed refactor
+import type { UndoState } from '../types';
 
-interface UndoRedoState {
-  canUndo: boolean;
-  canRedo: boolean;
-  undoStackSize: number;
-  redoStackSize: number;
+interface UndoRedoState extends UndoState {
   isCollaborationMode: boolean;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/collaboration/src/hooks/use-undo-redo.ts` around lines 8 - 21, This
file redefines the undo/redo state locally; replace the locally declared
UndoRedoState with the project's shared undo-state type (import the shared
undo-state type) and extend it only to add isCollaborationMode, leaving
UndoRedoActions and UseUndoRedoReturn intact; update the type alias/definition
that currently uses UndoRedoState (and any places using
undoStackSize/canUndo/canRedo/redoStackSize) to reference the imported shared
type plus the extra isCollaborationMode field so the hook (use-undo-redo.ts) no
longer drifts from the central contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/page.tsx`:
- Around line 72-73: The session flags (initiator/disabled) are being written
using useCollaborationSession(roomFromUrl) before router.push actually updates
the URL-derived room key, which can cause flags to be written to the old
session; change the flow to defer writing those flags until the session is keyed
to the desired room — e.g., after calling router.push(newRoom) wait for
roomFromUrl (or the session's current room/id from useCollaborationSession) to
match the target room, then perform the flag writes; apply this same
deferred-write pattern to the other block referenced around lines 106–117 so all
session writes only occur once the session key matches the intended room.

In `@app/test/collaboration/page.tsx`:
- Around line 88-89: The test collaboration page creates a session via
useCollaborationSession(roomFromUrl) but calls markAsInitiator() and
clearDisabled() on a session that may still be bound to the old/null room; defer
applying those writes until the session is for the current room by watching
roomFromUrl and applying the actions only after the session changes to the new
room. Concretely: stop invoking
session.markAsInitiator()/session.clearDisabled() immediately after creating the
session; instead record the intent (e.g., via a ref or state) and in an effect
that depends on the session instance and roomFromUrl, call markAsInitiator() and
clearDisabled() only if the session corresponds to the current roomFromUrl.
Update the code paths around useCollaborationSession, markAsInitiator,
clearDisabled and the component’s effect that performs the navigation so the
writes are performed against the correct session.

In `@features/collaboration/components/collaboration-start-dialog.tsx`:
- Around line 57-65: The rendered collaboration URL (roomUrl) is currently a
truncated span which is not focusable or keyboard/assistive-tech friendly;
update the CollaborationStartDialog UI to render the URL in a focusable,
read-only, selectable control (e.g., an input[type="text"] or textarea) instead
of the <span>, ensure it has a clear accessible name (aria-label or associated
<label>), preserves the same visual styles (classes around the container), and
remains non-editable (readOnly) so users can keyboard-navigate, select, and copy
the URL when clipboard access fails; apply the same change to both places that
render roomUrl.

In `@features/collaboration/components/collaborative-board.tsx`:
- Around line 48-54: The bitwise line "hash = hash & hash" is a no-op and meant
to force a 32-bit integer; locate the hash loop that uses the variables "hash"
and "hashContent" (the function producing the returned base-36 string) and
replace that no-op with a real 32-bit coercion such as "hash |= 0" (or "hash =
hash >>> 0" if you need an unsigned 32-bit value) so the hash stays within
32-bit bounds before returning hash.toString(36).

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx`:
- Around line 209-219: undo/redo call into undoManager mutates the Yjs document
(yelements) but the provider never refreshes the React state (elements), causing
UI desync; after calling undoManager.undo() or undoManager.redo() inside the
undo and redo callbacks, explicitly trigger the same sync path you use elsewhere
to rebuild React state from the Yjs map (e.g., call the function that converts
yelements -> elements or re-run the observer callback), so
updateElementsFromYElements (or whatever function updates elements from
yelements) is invoked immediately after undoManager.undo()/redo() to
setElements(...) and keep UI in sync with Yjs.
- Around line 125-137: The current change detection only compares array length
and element IDs (data.elements vs prevElements) so remote edits that update an
element's properties but keep the same id are ignored; update the comparison
inside that block to detect property changes as well — e.g., for each element
pair (el, prevEl) compare their shallow/deep equality (or a stable serialization
like JSON.stringify) or a specific version/timestamp field instead of only id,
and if any differ treat it as dataIsDifferent, then call
setIsLocalChange(true)/setTimeout(...false) and return data.elements as before
(referencing data.elements, prevElements, setIsLocalChange).

In `@packages/collaboration/src/hooks/use-collaboration-session.ts`:
- Around line 49-55: State for initiatorKey, dialogSeenKey, and disabledKey is
only read once via useState(() => getSessionItem(...)) and therefore doesn't
update when roomId (and thus those keys) change; update the state whenever
roomId or the derived keys change by adding a useEffect that re-reads
getSessionItem for initiatorKey, dialogSeenKey, and disabledKey and calls
setIsInitiator, setWasDialogSeen, and setWasDisabled respectively (keep existing
getSessionItem and key-building logic and ensure effect depends on roomId or the
three key variables).

In `@packages/collaboration/src/types.ts`:
- Around line 148-149: The id validation currently only checks typeof and
length, so whitespace-only ids like "   " pass; update the check in the element
id validation (the block that tests element['id']) to reject strings that are
only whitespace by ensuring trimmed length > 0 or using a regex to test for
non-whitespace (e.g., replace the length check with element['id'].trim().length
> 0 or a non-empty /\S/ test) so whitespace-only ids return false.

In `@tests/components/collaboration-start-dialog.test.tsx`:
- Around line 111-126: The test is spying on console.error but the component
uses logger.error; update the test to spy on the same logger the component uses
(logger.error) so assertions target the actual logging call (replace
vi.spyOn(console, 'error') with vi.spyOn(logger, 'error') and ensure the test
imports the logger from the collaboration component module or the shared logger
module used by CollaborationStartDialog, and call mockRestore() on that spy
afterwards; keep the rest of the test behavior (mockWriteText rejection, render,
click, and expectation for stringContaining('Failed to copy to clipboard'))
unchanged.

In `@tests/e2e/collaboration-undo-clear.spec.ts`:
- Around line 65-117: The test "undo/redo state syncs across two users"
currently only opens two contexts and clicks collaborate buttons then closes;
update it to perform actual drawing and undo/redo actions and assertions: after
clicking collaborateButton1 and collaborateButton2, use page1 to create a
drawable element (e.g., simulate pointer events or call the app's draw API) then
wait for page2 to observe the new element (query the same selector/state on
page2 and assert presence), next trigger undo on page1 (or via UI control) and
assert the element is removed on page2, then trigger redo on page1 and assert
the element is restored on page2; keep using the existing helpers/variables
(page1, page2, collaborateButton1, collaborateButton2, safeClose) and add
appropriate waits/timeouts around network/sync checks to avoid flakiness.

In `@tests/e2e/collaboration.test.ts`:
- Around line 5-9: The helper waitForTestBoard currently swallows selector
timeouts via waitForSelector(...).catch(() => {}), hiding real failures; remove
the empty catch so the timeout error propagates (or replace it with explicit
handling that throws a descriptive error), ensuring waitForTestBoard (the async
function) fails when the board selector isn't found within the 10s timeout so
tests cannot pass silently.

In `@tests/unit/yjs-undo-manager.test.ts`:
- Around line 84-120: Tests currently call mockUndoManager.undo() and .redo()
directly and only validate the mock; instead, set
mockUndoManager.undoStack/redoStack as required and invoke the actual
integration entrypoint that is expected to trigger those calls (the hook or
provider method that your PR added — e.g., the undo/redo handler exposed by the
hook/component), then assert mockUndoManager.undo/redo were called; also ensure
you clear mocks between tests (jest.clearAllMocks()) so the
.not.toHaveBeenCalled() assertions are meaningful. Use the
mockUndoManager.undoStack, mockUndoManager.redoStack, and
mockUndoManager.undo()/redo() symbols to locate and adjust the tests.
- Around line 14-30: The yjs mock currently exports a default constructor but
the code uses namespace imports (import * as Y from 'yjs') and calls new Y.Doc()
in yjs-provider.tsx, so update the vi.mock export to provide a named Doc export
(e.g., replace the default property with Doc) while preserving the UndoManager
export (UndoManager -> mockUndoManager) and the mocked methods (transact,
getMap, destroy, etc.) so new Y.Doc() resolves correctly.

---

Nitpick comments:
In `@features/toolbar/components/AppMenu.tsx`:
- Around line 123-125: Update the inconsistent error logging by replacing
console.error calls with the centralized logger.error in the error handlers for
handleSaveFile, handleExportSvg, handleExportPng, and handleExportJpg so they
match handleOpenFile; ensure each catch block calls logger.error with a clear
message and the caught error object, and continue to call
posthog.captureException(error) after logging for analytics.

In `@packages/collaboration/src/adapter/collaboration-context.tsx`:
- Around line 50-52: The inline undoState property type should be replaced with
the shared UndoState type from ../types to avoid duplication: change the
undoState declaration to use UndoState (instead of { canUndo: boolean; canRedo:
boolean; undoStackSize: number; redoStackSize: number }), add the necessary
import for UndoState, and leave the undo and redo method signatures as-is (undo,
redo) so the CollaborationContext type reuses the centralized type definition.

In `@packages/collaboration/src/hooks/use-undo-redo.ts`:
- Around line 8-21: This file redefines the undo/redo state locally; replace the
locally declared UndoRedoState with the project's shared undo-state type (import
the shared undo-state type) and extend it only to add isCollaborationMode,
leaving UndoRedoActions and UseUndoRedoReturn intact; update the type
alias/definition that currently uses UndoRedoState (and any places using
undoStackSize/canUndo/canRedo/redoStackSize) to reference the imported shared
type plus the extra isCollaborationMode field so the hook (use-undo-redo.ts) no
longer drifts from the central contract.

In `@packages/collaboration/src/sync-bus.tsx`:
- Around line 75-78: The context `value` object in SyncBusProvider is recreated
every render which can trigger unnecessary re-renders in consumers; wrap
creation of the `value` object in a memo hook (useMemo) keyed on `syncBus` and
`emitLocalChange` so the same reference is returned when those dependencies
haven’t changed, i.e. memoize the object assigned to `value` in the
`SyncBusProvider` component to reference-stabilize `syncBus` and
`emitLocalChange`.

In `@packages/shared/src/logger.ts`:
- Line 1: The LogLevel type is currently internal but should be exported for
downstream type safety; update the declaration of the LogLevel alias to be
exported and ensure any usages (e.g., the createLogger function signature)
import/consume the exported type so external modules can reference it (look for
the LogLevel type and the createLogger symbol in this file and export LogLevel
alongside existing exports).
- Around line 18-24: getIsDevelopment currently returns false without updating
the cachedIsDev variable, causing repeated environment checks; update the
function (getIsDevelopment) to assign cachedIsDev = false before the final
return so the false result is cached, ensuring subsequent calls use cachedIsDev
and skip re-evaluating window/location logic (refer to cachedIsDev and
getIsDevelopment to locate where to set the cached value).

In `@tests/components/collaboration-start-dialog.test.tsx`:
- Around line 94-109: Unskip the test "reverts to \"Copy\" text after timeout"
for CollaborationStartDialog and make it deterministic by enabling fake timers
(vi.useFakeTimers()) before rendering, using fireEvent.click(copyButton),
asserting 'Copied' with waitFor, then advancing timers with
vi.advanceTimersByTime(2000) instead of setTimeout/promise, and finally
asserting 'Copy' with waitFor; restore real timers afterwards with
vi.useRealTimers() to avoid affecting other tests. Use the existing symbols:
CollaborationStartDialog, defaultProps, render, screen, fireEvent, waitFor, and
the vitest timer helpers
vi.useFakeTimers/vi.advanceTimersByTime/vi.useRealTimers.

In `@tests/e2e/collaboration-undo-clear.spec.ts`:
- Around line 78-85: The safeClose helper is duplicated across tests:
consolidate the async function that closes context1 and context2 (currently
named safeClose) into a single shared definition—either move it to the top of
the relevant describe block or into a test utilities module and import it;
update all usages in the e2e collaboration-undo-clear tests (and the other
occurrence) to call the shared safeClose to remove duplication and keep the
try/await .catch behavior intact.
- Line 219: Replace the fragile SVG-based button selector (e.g., the menuButton
assignment using page1.getByRole('button').filter({ has: page1.locator('svg')
}).first()) with the stable test-id selector that AppMenu provides; use
page.getByTestId('app-menu-button') or
page.locator('[data-testid="app-menu-button"]') (and .first() if needed) so the
test targets the intended element reliably, and update the same pattern in the
other occurrences in this spec.

In `@tests/e2e/collaboration.test.ts`:
- Around line 62-69: After both pages navigate to roomUrl, add explicit
assertions to verify each client actually joined the shared room and the board
is ready: for page1 and page2 use wait-for-selector/assertions (e.g., presence
of the board canvas element, a participant list entry, or a room-id element) and
assert the expected text/state (e.g., that both pages show the same room
identifier or a participant count >0 and that the board-ready indicator is
visible). Reference the existing test variables page1, page2, and roomUrl and
ensure these checks run after the waitForLoadState calls so the test fails if
join or board initialization did not occur.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e622163 and eec4ce2.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (33)
  • app/page.tsx
  • app/test/collaboration/page.tsx
  • features/board/components/BoardCanvas.tsx
  • features/collaboration/components/collaboration-start-dialog.tsx
  • features/collaboration/components/collaborative-board.tsx
  • features/collaboration/components/room.tsx
  • features/collaboration/index.ts
  • features/toolbar/components/AppMenu.tsx
  • features/toolbar/components/UndoRedoButtons.tsx
  • packages/collaboration/package.json
  • packages/collaboration/src/adapter/collaboration-context.tsx
  • packages/collaboration/src/adapter/index.ts
  • packages/collaboration/src/adapter/mock-yjs-provider.tsx
  • packages/collaboration/src/adapter/yjs-provider.tsx
  • packages/collaboration/src/hooks/index.ts
  • packages/collaboration/src/hooks/use-collaboration-session.ts
  • packages/collaboration/src/hooks/use-undo-redo.ts
  • packages/collaboration/src/index.ts
  • packages/collaboration/src/logger.ts
  • packages/collaboration/src/sync-bus.ts
  • packages/collaboration/src/sync-bus.tsx
  • packages/collaboration/src/test-utils.ts
  • packages/collaboration/src/types.ts
  • packages/shared/src/index.ts
  • packages/shared/src/logger.ts
  • shared/constants/styles.ts
  • tests/components/collaboration-start-dialog.test.tsx
  • tests/e2e/collaboration-undo-clear.spec.ts
  • tests/e2e/collaboration.test.ts
  • tests/unit/clear-board-sync.test.ts
  • tests/unit/sync-bus.test.ts
  • tests/unit/yjs-undo-manager.test.ts
  • tsconfig.json
💤 Files with no reviewable changes (1)
  • packages/collaboration/src/sync-bus.ts

- Add CollaborationRoomContext to MockYjsProvider for e2e tests
- Move markAsInitiator/clearDisabled to useEffects for proper URL sync
- Split useCollaborationRoom to allow mock context override
- Change dialog link from span to input for better selection
- Fix hash calculation (hash |= 0 instead of hash & hash)
- Replace console.error with logger.error
- Update mock YJS undo/redo to sync elements after operation
- Add data-board attribute for e2e test selectors
- Remove coverage thresholds from vitest config
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: 8

Caution

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

⚠️ Outside diff range comments (1)
features/board/components/BoardCanvas.tsx (1)

85-92: ⚠️ Potential issue | 🟠 Major

Validate remote elements before applying to board state.

Remote changes are applied directly to setValue and listRender.update without runtime validation. A malformed payload can corrupt board state or trigger runtime failures.

Suggested fix
-    const unsubscribe = syncBusContext.syncBus.subscribeToRemoteChanges((elements: BoardElement[]) => {
-      onElementsChange(elements);
-      listRender.update(elements, {
+    const unsubscribe = syncBusContext.syncBus.subscribeToRemoteChanges((elements: BoardElement[]) => {
+      const { valid, invalid } = validateBoardElements(elements);
+      if (invalid.length > 0) {
+        logger.warn(`Skipped ${invalid.length} invalid remote board elements`, {
+          invalidCount: invalid.length,
+        });
+      }
+      onElementsChange(valid as PlaitElement[]);
+      listRender.update(valid as PlaitElement[], {
         board: board,
         parent: board,
         parentG: PlaitBoard.getElementHost(board),
       });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/board/components/BoardCanvas.tsx` around lines 85 - 92, Remote
payloads from syncBusContext.syncBus.subscribeToRemoteChanges are applied
immediately via onElementsChange and listRender.update which can corrupt state
if malformed; add runtime validation before applying: implement/plug a validator
(e.g., validateElements(elements)) that checks the payload is an array and each
BoardElement has required fields/types (ids, geometry, type, children or content
shapes) and return false on any failure; if validation fails, log the
error/context and skip calling onElementsChange and listRender.update (or
attempt safe sanitization), otherwise proceed to call onElementsChange(elements)
and listRender.update(elements, { board, parent: board, parentG:
PlaitBoard.getElementHost(board) }).
♻️ Duplicate comments (1)
app/page.tsx (1)

116-121: ⚠️ Potential issue | 🟠 Major

Session flags are scoped to the wrong room lifecycle.

Two logic bugs here:

  1. Line 116-121 marks every URL room as initiator (including users who just joined).
  2. Disabled state is written after room removal (Line 134-138), so it’s keyed to null and not persisted per room.

This can incorrectly show initiator-only UI and lose “disabled” intent for a specific room.

Suggested fix
-import { useEffect, Suspense, useCallback, useMemo } from 'react';
+import { useEffect, Suspense, useCallback, useMemo, useRef } from 'react';
@@
   const session = useCollaborationSession(roomFromUrl);
+  const pendingInitiatorRoomRef = useRef<string | null>(null);
@@
   const handleEnableCollaboration = useCallback(() => {
     const roomId = crypto.randomUUID();
+    pendingInitiatorRoomRef.current = roomId;
@@
     enableCollaboration(roomId);
   }, [pathname, searchParams, router, enableCollaboration]);
@@
-  useEffect(() => {
-    if (roomFromUrl && session.isInitiator === false) {
-      session.markAsInitiator();
-      session.clearDisabled();
-    }
-  }, [roomFromUrl, session]);
+  useEffect(() => {
+    if (!roomFromUrl) return;
+    if (pendingInitiatorRoomRef.current === roomFromUrl) {
+      session.markAsInitiator();
+      session.clearDisabled();
+      pendingInitiatorRoomRef.current = null;
+    }
+  }, [roomFromUrl, session]);
@@
   const handleDisableCollaboration = useCallback(() => {
+    if (roomFromUrl) {
+      session.markAsDisabled();
+      session.clearInitiator();
+    }
     disableCollaboration();
@@
-  }, [disableCollaboration, roomFromUrl, pathname, searchParams, router]);
+  }, [disableCollaboration, roomFromUrl, pathname, searchParams, router, session]);
-
-  useEffect(() => {
-    if (!roomFromUrl && session.wasDisabled === false) {
-      session.markAsDisabled();
-    }
-  }, [roomFromUrl, session]);

Also applies to: 123-138

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

In `@app/page.tsx` around lines 116 - 121, The effect currently marks every URL
room visitor as initiator and clears the disabled flag in the wrong lifecycle;
change the logic in the useEffect(s) so flags are tracked per-room id: only call
session.markAsInitiator() when the current user actually created/owns the room
(e.g., when roomFromUrl.id === session.ownedRoomId or when a creation event sets
a createdByCurrentUser boolean) and ensure session.clearDisabled() and any
writes to session.disabled are performed when entering a specific room
(roomFromUrl != null) and persisted using that room’s id (e.g., key off
roomFromUrl.id) instead of after room removal — update the useEffect watching
[roomFromUrl, session] and the removal handler code (the block around
session.clearDisabled() / disabled writes) to read/write disabled state keyed by
room id so initiator and disabled state are per-room, not global.
🧹 Nitpick comments (3)
tests/e2e/collaboration-undo-clear.spec.ts (1)

94-98: Remove redundant safeClose(...) before test.skip() inside try blocks.

These branches already have finally teardown (Line 153-Line 155 and Line 290-Line 292). Double-closing is unnecessary and adds noise.

♻️ Example cleanup simplification
       if (!button1Visible || !button2Visible) {
-        await safeClose(context1, context2);
         test.skip();
         return;
       }

Apply the same pattern to the other similar branches in this file.

Also applies to: 107-111, 120-124, 214-218, 227-231, 240-244, 253-257, 262-266, 276-279

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

In `@tests/e2e/collaboration-undo-clear.spec.ts` around lines 94 - 98, Remove the
redundant calls to safeClose(context1, context2) that occur immediately before
test.skip() inside try blocks (e.g., the branch beginning with "if
(!button1Visible || !button2Visible) { ... }"); rely on the existing finally
teardown (the try/finally that calls safeClose at the end) to perform cleanup
instead, and apply the same removal to the other listed branches (lines around
107-111, 120-124, 214-218, 227-231, 240-244, 253-257, 262-266, 276-279) so no
double-closing occurs—leave the test.skip() calls in place but delete the
pre-skip safeClose(...) calls.
tests/e2e/helpers/browser.ts (1)

3-10: Generalize safeClose and close contexts concurrently.

This helper is currently fixed to two contexts and performs serial close. A variadic version with Promise.allSettled keeps teardown resilient and reusable.

♻️ Proposed refactor
 export async function safeClose(context1: BrowserContext, context2: BrowserContext): Promise<void> {
-  try {
-    await context1.close().catch(() => {});
-    await context2.close().catch(() => {});
-  } catch {
-    // Ignore cleanup errors
-  }
+  await Promise.allSettled([context1.close(), context2.close()]);
 }

Or make it variadic for broader reuse:

-export async function safeClose(context1: BrowserContext, context2: BrowserContext): Promise<void> {
-  await Promise.allSettled([context1.close(), context2.close()]);
+export async function safeClose(...contexts: BrowserContext[]): Promise<void> {
+  await Promise.allSettled(contexts.map((context) => context.close()));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/helpers/browser.ts` around lines 3 - 10, Replace the fixed
two-argument safeClose with a variadic version that accepts ...contexts:
BrowserContext[] and closes them concurrently using Promise.allSettled; for each
context call context.close() (skip null/undefined), then await
Promise.allSettled to ensure teardown resilience and swallow any rejection
results (no throw) so cleanup never fails; update references to the old
signature to pass an array or spread arguments into safeClose if needed.
tests/e2e/collaboration.test.ts (1)

77-78: Prefer query-param assertions over runtime RegExp construction.

Line 77 and Line 78 can assert room directly from URL params; this avoids regex escaping pitfalls and is clearer.

♻️ Proposed refactor
-      await expect(page1).toHaveURL(new RegExp(`room=${roomId}`));
-      await expect(page2).toHaveURL(new RegExp(`room=${roomId}`));
+      await expect.poll(() => new URL(page1.url()).searchParams.get('room')).toBe(roomId);
+      await expect.poll(() => new URL(page2.url()).searchParams.get('room')).toBe(roomId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/collaboration.test.ts` around lines 77 - 78, The tests currently
use RegExp to check the room query param on page1 and page2; replace those with
direct query-param assertions by retrieving each page's URL (via page1.url() and
page2.url()), parsing its search params (new URL(...).searchParams) and
asserting that searchParams.get('room') equals roomId for both pages (use await
on page.url() calls). This avoids regex escaping issues and makes the assertions
explicit and robust.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@features/collaboration/components/collaborative-board.tsx`:
- Around line 55-58: Replace the non-deterministic timestamp fallback in the
catch block that logs "Error generating elements hash" (currently returning
Date.now().toString(36)) with a deterministic fallback computed from the input
payload (e.g., stable-serialize the elements or board state and compute a stable
hash). Capture the same error in logger.error, then produce the fallback by
serializing the elements (or the same data used by the primary hash) and
returning a stable hash string (for example a hex digest from a crypto hash of
JSON.stringify(elements) or a custom stable checksum) so identical inputs always
yield the same fallback value.

In `@packages/collaboration/src/adapter/collaboration-context.tsx`:
- Around line 58-65: The current useCollaborationRoom calls
useContext(CollaborationRoomContext) and only calls
useCollaborationRoomLiveblocks() when the context is missing, causing
conditional hook execution; refactor so hooks run unconditionally by moving the
fallback into a separate provider/component: create a CollaborationRoomProvider
(or a useCollaborationRoomFallback hook used unconditionally) that always
invokes useCollaborationRoomLiveblocks and then returns either the context value
from CollaborationRoomContext or the liveblocks fallback value, and update
useCollaborationRoom to call both useContext(CollaborationRoomContext) and the
fallback hook (or always read from the provider) so hook invocation order/count
is stable across renders.

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx`:
- Around line 108-109: The MockRoom component currently ignores passed props by
always initializing with createMockRoomId and a fresh Y.Doc; update the
initialization to honor incoming props: initialize roomId with useState(() =>
props.roomId ?? createMockRoomId()) (or the prop name used where MockRoom is
defined) and apply props.initialElements into the Y.Doc after creation (e.g.,
seed the Y.Doc via Y.Array/Y.Map or Y.applyUpdate inside the useState
initializer or a useEffect that runs once) so exported MockRoom actually scopes
to the provided roomId and seeds initialElements; reference the MockRoom
component, the roomId state, the initialElements prop, createMockRoomId and the
ydoc (new Y.Doc()) when making these changes.
- Around line 125-133: The subscribe callback for syncRef.current (inside the
sync handler that calls setElementsState) assumes data.elements is always an
array; validate the incoming BroadcastChannel payload before dereferencing by
checking that data is an object and Array.isArray(data.elements) (or coerce to
an empty array) and bail/ignore or log malformed messages instead of accessing
data.elements directly; update the logic that computes dataIsDifferent (in the
subscribe callback) to operate on a safe array variable so malformed messages do
not throw and break sync handling.
- Around line 235-240: Remove the extraneous connectionStatus property from the
syncState object returned by the useMemo in mock-yjs-provider.tsx because it is
not part of the SyncState interface; update the useMemo initializer for
syncState (the constant named syncState) to only include isConnected, isSyncing,
and lastSyncedAt so the object shape matches the SyncState type.
- Around line 197-210: insertElement, updateElement, and deleteElement capture a
stale elements value in their closures which can drop rapid mutations; fix by
introducing a ref (e.g., latestElementsRef) that is kept in sync with elements
(update the ref inside a useEffect or each setElements call) and then rewrite
those callbacks to read from latestElementsRef.current and call setElements
using the functional updater (or a value derived from the ref) so you can remove
elements from their dependency arrays; update the closure-free callbacks
insertElement, updateElement, and deleteElement to use the ref and functional
setElements to avoid stale-closure races.

In `@tests/e2e/collaboration-undo-clear.spec.ts`:
- Line 365: The test currently swallows failures by appending .catch(() => {})
to page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 });
remove the silent catch so the wait throws on timeout, or replace it with an
explicit assert that fails with a clear message when the dialog does not close
(use page.waitForSelector with state: 'hidden' and let the error propagate, or
catch and rethrow a new Error mentioning the cancel-flow and dialog selector);
update the invocation of page.waitForSelector to either be awaited without
.catch or wrapped to throw a descriptive error so the test fails if the dialog
never closes.

In `@tests/e2e/collaboration.test.ts`:
- Around line 79-82: The finally block currently awaits context1.close() then
context2.close(), which lets the first thrown error skip closing the second and
can mask test failures; replace both direct closes with the resilient helper
(call safeClose(context1) and safeClose(context2)) so each context is attempted
to close independently and errors are handled by safeClose, ensuring the second
close still runs even if the first fails.

---

Outside diff comments:
In `@features/board/components/BoardCanvas.tsx`:
- Around line 85-92: Remote payloads from
syncBusContext.syncBus.subscribeToRemoteChanges are applied immediately via
onElementsChange and listRender.update which can corrupt state if malformed; add
runtime validation before applying: implement/plug a validator (e.g.,
validateElements(elements)) that checks the payload is an array and each
BoardElement has required fields/types (ids, geometry, type, children or content
shapes) and return false on any failure; if validation fails, log the
error/context and skip calling onElementsChange and listRender.update (or
attempt safe sanitization), otherwise proceed to call onElementsChange(elements)
and listRender.update(elements, { board, parent: board, parentG:
PlaitBoard.getElementHost(board) }).

---

Duplicate comments:
In `@app/page.tsx`:
- Around line 116-121: The effect currently marks every URL room visitor as
initiator and clears the disabled flag in the wrong lifecycle; change the logic
in the useEffect(s) so flags are tracked per-room id: only call
session.markAsInitiator() when the current user actually created/owns the room
(e.g., when roomFromUrl.id === session.ownedRoomId or when a creation event sets
a createdByCurrentUser boolean) and ensure session.clearDisabled() and any
writes to session.disabled are performed when entering a specific room
(roomFromUrl != null) and persisted using that room’s id (e.g., key off
roomFromUrl.id) instead of after room removal — update the useEffect watching
[roomFromUrl, session] and the removal handler code (the block around
session.clearDisabled() / disabled writes) to read/write disabled state keyed by
room id so initiator and disabled state are per-room, not global.

---

Nitpick comments:
In `@tests/e2e/collaboration-undo-clear.spec.ts`:
- Around line 94-98: Remove the redundant calls to safeClose(context1, context2)
that occur immediately before test.skip() inside try blocks (e.g., the branch
beginning with "if (!button1Visible || !button2Visible) { ... }"); rely on the
existing finally teardown (the try/finally that calls safeClose at the end) to
perform cleanup instead, and apply the same removal to the other listed branches
(lines around 107-111, 120-124, 214-218, 227-231, 240-244, 253-257, 262-266,
276-279) so no double-closing occurs—leave the test.skip() calls in place but
delete the pre-skip safeClose(...) calls.

In `@tests/e2e/collaboration.test.ts`:
- Around line 77-78: The tests currently use RegExp to check the room query
param on page1 and page2; replace those with direct query-param assertions by
retrieving each page's URL (via page1.url() and page2.url()), parsing its search
params (new URL(...).searchParams) and asserting that searchParams.get('room')
equals roomId for both pages (use await on page.url() calls). This avoids regex
escaping issues and makes the assertions explicit and robust.

In `@tests/e2e/helpers/browser.ts`:
- Around line 3-10: Replace the fixed two-argument safeClose with a variadic
version that accepts ...contexts: BrowserContext[] and closes them concurrently
using Promise.allSettled; for each context call context.close() (skip
null/undefined), then await Promise.allSettled to ensure teardown resilience and
swallow any rejection results (no throw) so cleanup never fails; update
references to the old signature to pass an array or spread arguments into
safeClose if needed.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eec4ce2 and af7d3ef.

📒 Files selected for processing (19)
  • app/page.tsx
  • app/test/collaboration/page.tsx
  • features/board/components/BoardCanvas.tsx
  • features/collaboration/components/collaboration-start-dialog.tsx
  • features/collaboration/components/collaborative-board.tsx
  • features/toolbar/components/AppMenu.tsx
  • packages/collaboration/src/adapter/collaboration-context.tsx
  • packages/collaboration/src/adapter/mock-yjs-provider.tsx
  • packages/collaboration/src/hooks/use-collaboration-session.ts
  • packages/collaboration/src/hooks/use-undo-redo.ts
  • packages/collaboration/src/sync-bus.tsx
  • packages/collaboration/src/types.ts
  • packages/shared/src/logger.ts
  • tests/components/collaboration-start-dialog.test.tsx
  • tests/e2e/collaboration-undo-clear.spec.ts
  • tests/e2e/collaboration.test.ts
  • tests/e2e/helpers/browser.ts
  • tests/unit/yjs-undo-manager.test.ts
  • vitest.config.mts
💤 Files with no reviewable changes (1)
  • vitest.config.mts
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/collaboration/src/hooks/use-undo-redo.ts
  • features/collaboration/components/collaboration-start-dialog.tsx
  • features/toolbar/components/AppMenu.tsx
  • app/test/collaboration/page.tsx
  • packages/shared/src/logger.ts
  • packages/collaboration/src/sync-bus.tsx
  • tests/unit/yjs-undo-manager.test.ts

Comment on lines +55 to 58
} catch (error) {
logger.error('Error generating elements hash', error instanceof Error ? error : undefined);
return Date.now().toString(36);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use a deterministic hash fallback instead of timestamp.

When hashing fails, Date.now() makes the hash change even for unchanged input. That defeats dedupe checks and can trigger unnecessary sync/update churn.

Suggested fix
   } catch (error) {
     logger.error('Error generating elements hash', error instanceof Error ? error : undefined);
-    return Date.now().toString(36);
+    // Deterministic fallback to preserve dedupe behavior
+    return elements.map((el) => `${el.id}:${el.type ?? ''}`).join('|||');
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/collaboration/components/collaborative-board.tsx` around lines 55 -
58, Replace the non-deterministic timestamp fallback in the catch block that
logs "Error generating elements hash" (currently returning
Date.now().toString(36)) with a deterministic fallback computed from the input
payload (e.g., stable-serialize the elements or board state and compute a stable
hash). Capture the same error in logger.error, then produce the fallback by
serializing the elements (or the same data used by the primary hash) and
returning a stable hash string (for example a hex digest from a crypto hash of
JSON.stringify(elements) or a custom stable checksum) so identical inputs always
yield the same fallback value.

Comment on lines 58 to +65
export function useCollaborationRoom(): CollaborationRoomContextValue {
const existingContext = useContext(CollaborationRoomContext);
if (existingContext) {
return existingContext;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
return useCollaborationRoomLiveblocks();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n packages/collaboration/src/adapter/collaboration-context.tsx

Repository: Kripu77/thinkix

Length of output: 6043


🏁 Script executed:

# Verify the hook count discrepancy by analyzing both code paths
rg -n "useContext|useYjsCollaboration|useMyPresence|useOthers|useStatus|useRoom|useEffect|useCallback|useMemo" packages/collaboration/src/adapter/collaboration-context.tsx | head -30

Repository: Kripu77/thinkix

Length of output: 864


Refactor useCollaborationRoom to avoid conditional hook execution.

The function calls useContext unconditionally at line 59, then conditionally returns without invoking any other hooks when a context exists, but invokes five additional hooks in useCollaborationRoomLiveblocks when context is null. This violates the Rules of Hooks: hook count must remain constant across all renders, not vary based on runtime conditions. The eslint-disable suppresses the warning but does not address the underlying stability risk.

Extract the fallback logic into a separate conditional component or context provider to ensure all hooks execute consistently on every render.

🧰 Tools
🪛 Biome (2.4.4)

[error] 64-64: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

(lint/correctness/useHookAtTopLevel)

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

In `@packages/collaboration/src/adapter/collaboration-context.tsx` around lines 58
- 65, The current useCollaborationRoom calls
useContext(CollaborationRoomContext) and only calls
useCollaborationRoomLiveblocks() when the context is missing, causing
conditional hook execution; refactor so hooks run unconditionally by moving the
fallback into a separate provider/component: create a CollaborationRoomProvider
(or a useCollaborationRoomFallback hook used unconditionally) that always
invokes useCollaborationRoomLiveblocks and then returns either the context value
from CollaborationRoomContext or the liveblocks fallback value, and update
useCollaborationRoom to call both useContext(CollaborationRoomContext) and the
fallback hook (or always read from the provider) so hook invocation order/count
is stable across renders.

Comment on lines +108 to +109
const [roomId] = useState(createMockRoomId);
const [ydoc] = useState(() => new Y.Doc());
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

MockRoom API is currently non-functional (roomId/initialElements are ignored).

Line 285-286 expose roomId and initialElements, but Line 289-290 drops both. This makes room scoping/seeding impossible through the exported component and can mislead test usage.

✅ Minimal fix if props are intentionally unsupported
 interface MockRoomProps {
   children: ReactNode;
-  roomId: string;
-  initialElements?: BoardElement[];
 }

 export function MockRoom({ children }: MockRoomProps) {
   return <>{children}</>;
 }

Also applies to: 283-290

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

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx` around lines 108 -
109, The MockRoom component currently ignores passed props by always
initializing with createMockRoomId and a fresh Y.Doc; update the initialization
to honor incoming props: initialize roomId with useState(() => props.roomId ??
createMockRoomId()) (or the prop name used where MockRoom is defined) and apply
props.initialElements into the Y.Doc after creation (e.g., seed the Y.Doc via
Y.Array/Y.Map or Y.applyUpdate inside the useState initializer or a useEffect
that runs once) so exported MockRoom actually scopes to the provided roomId and
seeds initialElements; reference the MockRoom component, the roomId state, the
initialElements prop, createMockRoomId and the ydoc (new Y.Doc()) when making
these changes.

Comment on lines +125 to +133
const unsubscribe = syncRef.current.subscribe((data) => {
setElementsState(prevElements => {
const dataIsDifferent = data.elements.length !== prevElements.length ||
data.elements.some((el, i) => {
const prevEl = prevElements[i];
if (!prevEl || el.id !== prevEl.id) return true;
return JSON.stringify(el) !== JSON.stringify(prevEl);
});

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate incoming BroadcastChannel payload before dereferencing.

Line 127 assumes data.elements is always a valid array. A malformed message will throw and break sync handling.

✅ Proposed hardening
     const unsubscribe = syncRef.current.subscribe((data) => {
+      if (!data || !Array.isArray(data.elements)) return;
       setElementsState(prevElements => {
         const dataIsDifferent = data.elements.length !== prevElements.length ||
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx` around lines 125 -
133, The subscribe callback for syncRef.current (inside the sync handler that
calls setElementsState) assumes data.elements is always an array; validate the
incoming BroadcastChannel payload before dereferencing by checking that data is
an object and Array.isArray(data.elements) (or coerce to an empty array) and
bail/ignore or log malformed messages instead of accessing data.elements
directly; update the logic that computes dataIsDifferent (in the subscribe
callback) to operate on a safe array variable so malformed messages do not throw
and break sync handling.

Comment on lines +197 to +210
const insertElement = useCallback((element: BoardElement) => {
setElements([...elements, element]);
}, [elements, setElements]);

const updateElement = useCallback((id: string, changes: Record<string, unknown>) => {
const newElements = elements.map((el) =>
el.id === id ? { ...el, ...changes } : el
);
setElements(newElements);
}, [elements, setElements]);

const deleteElement = useCallback((id: string) => {
setElements(elements.filter((el) => el.id !== id));
}, [elements, setElements]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

head -50 packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 1312


🏁 Script executed:

wc -l packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 119


🏁 Script executed:

sed -n '190,220p' packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 999


🏁 Script executed:

sed -n '100,170p' packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 2194


🏁 Script executed:

sed -n '170,200p' packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 879


🏁 Script executed:

sed -n '177,195p' packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 510


🏁 Script executed:

grep -n "insertElement\|updateElement\|deleteElement" packages/collaboration/src/adapter/mock-yjs-provider.tsx | head -20

Repository: Kripu77/thinkix

Length of output: 923


Fix stale closure risk in element mutation callbacks.

insertElement, updateElement, and deleteElement capture elements state in their closure, but setElements has async operations (broadcast, setTimeout) that delay state updates. Two rapid calls can both operate on stale elements, dropping mutations.

Use a ref to sync with the latest elements state and remove it from the dependency arrays:

Proposed fix
   const [elements, setElementsState] = useState<BoardElement[]>([]);
+  const elementsRef = useRef<BoardElement[]>([]);
+
+  useEffect(() => {
+    elementsRef.current = elements;
+  }, [elements]);

   const insertElement = useCallback((element: BoardElement) => {
-    setElements([...elements, element]);
-  }, [elements, setElements]);
+    setElements([...elementsRef.current, element]);
+  }, [setElements]);

   const updateElement = useCallback((id: string, changes: Record<string, unknown>) => {
-    const newElements = elements.map((el) =>
+    const newElements = elementsRef.current.map((el) =>
       el.id === id ? { ...el, ...changes } : el
     );
     setElements(newElements);
-  }, [elements, setElements]);
+  }, [setElements]);

   const deleteElement = useCallback((id: string) => {
-     setElements(elements.filter((el) => el.id !== id));
-   }, [elements, setElements]);
+     setElements(elementsRef.current.filter((el) => el.id !== id));
+   }, [setElements]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const insertElement = useCallback((element: BoardElement) => {
setElements([...elements, element]);
}, [elements, setElements]);
const updateElement = useCallback((id: string, changes: Record<string, unknown>) => {
const newElements = elements.map((el) =>
el.id === id ? { ...el, ...changes } : el
);
setElements(newElements);
}, [elements, setElements]);
const deleteElement = useCallback((id: string) => {
setElements(elements.filter((el) => el.id !== id));
}, [elements, setElements]);
const elementsRef = useRef<BoardElement[]>([]);
useEffect(() => {
elementsRef.current = elements;
}, [elements]);
const insertElement = useCallback((element: BoardElement) => {
setElements([...elementsRef.current, element]);
}, [setElements]);
const updateElement = useCallback((id: string, changes: Record<string, unknown>) => {
const newElements = elementsRef.current.map((el) =>
el.id === id ? { ...el, ...changes } : el
);
setElements(newElements);
}, [setElements]);
const deleteElement = useCallback((id: string) => {
setElements(elementsRef.current.filter((el) => el.id !== id));
}, [setElements]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx` around lines 197 -
210, insertElement, updateElement, and deleteElement capture a stale elements
value in their closures which can drop rapid mutations; fix by introducing a ref
(e.g., latestElementsRef) that is kept in sync with elements (update the ref
inside a useEffect or each setElements call) and then rewrite those callbacks to
read from latestElementsRef.current and call setElements using the functional
updater (or a value derived from the ref) so you can remove elements from their
dependency arrays; update the closure-free callbacks insertElement,
updateElement, and deleteElement to use the ref and functional setElements to
avoid stale-closure races.

Comment on lines +235 to +240
const syncState: SyncState = useMemo(() => ({
isConnected: true,
isSyncing: false,
connectionStatus: 'connected' as ConnectionStatus,
lastSyncedAt: 0,
}), []);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== SyncState definition ==="
rg -n -A6 -B2 'export interface SyncState' packages/collaboration/src/types.ts

echo
echo "=== syncState literal in mock provider ==="
rg -n -A8 -B2 'const syncState: SyncState' packages/collaboration/src/adapter/mock-yjs-provider.tsx

echo
echo "=== Any connectionStatus usage in syncState block ==="
rg -n -A3 -B3 'connectionStatus' packages/collaboration/src/adapter/mock-yjs-provider.tsx

Repository: Kripu77/thinkix

Length of output: 1099


Remove connectionStatus field from syncState object literal—not part of SyncState interface.

Line 238 includes connectionStatus: 'connected' as ConnectionStatus, in the syncState object, but the SyncState interface (defined at packages/collaboration/src/types.ts:174) only declares isConnected, isSyncing, and lastSyncedAt. This will fail TypeScript's structural type checking.

Fix
  const syncState: SyncState = useMemo(() => ({
    isConnected: true,
    isSyncing: false,
-   connectionStatus: 'connected' as ConnectionStatus,
    lastSyncedAt: 0,
  }), []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const syncState: SyncState = useMemo(() => ({
isConnected: true,
isSyncing: false,
connectionStatus: 'connected' as ConnectionStatus,
lastSyncedAt: 0,
}), []);
const syncState: SyncState = useMemo(() => ({
isConnected: true,
isSyncing: false,
lastSyncedAt: 0,
}), []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/collaboration/src/adapter/mock-yjs-provider.tsx` around lines 235 -
240, Remove the extraneous connectionStatus property from the syncState object
returned by the useMemo in mock-yjs-provider.tsx because it is not part of the
SyncState interface; update the useMemo initializer for syncState (the constant
named syncState) to only include isConnected, isSyncing, and lastSyncedAt so the
object shape matches the SyncState type.

const cancelButton = page.getByRole('button', { name: /cancel/i });
if (await cancelButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await cancelButton.click();
await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and read the context around line 365
wc -l tests/e2e/collaboration-undo-clear.spec.ts

Repository: Kripu77/thinkix

Length of output: 105


🏁 Script executed:

# Read the area around line 365 with context
sed -n '355,375p' tests/e2e/collaboration-undo-clear.spec.ts | cat -n

Repository: Kripu77/thinkix

Length of output: 999


🏁 Script executed:

# Check the beginning of the file to identify the test framework
head -30 tests/e2e/collaboration-undo-clear.spec.ts | cat -n

Repository: Kripu77/thinkix

Length of output: 1480


🏁 Script executed:

# Let's check if there are other similar patterns of swallowing errors in this file
rg 'catch\(\(\)\s*=>\s*\{\}' tests/e2e/collaboration-undo-clear.spec.ts -A 1 -B 1

Repository: Kripu77/thinkix

Length of output: 215


🏁 Script executed:

# Check the broader context of line 365 (which is line 11 in the snippet) to understand the test flow
sed -n '350,375p' tests/e2e/collaboration-undo-clear.spec.ts | cat -n

Repository: Kripu77/thinkix

Length of output: 1216


🏁 Script executed:

# Verify Playwright API support for the proposed fix
web_search
Playwright expect toBeHidden API documentation

Repository: Kripu77/thinkix

Length of output: 158


🌐 Web query:

Playwright expect toBeHidden API

💡 Result:

expect(locator).toBeHidden() (Playwright Test)

What it asserts: the Locator either

  1. matches no DOM element, or
  2. matches an element that is not visible. [1]

Signature:

await expect(locator).toBeHidden(options?);

Options:

  • timeout?: number — how long Playwright retries the assertion before failing (defaults to expect timeout from config). [1]

Example:

import { test, expect } from '@playwright/test';

test('modal closes', async ({ page }) => {
  const modal = page.getByRole('dialog');
  await page.getByRole('button', { name: 'Close' }).click();
  await expect(modal).toBeHidden({ timeout: 10_000 });
});

Timeout defaults: Playwright’s default expect timeout is 5,000ms, configurable globally via expect: { timeout } in playwright.config. [2]

Source: Playwright LocatorAssertions.toBeHidden API reference. [1]

References


Don't swallow dialog-close failures in cancel-flow validation.

Line 365 ignores timeout errors with .catch(() => {}), allowing the test to pass even if the dialog never closes. This masks potential regressions in the cancel flow.

🛠️ Proposed fix
-          await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
+          await expect(page.getByRole('dialog')).toBeHidden({ timeout: 5000 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/collaboration-undo-clear.spec.ts` at line 365, The test currently
swallows failures by appending .catch(() => {}) to
page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 });
remove the silent catch so the wait throws on timeout, or replace it with an
explicit assert that fails with a clear message when the dialog does not close
(use page.waitForSelector with state: 'hidden' and let the error propagate, or
catch and rethrow a new Error mentioning the cancel-flow and dialog selector);
update the invocation of page.waitForSelector to either be awaited without
.catch or wrapped to throw a descriptive error so the test fails if the dialog
never closes.

Comment on lines +79 to +82
} finally {
await context1.close();
await context2.close();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "collaboration.test.ts" --exec head -20 {}

Repository: Kripu77/thinkix

Length of output: 1426


🏁 Script executed:

fd -t f "browser.ts" --path "*/helpers/*" --exec head -30 {}

Repository: Kripu77/thinkix

Length of output: 292


🏁 Script executed:

fd -t f "collaboration.test.ts" -x wc -l {}

Repository: Kripu77/thinkix

Length of output: 149


🏁 Script executed:

sed -n '75,85p' tests/e2e/collaboration.test.ts

Repository: Kripu77/thinkix

Length of output: 420


🏁 Script executed:

fd -t f "browser.ts" tests/

Repository: Kripu77/thinkix

Length of output: 87


🏁 Script executed:

sed -n '1,15p' tests/e2e/collaboration.test.ts

Repository: Kripu77/thinkix

Length of output: 536


🏁 Script executed:

cat -n tests/e2e/helpers/browser.ts

Repository: Kripu77/thinkix

Length of output: 429


🏁 Script executed:

rg "safeClose" tests/

Repository: Kripu77/thinkix

Length of output: 1242


Use resilient teardown in finally to avoid masking root failures.

On lines 80–81, if context1.close() throws, context2.close() is skipped and the teardown error can override the actual test failure. Use the existing safeClose helper which properly handles errors from both contexts.

🛠️ Proposed fix
 import { test, expect } from '@playwright/test';
+import { safeClose } from './helpers/browser';
@@
     } finally {
-      await context1.close();
-      await context2.close();
+      await safeClose(context1, context2);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} finally {
await context1.close();
await context2.close();
}
import { test, expect } from '@playwright/test';
import { safeClose } from './helpers/browser';
} finally {
await safeClose(context1, context2);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/collaboration.test.ts` around lines 79 - 82, The finally block
currently awaits context1.close() then context2.close(), which lets the first
thrown error skip closing the second and can mask test failures; replace both
direct closes with the resilient helper (call safeClose(context1) and
safeClose(context2)) so each context is attempted to close independently and
errors are handled by safeClose, ensuring the second close still runs even if
the first fails.

@Kripu77 Kripu77 merged commit d5e5adc into main Mar 3, 2026
9 checks passed
@Kripu77 Kripu77 deleted the feat/setup-y.js-undo-manager branch March 3, 2026 10:11
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