Skip to content

Keep preferences on iOS: re-link lost device cookies, ship prefs with the page - #420

Merged
lstein merged 2 commits into
masterfrom
lstein/fix/ios-preferences-persistence
Oct 6, 2026
Merged

lstein merged 2 commits into
masterfrom
lstein/fix/ios-preferences-persistence

Conversation

@lstein

@lstein lstein commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Problem

On iOS (Safari, and Chrome/Firefox on iOS), the selected album, slide delay, autotagging flag and control-label visibility reset to defaults after the browser has been closed for a while.

Preferences have been stored on the server since #284, keyed by an HttpOnly photomap_device cookie. iOS deletes that cookie along with localStorage, though. The server then created a new device ID with a record of defaults, and the real record was left with nothing pointing to it: one install had 18 device records in 8 days. No client-side storage can fix that.

Three more bugs made it worse:

  • A boot with empty localStorage drew the UI from defaults. reconcileWithServer applied the server copy afterwards, but nothing redrew the UI from it.
  • Every write PATCHed the whole settings set, stale values included. So did the 30-second backup in page-visibility.js.
  • The "show control panel labels" checkbox only ever wrote to localStorage.

Changes

Backend

  • When a request has no cookie, it is re-linked to its device by client IP + User-Agent.
    • A fingerprint belongs to one device at a time and moves only on writes (PATCH/PUT).
    • DELETE /preferences/ drops the fingerprint, so Reset to Defaults isn't re-linked.
    • The cookie is re-sent on every response, renewing its Max-Age.
  • GET / embeds the device's record as window.initialPreferences (null if it has none) and sets the cookie itself. FastAPI doesn't merge a dependency's Response into a response returned directly, hence setting it there. The page is served Cache-Control: no-cache.
  • New fields: umapWindowOpen, and lastSlideIndex (album → index, merged per album rather than replaced).

Frontend

  • state.js starts from the embedded record, falls back to localStorage, then to defaults.
    • A device with no record seeds one.
    • A key the record lacks but localStorage has is sent up.
    • reconcileWithServer and the _prefServerUpdatedAt timestamp cache are gone.
  • Writes send only the keys they changed: generated setters, persistSettings(...keys), and persistSlidePosition.
  • The preferences client:
    • retries failed PATCHes with backoff;
    • on a 422, resends key by key, so one bad value doesn't drop the rest;
    • sends flushes one after another, never overlapping;
    • uses keepalive;
    • can be closed during Reset to Defaults.
  • page-visibility.js drops the sessionStorage backup/restore and the periodic full saves.
  • Visual session state is remembered: whether the UMAP window is open, grid vs. swiper (already persisted), and the last slide in each album.

Known limits

  • If the iPad's IP changes (DHCP) or an iOS update changes its UA string, the device gets a fresh record. That is today's behavior.
  • Browsers on one machine with an identical UA share a record once their cookies are gone.
  • Orphaned records from before this change have no fingerprint and can't be re-linked.

Testing

  • make lint, Jest (997 tests) and pytest (1299 tests) all pass. New tests cover:
    • re-linking, fingerprint ownership, forget, persistence across restarts, and the old store format;
    • embedding preferences in the page;
    • starting up from the embedded record and per-key writes;
    • retries, the 422 split, serialized flushes, and per-album merging;
    • restoring the slide position.
  • Checked end to end in headless Chromium with an iPad UA. After clearing both the cookie and localStorage, every setting and the visual state came back, and only one device record existed.
  • Checked on a real iPad by the author.
  • An adversarial review was run on the diff. The second commit fixes what it found.

🤖 Generated with Claude Code

lstein and others added 2 commits October 4, 2026 11:01
… the page

iOS WebKit deletes the photomap_device cookie along with localStorage after
the browser has been closed for a while. The server then minted a new device
with default preferences, orphaning the real record (18 device records in
8 days on one install). On top of that, a boot without localStorage drew the
UI from defaults and applied the server copy too late to show, and every
write PATCHed the whole settings set, stale values included.

- A request without the cookie is re-linked to its device by client IP +
  User-Agent; a fingerprint belongs to one device at a time, and forget
  drops it so Reset to Defaults can't resurrect the old record. The cookie
  is re-sent (renewing Max-Age) on every response.
- GET / embeds the device's record as window.initialPreferences and sets
  the cookie itself; state.js boots from it, falling back to localStorage.
  reconcileWithServer and the timestamp cache are gone.
- Writes send only the keys they changed (persistSettings); the label
  checkbox, which only ever wrote localStorage, now reaches the server.
- page-visibility.js drops the sessionStorage backup and periodic full
  saves; PATCH uses keepalive so the hide-time flush survives suspension.
- Visual session state persists too: whether the UMAP window is open and
  the last slide shown per album (grid/swiper was already persisted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes from adversarial review of the previous commit:

- A PATCH that fails (network, server restart) is requeued with backoff
  instead of dropped: with only changed keys sent and the server record
  winning at boot, a lost write was silently undone on the next reload.
  A 422 batch is resent key by key so one bad value (e.g. ?delay=5000)
  doesn't drop the rest. Flushes are serialized.
- lastSlideIndex is merged per album, client- and server-side, and each
  slide change sends only its own album's entry, so two tabs can't
  overwrite each other's positions.
- Reset to Defaults closes the PATCH queue (and waits out one in flight)
  before the DELETE, so a slideshow tick can't recreate the record.
- Fingerprints move only on writes, so two profiles of one browser don't
  rewrite preferences.json on every request; DELETE no longer re-sends
  the cookie it clears.
- Keys a record lacks but localStorage has are sent up at boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lstein
lstein merged commit 1f52847 into master Oct 6, 2026
11 checks passed
@lstein
lstein deleted the lstein/fix/ios-preferences-persistence branch October 6, 2026 17:45
lstein added a commit that referenced this pull request Oct 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant