Skip to content

Commit a6a1107

Browse files
ralyodioclaude
andcommitted
fix: track all folder siblings as locally modified on bookmark move to preserve order
When a bookmark is moved/reordered, the browser only fires onMoved for the explicitly moved item. But all siblings in the folder have their indices shifted implicitly. Without tracking them, sync would override their new positions with stale cloud indices, effectively undoing the user's reorder. release:patch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8823b41 commit a6a1107

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

apps/extension/__tests__/locally-modified-persistence.test.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,116 @@ describe('categorizeCloudBookmarks with locallyModifiedBookmarkIds', () => {
491491
});
492492
});
493493

494+
describe('Bookmark move: sibling index protection', () => {
495+
it('should protect all siblings from cloud index override when one bookmark is moved', () => {
496+
// Scenario: User has bookmarks A(0), B(1), C(2), D(3) in a folder.
497+
// User drags A to the end: B(0), C(1), D(2), A(3).
498+
// Only A fires onMoved, but B, C, D all shifted indices.
499+
// All siblings must be in locallyModifiedBookmarkIds to prevent cloud from
500+
// reverting B, C, D to their old positions.
501+
502+
// Cloud still has OLD order
503+
const cloudBookmarks = [
504+
{ url: 'https://a.com', title: 'A', folderPath: 'Bookmarks Bar', index: 0 },
505+
{ url: 'https://b.com', title: 'B', folderPath: 'Bookmarks Bar', index: 1 },
506+
{ url: 'https://c.com', title: 'C', folderPath: 'Bookmarks Bar', index: 2 },
507+
{ url: 'https://d.com', title: 'D', folderPath: 'Bookmarks Bar', index: 3 },
508+
];
509+
510+
// Local has NEW order (user moved A to end)
511+
const localBookmarks = [
512+
{ id: 'bm-b', url: 'https://b.com', title: 'B', folderPath: 'Bookmarks Bar', index: 0 },
513+
{ id: 'bm-c', url: 'https://c.com', title: 'C', folderPath: 'Bookmarks Bar', index: 1 },
514+
{ id: 'bm-d', url: 'https://d.com', title: 'D', folderPath: 'Bookmarks Bar', index: 2 },
515+
{ id: 'bm-a', url: 'https://a.com', title: 'A', folderPath: 'Bookmarks Bar', index: 3 },
516+
];
517+
518+
// With the fix: ALL siblings are tracked as locally modified (not just A)
519+
const modifiedLocalIds = new Set(['bm-a', 'bm-b', 'bm-c', 'bm-d']);
520+
521+
const { toAdd, toUpdate, skippedByLocalModification } = categorizeCloudBookmarks(
522+
cloudBookmarks,
523+
localBookmarks,
524+
[],
525+
modifiedLocalIds
526+
);
527+
528+
// Nothing should be added or updated — all are protected
529+
expect(toAdd).toHaveLength(0);
530+
expect(toUpdate).toHaveLength(0);
531+
// All 4 bookmarks should be skipped because they are locally modified
532+
expect(skippedByLocalModification).toHaveLength(4);
533+
});
534+
535+
it('should revert sibling order if only moved bookmark is tracked (demonstrates bug)', () => {
536+
// This test demonstrates what WOULD happen without the sibling tracking fix:
537+
// Only the explicitly moved bookmark is protected, siblings get overridden.
538+
539+
const cloudBookmarks = [
540+
{ url: 'https://a.com', title: 'A', folderPath: 'Bookmarks Bar', index: 0 },
541+
{ url: 'https://b.com', title: 'B', folderPath: 'Bookmarks Bar', index: 1 },
542+
{ url: 'https://c.com', title: 'C', folderPath: 'Bookmarks Bar', index: 2 },
543+
];
544+
545+
const localBookmarks = [
546+
{ id: 'bm-b', url: 'https://b.com', title: 'B', folderPath: 'Bookmarks Bar', index: 0 },
547+
{ id: 'bm-c', url: 'https://c.com', title: 'C', folderPath: 'Bookmarks Bar', index: 1 },
548+
{ id: 'bm-a', url: 'https://a.com', title: 'A', folderPath: 'Bookmarks Bar', index: 2 },
549+
];
550+
551+
// BUG scenario: only the moved bookmark (A) is tracked
552+
const modifiedLocalIds = new Set(['bm-a']);
553+
554+
const { toUpdate, skippedByLocalModification } = categorizeCloudBookmarks(
555+
cloudBookmarks,
556+
localBookmarks,
557+
[],
558+
modifiedLocalIds
559+
);
560+
561+
// A is protected (correct)
562+
expect(skippedByLocalModification).toContain('https://a.com');
563+
564+
// B and C would get overridden with cloud indices (the bug!)
565+
// B: cloud index 1 vs local index 0 → needs update
566+
// C: cloud index 2 vs local index 1 → needs update
567+
expect(toUpdate).toHaveLength(2);
568+
expect(toUpdate[0].cloud.url).toBe('https://b.com');
569+
expect(toUpdate[1].cloud.url).toBe('https://c.com');
570+
});
571+
572+
it('should handle cross-folder move with siblings in both folders tracked', () => {
573+
// User moves bookmark from Folder1 to Folder2
574+
// Both source and destination folder siblings should be protected
575+
576+
const cloudBookmarks = [
577+
{ url: 'https://stay1.com', title: 'Stay1', folderPath: 'Bookmarks Bar/Folder1', index: 0 },
578+
{ url: 'https://moved.com', title: 'Moved', folderPath: 'Bookmarks Bar/Folder1', index: 1 },
579+
{ url: 'https://stay2.com', title: 'Stay2', folderPath: 'Bookmarks Bar/Folder2', index: 0 },
580+
];
581+
582+
const localBookmarks = [
583+
{ id: 'bm-stay1', url: 'https://stay1.com', title: 'Stay1', folderPath: 'Bookmarks Bar/Folder1', index: 0 },
584+
{ id: 'bm-stay2', url: 'https://stay2.com', title: 'Stay2', folderPath: 'Bookmarks Bar/Folder2', index: 0 },
585+
{ id: 'bm-moved', url: 'https://moved.com', title: 'Moved', folderPath: 'Bookmarks Bar/Folder2', index: 1 },
586+
];
587+
588+
// All siblings in both folders are tracked
589+
const modifiedLocalIds = new Set(['bm-stay1', 'bm-stay2', 'bm-moved']);
590+
591+
const { toAdd, toUpdate, skippedByLocalModification } = categorizeCloudBookmarks(
592+
cloudBookmarks,
593+
localBookmarks,
594+
[],
595+
modifiedLocalIds
596+
);
597+
598+
expect(toAdd).toHaveLength(0);
599+
expect(toUpdate).toHaveLength(0);
600+
expect(skippedByLocalModification).toHaveLength(3);
601+
});
602+
});
603+
494604
describe('Sync scenario: service worker restart', () => {
495605
it('should preserve local changes across service worker restarts via persisted IDs', async () => {
496606
// Simulate: user adds/modifies bookmarks → service worker restarts → sync runs

apps/extension/src/background/index.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,11 +1306,35 @@ function setupBookmarkListeners() {
13061306
});
13071307

13081308
// Listen for bookmark moves
1309-
browser.bookmarks.onMoved.addListener((id, moveInfo) => {
1309+
browser.bookmarks.onMoved.addListener(async (id, moveInfo) => {
13101310
console.log('[MarkSyncr] Bookmark moved:', id);
13111311

1312-
// Always track locally modified bookmarks, even during sync
1312+
// Always track the moved bookmark itself
13131313
locallyModifiedBookmarkIds.add(id);
1314+
1315+
// CRITICAL: When a bookmark is moved, all siblings in the affected folder(s)
1316+
// have their indices shifted implicitly by the browser. We must mark them all
1317+
// as locally modified so the sync doesn't override their new positions with
1318+
// stale cloud indices. Without this, reordering a single bookmark causes the
1319+
// cloud's old order to be re-applied to all the un-tracked siblings.
1320+
try {
1321+
const foldersToMark = new Set([moveInfo.parentId]);
1322+
if (moveInfo.oldParentId && moveInfo.oldParentId !== moveInfo.parentId) {
1323+
foldersToMark.add(moveInfo.oldParentId);
1324+
}
1325+
for (const folderId of foldersToMark) {
1326+
const children = await browser.bookmarks.getChildren(folderId);
1327+
for (const child of children) {
1328+
locallyModifiedBookmarkIds.add(child.id);
1329+
}
1330+
}
1331+
console.log(
1332+
`[MarkSyncr] Tracked ${foldersToMark.size} folder(s) with all siblings as locally modified`
1333+
);
1334+
} catch (err) {
1335+
console.warn('[MarkSyncr] Failed to mark siblings as modified:', err.message);
1336+
}
1337+
13141338
debouncedSaveLocallyModifiedIds();
13151339

13161340
// Skip during sync operations to prevent sync loops

0 commit comments

Comments
 (0)