Skip to content

Stop the app reloading while a form is being filled in - #288

Merged
mike12806 merged 2 commits into
mainfrom
claude/form-refresh-bug-46amsi
Aug 4, 2026
Merged

Stop the app reloading while a form is being filled in#288
mike12806 merged 2 commits into
mainfrom
claude/form-refresh-bug-46amsi

Conversation

@mike12806

@mike12806 mike12806 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Filling in a quick-log form in the installed PWA could lose the whole form to a page refresh. Three separate things could pull the rug out; all three are fixed here behind one shared guard, and a draft covers what's left.

1. The service worker reload (the main one)

vite.config.ts uses registerType: "autoUpdate". In that mode vite-plugin-pwa reloads the page itself as soon as the new worker activates:

wb.addEventListener("activated", (event) => {
  if (event.isUpdate || event.isExternal) {
    if (onNeedReload) onNeedReload();
    else window.location.reload();   // ← this ran
  }
});

pwa.ts was passing onNeedRefresh, which is only called in "prompt" mode — so the reload we thought we controlled was the plugin's unconditional one. After any deploy it fires a few seconds into the next launch, which is exactly when someone has opened the app to log a feed.

pwa.ts now passes onNeedReload (the documented hook for taking over the reload) and defers it to a moment that costs nothing: the app going to the background, or coming back after 30+ minutes away. It never fires while a form is open.

2. The on-focus refetch

DataRefreshProvider refetches on visibilitychange/focus. On a phone the on-screen keyboard and the native date picker both blur and re-focus the window, so this fired repeatedly while the form was open, rebuilding every list underneath it — and each fetch was another chance to trip #3. It's now held while a form is open and runs once the form closes. Saving still refreshes immediately, as before.

3. The re-auth navigation

doFetch treated any fetch() rejection as an expired Cloudflare Access session and navigated to /api/auth/login. A dropped connection — routine on a phone — throws the same way, so a momentary blip navigated away from a half-filled form.

A cache-busted GET /auth/me probe now tells the two apart: session alive → surface a network error; probe also blocked → re-auth as before. The original request is deliberately not retried, since a POST that failed on the way back would double-log the entry. A genuine 401 still re-auths immediately.

The shared guard

isUserBusy() — any open modal or focused field — asked of the DOM rather than registered per dialog, so the ~15 pages with their own add/edit dialogs are covered without opting in, and pages added later stay covered. A focused field counts even when the window is blurred, which is the iOS native-picker case.

4. Drafts, for the interruptions that can't be held off

A genuine session expiry has to navigate to re-auth, and iOS evicts a backgrounded PWA whenever it likes. Neither gives the form a chance to react, so QuickLogDialog now persists what's been entered on every edit and offers it back the next time that category is opened ("Picked up where you left off").

Drafts are per child, expire after six hours, and are cleared as soon as the entry is saved or the form is dismissed — dismissing is an explicit discard — so the only draft that survives is one the user never got to finish. A form that was merely opened stores nothing. localStorage rather than sessionStorage, so an evicted app still has it on relaunch; every access is guarded, so storage that's full, blocked, or holding something corrupt loses the draft and never the form.

Only the quick-log dialog is covered. The per-page add/edit dialogs still lose their contents on a teardown — worth doing if the same thing shows up there, but it's a bigger diff for a much rarer path.

Testing

  • npm test -w client — 84 passed, 21 of them new: deferredReload.test.ts (8), FormDraft.test.tsx (8), apiClient.test.ts (3), DataRefresh.test.tsx (2)
  • npx tsc --noEmit -p client/tsconfig.json and npm run build:client — both clean

(An earlier version of this description put the count at 84 before the draft work landed; it was 76 at that point.)

claude added 2 commits August 4, 2026 20:28
Logging an entry in the installed PWA could lose the half-filled form to a
page reload. Three things could interrupt it:

The service worker. With registerType: 'autoUpdate' vite-plugin-pwa reloads
the page itself the moment a new worker activates, unless the app takes over
via `onNeedReload` — `onNeedRefresh`, which pwa.ts was passing, is only called
in 'prompt' mode, so the reload we thought we controlled was the plugin's
unconditional one. After a deploy that fires seconds into the next launch,
which is exactly when someone is tapping out a feed. Updates now wait for a
moment that costs nothing: the app going to the background, or coming back
from a long stint there.

The on-focus refetch. A native date picker and the on-screen keyboard both
blur and re-focus the window, so it fired repeatedly mid-form, rebuilding
every list under the dialog. It's now held while a form is open and runs once
the form is closed.

The re-auth navigation. Any fetch() rejection was read as an expired Cloudflare
Access session and navigated to the login route — but a dropped connection,
routine on a phone, throws the same way. A cache-busted /auth/me probe now
tells the two apart, so a blip surfaces as an error instead of navigating away.
The original request isn't retried: a POST that failed on the way back would
double-log.

The shared guard is `isUserBusy()` — any open modal or focused field — asked of
the DOM rather than registered per dialog, so pages added later are covered.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TTSRgrWjQN9E99n5wxT6r8
Most interruptions now wait for the user to finish, but two can't be held off:
an expired Cloudflare Access session has to navigate to re-auth, and iOS
evicts a backgrounded PWA whenever it likes. Neither gives the form a chance
to react.

QuickLogDialog now persists what's been entered on every edit and offers it
back the next time that category is opened. Drafts are per child, expire after
six hours, and are cleared as soon as the entry is saved or the form is
dismissed — dismissing is an explicit discard — so the only draft that ever
survives is one the user never got to finish. A form that was merely opened
stores nothing.

localStorage rather than sessionStorage so an evicted app still has the draft
on relaunch. Every access is guarded: storage that's full, blocked or holding
something corrupt loses the draft, never the form.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TTSRgrWjQN9E99n5wxT6r8
@mike12806
mike12806 marked this pull request as ready for review August 4, 2026 21:34
@mike12806
mike12806 merged commit 19f343f into main Aug 4, 2026
6 checks passed
@mike12806
mike12806 deleted the claude/form-refresh-bug-46amsi branch August 4, 2026 21:35
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.

2 participants