Sync the dashboard layout with the server (backend #213) - #288
Conversation
The dashboard arrangement is still written to local storage first, and is now also sent to /users/me/dashboard/layout (debounced) and read back on arrival, so it follows the user to another device. On arrival the server's layout wins; if it has none, this browser's layout is uploaded once. Reset forgets it on the server too. Failures fall back to local storage silently, as before.
DavidLeuter
left a comment
There was a problem hiding this comment.
Nice work — the hook is closer to useBoardStructureSync than a copy would be, and it's more careful in a couple of places (holding a change made before the first read, flushing the queued layout on unmount). Tests read well. Requesting changes for one behavioural bug, plus a small race.
1. A reset on one device gets undone by any other device (blocking)
dashboardLayoutService.resetLayout says it "forgets the arrangement, so every device falls back to the default". That doesn't hold, because the arrival logic can't tell "never synced" apart from "reset somewhere else":
- PC and laptop both have layout X (server + local storage on both).
- Reset on the PC →
DELETE, the server has no row. - Open the dashboard on the laptop →
fetchLayoutanswersupdatedAt: null, the laptop still has X in local storage → the migration branchsaveLayout(X). - Back on the PC, X is back.
With a multi-device feature that's a very reachable path. A reset only survives if it happens on every device where the layout was ever stored.
Suggestion: remember per browser that the local copy has already been synced once (e.g. a synced: true next to version in the stored entry, set after the first successful pull/push). On arrival with an empty server:
- local never synced → upload it (the real migration, as now)
- local already synced → someone reset it elsewhere →
clearStoredLayout(userId)+onPulled()
A test for "reset elsewhere, stale local copy here → stays default" would pin it down.
2. A change made while the pending change is being flushed gets dropped (small)
In the arrival effect, pulledFor.current is only set in finally, after await saveLayout(waiting.layout) / await resetLayout(). A push() during that await still sees pulledFor !== userId and overwrites pending.current. Then finally sets pending.current = null, so that newer layout never reaches the server. Local storage keeps it, but on the next visit the server's older copy wins and overwrites it.
Fix: take waiting out of pending, set pulledFor.current = userId before awaiting the flush (later pushes then go through the debounce as usual), or re-check pending after the await.
Non-blocking notes
- "The last pending change is flushed when leaving the page" only covers unmount (SPA navigation). Closing the tab or reloading within the 1.2 s window drops the PUT, and so does any failed PUT (offline). In both cases the next arrival lets the older server copy overwrite the newer local one. So "a failed request costs nothing" is only true until the next page load. The board has the same trade-off, so I'm fine leaving it. If you want to close it cheaply: flush
queuedonpagehidewithfetch(..., { keepalive: true }). - The
[userId]cleanup flushes the queued layout with whatever token is current whenuserIdchanges. That's harmless right now since logout reloads, but it's worth a comment if user switching ever happens without a reload.
Backend half (sprintstart-backend#270) looks good to me.
Remember per browser whether the layout was synced once. When the server has no layout, a local copy that was synced before is a stale copy of a layout reset elsewhere and is dropped; only a never-synced copy is migrated up, and it counts as synced only once the upload succeeded. Also settle the first read before flushing a held change, so a change made while it is being sent is no longer dropped.
|
Thanks for the thorough review! Both points are fixed in fabd4a8. 1. Reset undone by another device On arrival, when the server has no layout:
Tests: "drops a stale copy instead of bringing back a layout that was reset on another device" and "does not treat a failed migration as synced". 2. Change dropped while the held change is flushed Non-blocking
Dashboard tests: 87/87 passing locally. |
DavidLeuter
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. Point 2 (the race) is fixed cleanly and the test covers it. I agree on skipping pagehide, and the offline-change-vs-reset gap you mention is acceptable.
Point 1 fixes the resurrection, but the synced flag introduces a new failure mode that's worse than the one it replaces: the flag says "this browser has talked to the server once", but it gets read as "the local copy is what the server had". Those two diverge as soon as a PUT after the first sync doesn't go through, and then the whole local layout gets deleted.
Traced through the code:
- First visit on a device. Server empty, nothing local → the last
elsebranch →markLayoutSynced. - The user arranges their dashboard. The debounced PUT fails (backend briefly down, offline, or the tab is closed within the 1.2 s window).
- Next visit: server empty,
local && readLayoutSynced(userId)→clearStoredLayout→ the arrangement is gone, on the device it was made on.
It happens the same way after a reset on this device (reset marks synced → the user rearranges → the PUT fails → next visit wipes it). Before this commit, a failed PUT cost at most the latest change (the older server copy won). Now it can cost the whole layout.
Suggested fix: make the flag mean "local == server". Clear it on every local write and only set it after a successful exchange:
push()(orapplyinuseDashboardLayout) →markLayoutUnsynced(userId)- successful pull / PUT / DELETE →
markLayoutSynced(userId), as now
Then on arrival with an empty server: local && synced → reset elsewhere → drop it; local && !synced → upload it (migration, or a change that never made it up). The second case now also covers "edited offline while it was reset elsewhere" (the newer local statement wins instead of being dropped), so it closes the gap you mentioned too.
One detail with that: the .then(() => markLayoutSynced(userId)) on the debounced PUT would mark a newer local change as synced if another push happened while that PUT was in flight. Only marking when queued.current === null && !timer.current at resolve time (or comparing a small revision counter) avoids that.
A test for the scenario above would pin it: nothing anywhere → sync settles → push(LOCAL) with saveLayout rejecting → remount with the server still empty → expect LOCAL to be uploaded rather than cleared.
Rest of the diff looks good. CI was still running when I looked.
The flag is now cleared on every local change and set only after a request confirmed both sides agree, and only if nothing changed locally while it was on its way. When the server has no layout, a local copy that is not in sync is uploaded (migration, or a change whose PUT failed) instead of being cleared as a reset from another device.
|
Good catch — agreed, the flag was answering the wrong question. Fixed in 2b22472, along the lines you suggested:
On arrival with an empty server: New tests:
Dashboard tests: 89/89 passing locally. |
DavidLeuter
left a comment
There was a problem hiding this comment.
Thanks, this is the right shape now. Approving.
I went through the arrival paths again against 2b22472:
- Server has a layout → it wins, marked in sync, no await in between, so no revision check is needed there. ✓
- Server empty, local in sync → reset elsewhere, dropped. ✓
- Server empty, local not in sync → uploaded. That covers the old migration, a failed PUT, and an offline edit made while it was reset elsewhere. ✓
- Held change / held reset / in-flight PUT →
confirmSynced(sentAt)only vouches for what was actually sent, and a newer push clears the flag first. ✓ - The unmount flush that never marks in sync is a good call. The worst case is one redundant upload.
Both new tests pin exactly the scenarios from my last review.
The one trade-off left is the same one the board has: a failed PUT, followed by a layout that a different device stored on the server, loses to the server copy on the next visit. Neither side is clearly newer there, so I'm fine with server-wins.
CI was still queued when I looked, so please merge once it's green.
Frontend half of SprintStartProject/sprintstart-backend#213 — needs SprintStartProject/sprintstart-backend#270.
What
The dashboard layout now follows the user to other devices instead of living only in this browser.
dashboardLayoutService.ts–GET/PUT/DELETE /api/v1/users/me/dashboard/layoutuseDashboardLayoutSync.ts– modelled on the board'suseBoardStructureSync:storage.ts– exportsLAYOUT_VERSION, which is sent with every read and write; the server answers a layout of another version as the default, same as the client already does locally.Tests
useDashboardLayoutSync.test.tsx– server wins, migration, nothing stored, change before first read, debounce, reset, offline.tsc,eslint,prettierclean.Note for running locally on Node 25+:
NODE_OPTIONS=--no-experimental-webstorage npm test, otherwise Node's ownlocalStorageshadows jsdom's (CI uses Node 24 and is unaffected).