Stop the app reloading while a form is being filled in - #288
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsusesregisterType: "autoUpdate". In that mode vite-plugin-pwa reloads the page itself as soon as the new worker activates:pwa.tswas passingonNeedRefresh, 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.tsnow passesonNeedReload(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
DataRefreshProviderrefetches onvisibilitychange/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
doFetchtreated anyfetch()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/meprobe 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 aPOSTthat failed on the way back would double-log the entry. A genuine401still 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
QuickLogDialognow 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.
localStoragerather thansessionStorage, 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.jsonandnpm 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.)