Skip to content

Persist dark/light theme preference per user #69

Description

@thermcampos

Problem Statement

The app has a floating button to toggle between light and dark theme, but the choice is only stored in the browser's localStorage. It is not tied to the user account, so the preference is lost when switching browsers or devices, and users have reported the theme unexpectedly changing back some time after a reload. Investigation confirmed there is no device/OS theme detector in the client; nothing but the toggle button writes the theme value, so the drift likely comes from unrelated state/remount behavior plus the lack of a server-side source of truth.

Solution

Persist the theme preference on the user record, expose it through the existing current-user endpoint (/me), and keep localStorage as a fast local cache. The server value is the source of truth for signed-in users: it is applied as soon as the user logs in or the session is restored, and localStorage is re-synced to match. Signed-out users keep the current local-only cosmetic behavior. Failed persistence attempts are retried silently on the next session sync instead of reverting the UI.

User Stories

  1. As a signed-in user, I want my theme preference saved to my account, so that it follows me across browsers and devices.
  2. As a signed-in user, I want my saved theme applied as soon as I log in, so that I never see the wrong theme after authenticating.
  3. As a signed-in user with a saved preference, I want the server value to override a stale localStorage value, so that my account is the single source of truth.
  4. As a signed-in user, I want the theme from localStorage to paint immediately on page load before the server responds, so that I don't see a flash of the wrong theme.
  5. As a signed-in user, I want my toggle to update the UI instantly even if the save request is slow, so that the app feels responsive.
  6. As a signed-in user, I want a failed theme save to be retried silently on my next login or session refresh, so that my preference eventually reaches the server without me noticing.
  7. As a signed-in user, I want the UI to keep my chosen theme when the save request fails, so that my visible choice is never reverted by a network problem.
  8. As a signed-in user with a pending (failed) theme save, I want my newer local choice to win over the older server value on the next sync, so that my most recent decision is preserved.
  9. As an existing user whose account predates this feature, I want my theme to default to light, so that behavior is predictable after the migration.
  10. As a signed-out user, I want to toggle the theme with only localStorage involved, so that the landing and login pages remain theme-able without an account.
  11. As a signed-out user who then logs in, I want the theme to switch to the one saved in my account, so that my account preference takes over after authentication.
  12. As a user logging out, I want the current theme left as-is, so that the screen doesn't change appearance during sign-out.
  13. As a user, I want toggling the theme to not remount or reset the page I'm on, so that I don't lose scroll position or unsaved form input.
  14. As an API consumer, I want the theme field on the current-user response to contain only light or dark, so that clients can rely on a closed set of values.
  15. As an API consumer, I want invalid theme values rejected with a clear error, so that bad data cannot be written to user accounts.

Implementation Decisions

  • Schema: new Flyway migration adding a nullable theme column (short varchar) to the users table, following the existing lang column precedent. NULL is treated as light; no backfill (existing users default to light, per product decision).
  • User entity: add a theme field with getter/setter, mirroring how lang is modeled.
  • API contract, read: the current-user endpoint (GET /rest/users/me) response gains a theme field. The login/signup token response is intentionally left unchanged.
  • API contract, write: the existing user patch endpoint (PATCH /rest/users) accepts an optional theme field. Values are validated strictly: only light and dark are accepted; anything else is rejected with a new dedicated bad-theme exception mirroring the existing bad-language one. Absent/null/blank means "don't change", consistent with the other patchable fields.
  • Fresh-login fetch: the client auth flow currently builds the user object from the login response and only calls /me on session restore and on a periodic refresh. To apply the server theme immediately at login, the sign-in flow additionally fetches /me after the token is stored (one extra request; login response contract untouched).
  • Theme state location: theme state stays in the top-level App component, which already consumes the auth context; no new context/provider is introduced.
  • Client sync semantics (decided with the requester):
    • Toggle: update UI state and localStorage immediately; if signed in, send the patch; if signed out, localStorage only.
    • On user data arriving (login, session restore, periodic refresh): if no pending save exists, the server theme wins (null -> light) and localStorage is re-synced to it; if a pending save exists, the server value is ignored, the pending value is re-patched, and the pending flag is cleared on success (kept on failure, retried at the next sync).
    • Pending marker: a dedicated localStorage key holding the target theme value; only the latest value matters (no queue).
    • Logout leaves both the theme and the pending marker untouched.
  • Router bug fix (same branch): the browser router is currently created inside the App render body, so every state change (including each theme toggle) recreates it and remounts the entire route tree, losing scroll and in-memory page state. Memoize router creation keyed on auth status.
  • Account settings page: unchanged; the floating toggle button remains the only theme UI.
  • Request paths and error shape stay compatible with the existing frontend API config and global exception handling conventions.

Testing Decisions

Good tests here assert external behavior only (API responses, validation errors, rendered theme attribute, localStorage effects), never implementation details (state variables, call counts of internals).

Reuse existing seams; no new test infrastructure:

  • Backend service seam: extend the existing auth service unit tests to cover patching a valid theme, rejecting an invalid one, and ignoring absent/blank values; extend the existing user controller tests for the new field on /me and the patch endpoint. Prior art: the lang field coverage in the same test classes.
  • Frontend component seam: extend the existing App tests (theme toggle, localStorage sync, server-wins vs pending-wins behavior on user load) and auth provider tests (post-login /me fetch). Prior art: current theme-initialization and session-restore tests in those files.
  • Migration correctness is covered by the existing Flyway bootstrapping in backend integration tests; no dedicated migration test.

Quality gates: bash tools/check-frontend.sh and bash tools/check-backend.sh must pass.

Out of Scope

  • Syncing theme via the login/signup token response.
  • A theme selector on the Account settings page.
  • Respecting OS/browser prefers-color-scheme (explicitly rejected; the user's explicit choice is the only input).
  • Server-side theming for the public shared-note page beyond current behavior.
  • Queueing multiple pending patches or conflict resolution beyond "latest pending value wins".

Further Notes

  • Known accepted edge case: the pending marker survives logout; if a different user logs in on the same browser before the retry succeeds, the retry would push the previous user's theme to their account. Judged cosmetic and rare; accepted.
  • Branch fix/dark-light-theme-memory was created for this work.
  • The theme toggle currently applies via the data-bs-theme attribute on <body> plus a dark-mode stylesheet; that mechanism stays as-is.

Acceptance Criteria

  • Signed-in persistence - 1. Sign in, toggle to dark, log out/in (or reload on another browser/incognito with same account) -> theme is dark after login.
  • Signed-in persistence - 2. Toggle the theme -> a PATCH /rest/users fires with "theme": "dark"/"light" and the UI flips instantly.
  • Signed-in persistence - 3. GET /rest/users/me response contains theme with value light or dark only.
  • Source os truth & sync - 4. Set localStorage theme to a value different from the server, reload -> after session restore, server value wins and localStorage is rewritten to match.
  • Signed-in persistence - 5. Existing account (created before this feature, theme = NULL in DB) -> /me returns "light"; app shows light theme.
  • Signed-in persistence - 6. While signed out, toggle to dark on landing/login page -> works, no network call; then log in -> theme switches to the account's saved theme.
  • Failure handling - 7. Block/throttle the PATCH (DevTools offline or network throttling), toggle -> UI keeps the new theme, TASKNOTE-THEME-PENDING appears in localStorage with the target value.
  • Failure handling - 8. With a pending market set, reload and let the session restore -> the pending value is re-patched (not the server value); on success the marker is removed, on failure it stays.
  • Failure handling - 9. Log out with a pending marker -> marker and current theme are left untouched.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions