Skip to content

Commit e7b4cee

Browse files
PreshyCopilot
andauthored
fix: preserve locally modified bookmark titles, folders, and ordering during sync (#9)
* fix: preserve locally modified bookmark titles and folders during sync Previously, locallyModifiedBookmarkIds protection only skipped index-only changes from cloud. Title and folder changes were allowed through, which caused local edits (e.g., renaming a bookmark label) to be overwritten by stale cloud data during the pull phase. By the time the push phase ran, the local change had already been reverted, so nothing got pushed. Now ALL cloud-driven updates are skipped for locally modified bookmarks, allowing local title, folder, and index changes to survive the pull phase and get pushed to cloud correctly. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: skip ALL cloud updates for locally modified bookmarks (not just index-only) Update categorizeCloudBookmarks, updateLocalBookmarksFromCloud, and tests to ensure local wins for title, folder, AND index changes when a bookmark is in locallyModifiedBookmarkIds. Fixes the bug where renaming a bookmark label locally (e.g. 'incident' -> 's') gets reverted on next sync. Also reverts Copilot autofix commits that partially restored the old broken behavior. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 158fc06 commit e7b4cee

2 files changed

Lines changed: 69 additions & 105 deletions

File tree

apps/extension/__tests__/local-reorder-preserved.test.js

Lines changed: 39 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
* the sync pulls the cloud's old order and applies it locally, reverting the
66
* user's changes. The cloud's stale indices overwrite local modifications.
77
*
8-
* Fix: Skip cloud-to-local index updates and reordering for bookmarks that are
9-
* in the locallyModifiedBookmarkIds set. The local order takes priority and
10-
* gets pushed to cloud instead.
8+
* Fix: Skip ALL cloud-to-local updates (title, folder, index) for bookmarks
9+
* that are in the locallyModifiedBookmarkIds set. The local state takes
10+
* priority and gets pushed to cloud instead.
1111
*
1212
* Three fix points tested:
13-
* 1. categorizeCloudBookmarks() - skips index-only updates for locally modified bookmarks
14-
* 2. updateLocalBookmarksFromCloud() - skips index-only moves for locally modified bookmarks
13+
* 1. categorizeCloudBookmarks() - skips ALL cloud updates for locally modified bookmarks
14+
* 2. updateLocalBookmarksFromCloud() - skips ALL cloud updates for locally modified bookmarks
1515
* 3. reorderLocalToMatchCloud() - skips reorder for folders with locally modified children
1616
*/
1717

@@ -46,7 +46,8 @@ function bookmarkNeedsUpdate(cloudBm, localBm) {
4646
}
4747

4848
/**
49-
* categorizeCloudBookmarks WITH the fix: skip index-only updates for locally modified bookmarks
49+
* categorizeCloudBookmarks WITH the fix: skip ALL cloud updates for locally modified bookmarks.
50+
* Local state takes priority and will be pushed to cloud in the push phase.
5051
*/
5152
function categorizeCloudBookmarks(
5253
cloudBookmarks,
@@ -80,17 +81,11 @@ function categorizeCloudBookmarks(
8081
if (!localBm) {
8182
toAdd.push(cloudBm);
8283
} else if (bookmarkNeedsUpdate(cloudBm, localBm)) {
83-
// FIX: Skip index-only updates for locally modified bookmarks
84+
// FIX: Skip ALL cloud updates for locally modified bookmarks.
85+
// Local wins for title, folder, AND index changes.
8486
if (locallyModifiedBookmarkIds.has(localBm.id)) {
85-
const cloudFolder = normalizeFolderPath(cloudBm.folderPath);
86-
const localFolder = normalizeFolderPath(localBm.folderPath);
87-
const titleChanged = (cloudBm.title ?? '') !== (localBm.title ?? '');
88-
const folderChanged = cloudFolder !== localFolder;
89-
90-
if (!titleChanged && !folderChanged) {
91-
skippedByLocalModification.push(cloudBm.url);
92-
continue;
93-
}
87+
skippedByLocalModification.push(cloudBm.url);
88+
continue;
9489
}
9590
toUpdate.push({ cloud: cloudBm, local: localBm });
9691
} else {
@@ -178,7 +173,7 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
178173
expect(result.skippedByLocalModification.length).toBe(5);
179174
});
180175

181-
it('should still allow title changes from cloud even for locally modified bookmarks', () => {
176+
it('should skip cloud title changes for locally modified bookmarks (local wins)', () => {
182177
const cloudBookmarks = [
183178
{
184179
url: 'https://github.com',
@@ -207,12 +202,13 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
207202
locallyModifiedBookmarkIds
208203
);
209204

210-
// Title changed in cloud - should still be updated
211-
expect(result.toUpdate.length).toBe(1);
212-
expect(result.skippedByLocalModification.length).toBe(0);
205+
// Locally modified bookmark — local wins, cloud title change is skipped.
206+
// The local state will be pushed to cloud in the push phase.
207+
expect(result.toUpdate.length).toBe(0);
208+
expect(result.skippedByLocalModification.length).toBe(1);
213209
});
214210

215-
it('should still allow folder changes from cloud even for locally modified bookmarks', () => {
211+
it('should skip cloud folder changes for locally modified bookmarks (local wins)', () => {
216212
const cloudBookmarks = [
217213
{ url: 'https://github.com', title: 'GitHub', folderPath: 'Bookmarks Bar/Work', index: 0 },
218214
];
@@ -236,9 +232,10 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
236232
locallyModifiedBookmarkIds
237233
);
238234

239-
// Folder changed in cloud - should still be updated
240-
expect(result.toUpdate.length).toBe(1);
241-
expect(result.skippedByLocalModification.length).toBe(0);
235+
// Locally modified bookmark — local wins, cloud folder change is skipped.
236+
// The local state will be pushed to cloud in the push phase.
237+
expect(result.toUpdate.length).toBe(0);
238+
expect(result.skippedByLocalModification.length).toBe(1);
242239
});
243240

244241
it('should update bookmarks normally when they are NOT locally modified', () => {
@@ -303,17 +300,17 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
303300
locallyModifiedBookmarkIds
304301
);
305302

306-
// bm-1: locally modified, index-only change -> skip
307-
// bm-2: locally modified, index-only change -> skip
308-
// bm-3: NOT locally modified, title changed -> update
303+
// bm-1: locally modified -> skip (local wins)
304+
// bm-2: locally modified -> skip (local wins)
305+
// bm-3: NOT locally modified, title changed -> update from cloud
309306
expect(result.skippedByLocalModification.length).toBe(2);
310307
expect(result.toUpdate.length).toBe(1);
311308
expect(result.toUpdate[0].cloud.url).toBe('https://c.com');
312309
});
313310
});
314311

315312
describe('updateLocalBookmarksFromCloud - skip index-only moves for locally modified', () => {
316-
it('should skip index-only moves for locally modified bookmarks', () => {
313+
it('should skip ALL cloud updates for locally modified bookmarks', () => {
317314
const locallyModifiedBookmarkIds = new Set(['bm-1', 'bm-2', 'bm-3']);
318315

319316
const bookmarksToUpdate = [
@@ -344,32 +341,21 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
344341
const updated = [];
345342

346343
for (const { cloud, local } of bookmarksToUpdate) {
347-
const titleChanged = (cloud.title ?? '') !== (local.title ?? '');
348-
const folderChanged =
349-
normalizeFolderPath(cloud.folderPath) !== normalizeFolderPath(local.folderPath);
350-
const indexChanged =
351-
cloud.index !== undefined && local.index !== undefined && cloud.index !== local.index;
352-
353-
// FIX: skip index-only moves for locally modified bookmarks
354-
if (
355-
locallyModifiedBookmarkIds.has(local.id) &&
356-
!titleChanged &&
357-
!folderChanged &&
358-
indexChanged
359-
) {
344+
// FIX: skip ALL cloud updates for locally modified bookmarks
345+
if (locallyModifiedBookmarkIds.has(local.id)) {
360346
skipped.push(cloud.url);
361347
continue;
362348
}
363349

364350
updated.push(cloud.url);
365351
}
366352

367-
// Both bookmarks have index-only changes and are locally modified
353+
// Both bookmarks are locally modified — local wins
368354
expect(skipped).toEqual(['https://a.com', 'https://b.com']);
369355
expect(updated).toEqual([]);
370356
});
371357

372-
it('should still update title even for locally modified bookmarks', () => {
358+
it('should skip ALL cloud updates for locally modified bookmarks (including title)', () => {
373359
const locallyModifiedBookmarkIds = new Set(['bm-1']);
374360

375361
const bookmarksToUpdate = [
@@ -394,28 +380,18 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
394380
const updated = [];
395381

396382
for (const { cloud, local } of bookmarksToUpdate) {
397-
const titleChanged = (cloud.title ?? '') !== (local.title ?? '');
398-
const folderChanged =
399-
normalizeFolderPath(cloud.folderPath) !== normalizeFolderPath(local.folderPath);
400-
const indexChanged =
401-
cloud.index !== undefined && local.index !== undefined && cloud.index !== local.index;
402-
403-
if (
404-
locallyModifiedBookmarkIds.has(local.id) &&
405-
!titleChanged &&
406-
!folderChanged &&
407-
indexChanged
408-
) {
383+
// FIX: skip ALL cloud updates for locally modified bookmarks
384+
if (locallyModifiedBookmarkIds.has(local.id)) {
409385
skipped.push(cloud.url);
410386
continue;
411387
}
412388

413389
updated.push(cloud.url);
414390
}
415391

416-
// Title changed - should NOT be skipped
417-
expect(skipped).toEqual([]);
418-
expect(updated).toEqual(['https://a.com']);
392+
// Locally modified — local wins, cloud title change is skipped
393+
expect(skipped).toEqual(['https://a.com']);
394+
expect(updated).toEqual([]);
419395
});
420396
});
421397

@@ -697,8 +673,8 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
697673
expect(result.skippedByLocalModification.length).toBe(1);
698674
});
699675

700-
it('should detect actual folder changes even after normalization', () => {
701-
// Cloud moved bookmark to a subfolder - this is a real change
676+
it('should skip cloud folder changes for locally modified bookmarks even after normalization', () => {
677+
// Cloud moved bookmark to a subfolder - but bookmark is locally modified, so local wins
702678
const cloudBookmarks = [
703679
{ url: 'https://a.com', title: 'A', folderPath: 'Bookmarks Bar/Work', index: 0 },
704680
];
@@ -716,9 +692,9 @@ describe('Bug Fix: Bookmark reorder should NOT reset on Sync Now', () => {
716692
locallyModifiedBookmarkIds
717693
);
718694

719-
// Folder changed (toolbar/ vs toolbar/Work) - should be updated
720-
expect(result.toUpdate.length).toBe(1);
721-
expect(result.skippedByLocalModification.length).toBe(0);
695+
// Locally modified — local wins, cloud folder change is skipped
696+
expect(result.toUpdate.length).toBe(0);
697+
expect(result.skippedByLocalModification.length).toBe(1);
722698
});
723699
});
724700
});

apps/extension/src/background/index.js

Lines changed: 30 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -492,26 +492,14 @@ function categorizeCloudBookmarks(cloudBookmarks, localBookmarks, tombstones) {
492492
if (!localBm) {
493493
toAdd.push(cloudBm);
494494
} else if (bookmarkNeedsUpdate(cloudBm, localBm)) {
495-
// If this bookmark was locally modified (e.g., user reordered it), skip
496-
// cloud-driven index/position updates. The local state takes priority and
497-
// will be pushed to cloud. This prevents the "reorder then Sync Now resets
498-
// order" bug where cloud's stale indices overwrite local changes.
499-
// We still allow title and folder changes from cloud (meaningful edits),
500-
// but skip index-only changes for locally modified bookmarks.
495+
// If this bookmark was locally modified (e.g., user renamed or reordered
496+
// it), skip ALL cloud-driven updates. The local state takes priority and
497+
// will be pushed to cloud in the push phase. Previously we only skipped
498+
// index-only changes, which caused local title/folder edits to be
499+
// overwritten by stale cloud data.
501500
if (locallyModifiedBookmarkIds.has(localBm.id)) {
502-
const cloudFolder = normalizeFolderPath(cloudBm.folderPath);
503-
const localFolder = normalizeFolderPath(localBm.folderPath);
504-
const titleChanged = (cloudBm.title ?? '') !== (localBm.title ?? '');
505-
const folderChanged = cloudFolder !== localFolder;
506-
507-
if (!titleChanged && !folderChanged) {
508-
// Only the index differs — this is a reorder conflict.
509-
// Local wins because the user just rearranged these bookmarks.
510-
skippedByLocalModification.push(cloudBm.url);
511-
continue;
512-
}
513-
// Title or folder changed in cloud — allow the update even for locally
514-
// modified bookmarks (meaningful edit from another browser).
501+
skippedByLocalModification.push(cloudBm.url);
502+
continue;
515503
}
516504
toUpdate.push({ cloud: cloudBm, local: localBm });
517505
} else {
@@ -2347,12 +2335,11 @@ async function addCloudBookmarksToLocal(cloudItems) {
23472335

23482336
if (item.type === 'folder') {
23492337
// It's a folder entry - create the folder WITHOUT specifying index.
2350-
// Index-based positioning is handled by reorderLocalToMatchCloud after
2351-
// all items are created. Setting index here causes cascading shifts:
2352-
// each insertion at index N shifts all items at N+ to the right, so
2353-
// subsequent inserts land at the wrong position. When the cloud index
2354-
// exceeds the local folder's item count, the browser clamps it to the
2355-
// end — the main cause of "bookmarks end up at the END of toolbar."
2338+
// Contract: creation only establishes parent/identity; index/ordering
2339+
// are applied later by reorderLocalToMatchCloud once all items exist.
2340+
// Setting index during creation can cause cascading shifts (each insert
2341+
// at index N pushes items at N+ to the right) and lead to incorrect
2342+
// positions when the cloud index exceeds the current local item count.
23562343
const children = await browser.bookmarks.getChildren(targetFolderId);
23572344
const existingFolder = children.find((c) => !c.url && c.title === item.title);
23582345

@@ -2513,14 +2500,19 @@ async function reorderLocalToMatchCloud(cloudBookmarks, userModifiedIds = null)
25132500

25142501
if (children.length < 2) continue;
25152502

2516-
// Skip reordering only when the user manually rearranged bookmarks
2517-
// before this sync started. We use `userModifiedIds` — a snapshot of
2518-
// locallyModifiedBookmarkIds taken at the START of performSync, before
2519-
// any sync-driven creates/moves/deletes. This ensures that sync-driven
2520-
// changes (adds, folder moves, tombstone deletions) don't accidentally
2503+
// Skip auto-reordering when a folder appears to have multiple
2504+
// *locally modified* children before this sync started. This is a
2505+
// conservative heuristic that treats "more than one modified child"
2506+
// as a signal that the user may have intentionally rearranged or
2507+
// otherwise edited that folder and we should not override it.
2508+
//
2509+
// We use `userModifiedIds` — a snapshot of locallyModifiedBookmarkIds
2510+
// taken at the START of performSync, before any sync-driven
2511+
// creates/moves/deletes. This ensures that sync-driven changes
2512+
// (adds, folder moves, tombstone deletions) don't accidentally
25212513
// trigger the skip, which was the root cause of "bookmarks end up at
2522-
// the end of the toolbar" — the reorder pass was being skipped because
2523-
// sync-driven onMoved events polluted locallyModifiedBookmarkIds.
2514+
// the end of the toolbar" — the reorder pass was being skipped
2515+
// because sync-driven onMoved events polluted locallyModifiedBookmarkIds.
25242516
const idsToCheck = userModifiedIds || locallyModifiedBookmarkIds;
25252517
const modifiedChildrenCount = children.reduce(
25262518
(count, child) => (idsToCheck.has(child.id) ? count + 1 : count),
@@ -2687,17 +2679,13 @@ async function updateLocalBookmarksFromCloud(bookmarksToUpdate) {
26872679
const indexChanged =
26882680
cloud.index !== undefined && local.index !== undefined && cloud.index !== local.index;
26892681

2690-
// Skip index-only moves for locally modified bookmarks.
2691-
// This prevents the "reorder then Sync Now resets order" bug where
2692-
// cloud's stale indices overwrite local changes the user just made.
2693-
if (
2694-
locallyModifiedBookmarkIds.has(local.id) &&
2695-
!titleChanged &&
2696-
!folderChanged &&
2697-
indexChanged
2698-
) {
2682+
// Skip ALL cloud-driven updates for locally modified bookmarks.
2683+
// Local state takes priority and will be pushed to cloud in the push phase.
2684+
// This prevents stale cloud data (title, folder, index) from overwriting
2685+
// local changes the user just made.
2686+
if (locallyModifiedBookmarkIds.has(local.id)) {
26992687
console.log(
2700-
`[MarkSyncr] 🏠 Skipping index-only update for locally modified bookmark: ${cloud.url} (local index ${local.index} vs cloud index ${cloud.index})`
2688+
`[MarkSyncr] 🏠 Skipping cloud update for locally modified bookmark: ${cloud.url} (title: "${local.title}" vs cloud "${cloud.title}", index: ${local.index} vs cloud ${cloud.index})`
27012689
);
27022690
continue;
27032691
}

0 commit comments

Comments
 (0)