Skip to content

Add reordering, key badges, and syncer - #36

Merged
xjeyk merged 7 commits into
trunkfrom
feat/edit-mode
Sep 10, 2026
Merged

xjeyk merged 7 commits into
trunkfrom
feat/edit-mode

Conversation

@xjeyk

@xjeyk xjeyk commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Add shortcut reordering (Move left/Move right) with an immutable moveItemInList helper and UI controls + disabled/bounds behavior. Add numeric key badges (1–9, 0) and speed-dial keyboard activation (1–9, 0; Shift opens new tab). Implement createSyncer in storage.js to debounce writes and guard against self-echoed chrome.storage.onChanged events. Update storage exports, wire syncer into newtab.js, adjust rendering to emit badges and move controls, add CSS for badges and dropdown-disabled states, include custom-shortcuts.json, and add unit/integration tests covering debounce, syncer, keyboard nav, and reordering.

Closes: #34, #5

Add shortcut reordering (Move left/Move right) with an immutable moveItemInList helper and UI controls + disabled/bounds behavior. Add numeric key badges (1–9, 0) and speed-dial keyboard activation (1–9, 0; Shift opens new tab). Implement createSyncer in storage.js to debounce writes and guard against self-echoed chrome.storage.onChanged events. Update storage exports, wire syncer into newtab.js, adjust rendering to emit badges and move controls, add CSS for badges and dropdown-disabled states, include custom-shortcuts.json, and add unit/integration tests covering debounce, syncer, keyboard nav, and reordering.
@xjeyk xjeyk self-assigned this Sep 10, 2026
@xjeyk xjeyk linked an issue Sep 10, 2026 that may be closed by this pull request
4 tasks
@xjeyk
xjeyk marked this pull request as ready for review September 10, 2026 21:43
@xjeyk

xjeyk commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Review:

Blocker
The PR’s self-echo guard incorrectly uses serialized state content as an origin identifier. A valid remote update that matches any of the last ten locally written states is discarded, leaving the rendered state inconsistent with the mirrored local-storage state. The claimed “remote wins” behavior also fails if the local write has already started.

Issue 1 — storage.js:185-220
Problem: history records serialized payloads, and onRemote() suppresses any incoming state that has matching content. A separate device can legitimately publish a previously local state (for example, undoing an edit or writing from stale state). This tab silently ignores that remote update because its payload remains in history. onChange() has already copied the remote state to local storage, while newtab.js keeps rendering the stale in-memory state, producing UI/storage divergence until reload.

Required Fix: Store a stable per-install writer ID plus a monotonic revision (or equivalent provenance metadata) with every synchronized state. Suppress only the exact local write identified by that origin and revision. Apply remote states from other origins even if their business payload is byte-identical. Add a two-device regression test for a remote reversion to a prior local state.

Issue 2 — storage.js:194-219
Problem: Calling debouncedSave.cancel() only prevents a timer that has not fired. Once the timer callback invokes saveAll(state), its asynchronous chrome.storage.sync.set call is already in flight and cannot be cancelled. A remote update arriving during that write is rendered as the winner, but the stale local write can complete afterward and overwrite it in sync storage. The PR only tests the pre-timer cancellation path; it does not test the in-flight write race.

Required Fix: Route local writes and remote reconciliation through a single serialized async state machine. Use revisions and an invalidation check so a remote update can prevent an older local operation from committing. Add a test that holds the local sync write pending, delivers a remote update, then resolves the stale write and verifies the remote state remains authoritative.

Issue 3 — storage.js:194-206
Problem: The debounced callback invokes saveAll(state) without awaiting, retaining, or handling its promise. persist() therefore has no completion or failure contract. Additionally, rememberWrite() records a state before either storage write has succeeded, so a failed write may be treated as a self-echo and a subsequent real remote update can be discarded.

Required Fix: Make the syncer own the write promise and expose a flush()/completion path for lifecycle boundaries. Record an echo marker only after the sync-layer write succeeds, and propagate Chrome storage failures. Add failure-path coverage for rejected/failed local and sync writes, including an incoming state after a failed local write.

@xjeyk

xjeyk commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

@qodo review this change

@qodo-code-review

Copy link
Copy Markdown

review this change

I reviewed the change and don’t see any actionable correctness issues.

The implementation correctly:

  • Assigns keyboard badges in flat group/ungrouped order, with 0 mapped to the tenth tile.
  • Ignores numeric shortcuts while form fields or content-editable elements are focused.
  • Reorders shortcuts immutably within their own section and persists the updated state.
  • Disables boundary move controls and omits them for single-item sections.
  • Covers the new behavior with unit and integration tests.

…ed write queue, and error handling

- Add stable writerId + monotonic revision for provenance (Issue 1)
- Serialize writes through promise chain; remote updates invalidate pending local writes (Issue 2)
- Track acknowledged revisions only after successful sync write; propagate errors (Issue 3)
- Add regression tests: two-device reversion, flush(), bounded history
- Update newtab.js to use async createSyncer and pass meta to onRemote
@xjeyk

xjeyk commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner Author

Review:

0 Security vulnerabilities, 5 Potential bugs, 3 Code quality issues **Resolved in commit [fe9899f]**

Prevent disabled move buttons from triggering moveShortcut by checking btn.disabled (and/or boundary guards) in handleClick.

Description

Disabled move buttons can still trigger reordering via event delegation

  • Description: The move controls are rendered as <button ... disabled> but handleClick does not guard against btn.disabled, so programmatic clicks (or forced clicks in tests/automation) can still call moveShortcut, potentially reordering when UI indicates it’s disabled.
  • PR Git Diff Pointer:
@@
-  } else if (action === 'move-shortcut-left' || action === 'move-shortcut-right') {
+  } else if (action === 'move-shortcut-left' || action === 'move-shortcut-right') {
     e.preventDefault();
     e.stopPropagation();
     closeAllTileMenus();
     moveShortcut(groupId, shortcutId, action === 'move-shortcut-left' ? 'left' : 'right');
   } else if (action === 'edit-shortcut') {
  • Evidence: tileHTML explicitly adds disabled for boundary items (${canMoveLeft ? '' : ' disabled'}), but handleClick only checks EDIT_ACTIONS.has(action) and never checks btn.disabled before executing the action.
  • How to Fix: Add if (btn.disabled) return; (or action-specific boundary checks) before calling moveShortcut.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/newtab.js


Fix saveAll() to use chrome.runtime.lastError instead of a callback error argument for chrome.storage.*.set.

Description

Incorrect Chrome storage error handling: callbacks don’t receive an error argument

  • Description: saveAll treats the callback parameter as an error (err => err ? reject(err) : resolve()), but Chrome extension APIs report errors via chrome.runtime.lastError, so failures may be silently treated as success or vice versa depending on callback invocation.
  • PR Git Diff Pointer:
@@
-    new Promise(resolve => chrome.storage.local.set(payload, resolve)),
-    new Promise(resolve => chrome.storage.sync.set(payload, resolve)),
+    new Promise((resolve, reject) => chrome.storage.local.set(localPayload, err => err ? reject(err) : resolve())),
+    new Promise((resolve, reject) => chrome.storage.sync.set(syncPayload, err => err ? reject(err) : resolve())),
  • Evidence: Elsewhere in the repo, storage calls are used with cb && cb() patterns and never pass an error argument (e.g., tests stub set: vi.fn((obj, cb) => cb && cb())), indicating the callback signature is assumed to be no-args.
  • How to Fix: In each callback, check chrome.runtime?.lastError and reject with that message when present; otherwise resolve.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/storage.js


Fix debounce() so earlier returned promises are resolved/rejected when superseded by later calls before the timer fires.

Description

debounce() promise bookkeeping can leave earlier callers unresolved when multiple calls happen before timer fires

  • Description: Each call overwrites pendingResolve/pendingReject/pendingPromise; when a second call happens before the timer fires, the previous promise is lost and never resolved/rejected, causing awaiters to hang.
  • PR Git Diff Pointer:
@@
   const debounced = (...args) => {
@@
-    // Create a new promise for this debounced call
-    pendingPromise = new Promise((resolve, reject) => {
-      pendingResolve = resolve;
-      pendingReject = reject;
-    });
+    // Create a new promise for this debounced call
+    pendingPromise = new Promise((resolve, reject) => {
+      pendingResolve = resolve;
+      pendingReject = reject;
+    });
@@
     timer = setTimeout(async () => {
@@
     }, ms);

     return pendingPromise;
   };
  • Evidence: There is no resolution of the previous pendingResolve when a new call arrives (unlike createSyncer.scheduleDebouncedSave, which explicitly resolves the superseded promise), so only the last call’s promise is settled.
  • How to Fix: Track a list/queue of pending resolvers or resolve the previous pending promise when superseded (same approach used in createSyncer.scheduleDebouncedSave).

Location

File: /Users/jkubik/Code/new-tab-shortcuts/storage.js


Make boot() idempotent (booted flag) to prevent double initialization and duplicate event listeners.

Description

boot() can run twice in edge cases, duplicating event listeners and causing double-handling

  • Description: The new readyState-based boot logic can call boot() immediately and also register DOMContentLoaded if document.readyState is truthy but still 'loading' at evaluation time, leading to duplicate listeners (e.g., two keydown handlers, two onChange subscriptions).
  • PR Git Diff Pointer:
+if (typeof document !== 'undefined' && document.readyState && document.readyState !== 'loading') {
+  boot();
+} else if (typeof document !== 'undefined' && document.addEventListener) {
+  document.addEventListener('DOMContentLoaded', boot);
+}
  • Evidence: boot() registers multiple global listeners (document.addEventListener('keydown', ...), document.addEventListener('keydown', handleKeyNavigation), and onChange(...)), and there is no idempotency guard inside boot().
  • How to Fix: Add a module-level let booted=false guard inside boot() (early return if already booted) to ensure single initialization.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/newtab.js


Adjust acknowledgedRevisions pruning to evict oldest insertion (not numeric min) to preserve intended echo-suppression window semantics.

Description

Acknowledged revision pruning removes the minimum value repeatedly, not the oldest, and can mis-prune when revisions are sparse

  • Description: The pruning logic deletes Math.min(...acknowledgedRevisions) when size exceeds limit, which is not “oldest insertion” and can behave unexpectedly if revisions are not contiguous or if the set contains older revisions that should remain for echo suppression.
  • PR Git Diff Pointer:
+        if (acknowledgedRevisions.size > historyLimit) {
+          const minRev = Math.min(...acknowledgedRevisions);
+          acknowledgedRevisions.delete(minRev);
+        }
  • Evidence: The previous implementation used a Set as insertion-ordered history and evicted history.keys().next().value (oldest insertion), but the new code evicts by numeric minimum, changing semantics.
  • How to Fix: Track acknowledged revisions in insertion order (e.g., an array/queue) and evict the oldest inserted revision, or store a bounded LRU keyed by revision.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/storage.js


Add trailing newline at EOF for storage.js and tests/newtab.test.js.

Description

Missing trailing newline at EOF breaks some tooling and diffs

  • Description: storage.js and tests/newtab.test.js are committed without a trailing newline, which can cause noisy diffs and some linters/formatters to fail.
  • PR Git Diff Pointer:
-export { init, saveAll, onChange, newId, validate, debounce, createSyncer, saveBackground, readBackground, clearBackground, saveBackgroundSize, readBackgroundSize };
+export { init, saveAll, onChange, newId, validate, debounce, createSyncer, saveBackground, readBackground, clearBackground, saveBackgroundSize, readBackgroundSize };
\ No newline at end of file
  • Evidence: Git explicitly marks \ No newline at end of file for both storage.js and the new tests/newtab.test.js.
  • How to Fix: Add a trailing newline to the affected files.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/storage.js


Move moveItemInList to a small utility module and import it in both newtab.js and tests, avoiding exporting internals from newtab.js.

Description

Exporting moveItemInList from newtab.js expands public surface without stability guarantees

  • Description: moveItemInList is exported solely for unit tests, which couples tests to production module exports and risks accidental external usage or future breaking changes.
  • PR Git Diff Pointer:
+export { moveItemInList };
  • Evidence: The function is otherwise only used internally by moveShortcut, and the new unit test imports it directly (const { moveItemInList } = await import('../newtab.js');).
  • How to Fix: Prefer exporting from a dedicated utility module (e.g., reorder.js) or use a test-only import strategy that doesn’t require changing production exports.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/newtab.js


Cache tile elements/hrefs during render and use the cache in handleKeyNavigation instead of querySelectorAll on each keydown.

Description

Keyboard navigation queries the DOM on every digit keypress

  • Description: handleKeyNavigation runs document.querySelectorAll('.tile[href]') on each numeric keydown, which is avoidable work and can become noticeable with many tiles.
  • PR Git Diff Pointer:
+  const tiles = document.querySelectorAll('.tile[href]');
+  const tile = tiles[idx];
  • Evidence: The tile order is already known at render time (flat index is computed via startIdx/nextTileIndex), but no cached list is maintained for key navigation.
  • How to Fix: Cache the tile hrefs (or elements) during render() and have handleKeyNavigation use the cached array.

Location

File: /Users/jkubik/Code/new-tab-shortcuts/newtab.js

@xjeyk
xjeyk merged commit c17a4bd into trunk Sep 10, 2026
3 checks passed
@xjeyk
xjeyk deleted the feat/edit-mode branch September 10, 2026 23:34
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.

Add lightweight shortcut re-ordering within groups and ungrouped feat: keyboard shortcut launcher (number/letter key to activate tile)

1 participant