Skip to content

Focus Pages Stale — Root Cause Found & Permanently Fixed

Section titled “Focus Pages Stale — Root Cause Found & Permanently Fixed”

Date: 2026-06-26 Repos: ~/projects/monorepo, ~/utils/web/web-deploy Follows (failed prior attempts): 2026-06-20_Focus-Pages-Cache-Fix, 2026-06-23_Focus-Pages-Bfcache-Fix


MBR + SDC focus page PWAs (Android, iPod) kept showing stale content — task lists with outdated priorities. “Fixed” 2–3 times already; each fix held briefly then regressed.

Why every prior fix failed — the real root cause

Section titled “Why every prior fix failed — the real root cause”

The installed PWA loads from talbotstevens.com, which runs Microsoft-IIS/10.0 on Plesk/Windows (server: Microsoft-IIS/10.0, x-powered-by-plesk: PleskWin).

Both prior fixes targeted the wrong server type:

Prior fix (date)Mechanism addedWhy it did nothing on this host
2026-06-20public/_headersCloudflare-Pages-only — never applies to the FTP/IIS copy the PWA installs from
2026-06-20public/.htaccessApache-only. IIS ignores .htaccess entirely (even 404s .ht* via hiddenSegments)
2026-06-23bfcache reload handlers (JS)Correct & kept — but location.reload() honors the HTTP cache; with no cache header it re-serves stale bytes

Net effect: the server sent no Cache-Control header at all. Browsers fell back to heuristic caching ((now − Last-Modified) × 0.1, potentially hours), so each phone cached focus.html and never revalidated. Deploys updated the server file, but the phone never saw it.

$ curl -sI https://talbotstevens.com/mbr/focus.html
HTTP/2 200
content-type: text/html
last-modified: Thu, 25 Jun 2026 18:12:33 GMT
etag: "8e0032ce4dd1:0"
server: Microsoft-IIS/10.0 ← IIS, not Apache
x-powered-by-plesk: PleskWin
← NO cache-control line at all
$ curl -sI https://talbotstevens.com/mbr/.htaccess → HTTP/2 404 (IIS never processes it)

The deployed HTML itself was current (had the latest KB content + the bfcache handlers) — so the build/content pipeline was fine. The defect was 100% the missing server cache directive.

Replace the dead Apache .htaccess with an IIS web.config that emits Cache-Control: no-cache on focus.html.

sites/mbr/public/web.config + sites/sdc/public/web.config (new):

<configuration>
<location path="focus.html">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</configuration>

DisableCacheCache-Control: no-cache (+ Expires: -1). Astro copies public/ to dist/, so it deploys automatically.

Changes:

  • sites/mbr/public/web.config, sites/sdc/public/web.config — created
  • sites/mbr/public/.htaccess, sites/sdc/public/.htaccessdeleted (dead on IIS)
  • web-deploy/focus-deploy/deploy_mbr_focus.py + deploy_sdc_focus.pyFAVICON_FILES: .htaccessweb.config
  • monorepo/LESSONS.md + docs/focus-pages.md — corrected the (wrong) Apache root cause
$ curl -sI https://talbotstevens.com/mbr/focus.html | grep -i cache-control
cache-control: no-cache ✓ (same for /sdc/)
$ curl -sI -H "If-Modified-Since: <last-modified>" .../mbr/focus.html
HTTP/2 304 ✓ cheap revalidation when unchanged
$ curl -sI https://talbotstevens.com/mbr/web.config → HTTP/2 404 ✓ IIS protects it

Full state machine now works: PWA resume → bfcache pageshow/visibilitychange reload → conditional GET (no-cache) → 304 instant if unchanged, 200 fresh if redeployed. No heuristic caching, no stale task lists.

The rule going forward (added to LESSONS.md)

Section titled “The rule going forward (added to LESSONS.md)”

talbotstevens.com is IIS — use web.config, never .htaccess. After any focus-page cache change, the fix is only real if the header is live: curl -sI https://talbotstevens.com/<brand>/focus.html | grep -i cache-control → must show no-cache. Files uploaded ≠ header applied.

FTP from this WSL/sandbox env had intermittent DNS (gaierror -2) on ftp.talbotstevens.com and the deploy script’s nested subprocess got re-sandboxed. Worked around for this deploy by uploading via a single direct ftplib session to the resolved IP (23.111.70.74), creds from web-deploy/.env via python-dotenv (bash source injected a CR → ftplib “illegal newline”). Not a code defect — only relevant if deploying focus pages from inside the agent sandbox again.


Follow-up (same day): Android still stale → client-side build-id check

Section titled “Follow-up (same day): Android still stale → client-side build-id check”

Talbot’s feedback after the header fix: iPad/iOS self-refreshed perfectly (no touch needed), but Android Chrome PWA was still stale even after close+reopen. Also requested: confirm the fix works after the planned IIS → Apache/Linux migration.

Why Android stayed stale: the no-cache header only governs future responses. Android had a pre-fix heuristic cache entry it still considered fresh, so it never revalidated focus.html, and location.reload() honors that cached entry rather than forcing the network. A header change can’t evict an already-poisoned cache entry. HTTP cache behavior can’t be relied on for correctness on Android PWAs.

Robust, host-independent fix — client build-id check (in focus.astro):

  • Each build stamps <meta name="focus-build" content="<ISO timestamp>"> (unique per build).
  • Inline script, on load + bfcache pageshow + resume visibilitychange, runs: fetch(location.pathname + '?_=' + Date.now(), { cache: 'no-store' }) → parse the live build id → if it differs, location.replace(location.pathname + '?v=' + liveId) (guaranteed cache miss → fresh).
  • no-store + cache-bust query means the freshness check can never be served from a poisoned cache, on any host. Degrades gracefully offline (fetch fails → keep current page). Throttled to avoid redundant checks; no redirect loop once fresh (verified).

Dual-host (answers “works on IIS and Apache?”): YES. Deploy now ships both web.config (IIS) and .htaccess (Apache) — each host honors its own, ignores the other, so the migration needs no change. .htaccess also denies public access to web.config on Apache. And the client build-id check is header-independent, so it works identically before, during, and after the migration.

Changes (follow-up):

  • sites/{mbr,sdc}/src/pages/focus.astrobuildId const + <meta name="focus-build"> + replaced the plain-reload freshness handlers with the build-id network check.
  • sites/{mbr,sdc}/public/.htaccess — re-created (Apache header + deny web.config).
  • web-deploy/focus-deploy/deploy_{mbr,sdc}_focus.pyFAVICON_FILES ships both web.config + .htaccess.
  • Rebuilt + deployed both.

Verification (real Chromium via Playwright, against live site):

  • Fresh page → no redirect loop, current content renders. ✓
  • Stale client (forced old build id) → same-origin no-store fetch detects newer build → would location.replace('?v=<live id>'). ✓
  • Live: cache-control: no-cache on both; <meta name="focus-build"> present; ?_= and ?v= URLs → 200. ✓

This closes the Android gap and is migration-proof.


Follow-up 2 (same day): open-page auto-update (Tier 2 poll)

Section titled “Follow-up 2 (same day): open-page auto-update (Tier 2 poll)”

Talbot’s question: can a PWA auto-refresh shortly after content is pushed, like the iPad appeared to do? Answer given: the iPad wasn’t doing push — it was open/resumed and revalidated. A PWA runs code only while open (no service worker here), so:

  • Open / resumed page: yes — and now deterministic (below).
  • Fully-closed app, proactively: only via Service Worker + Web Push (Android real; iOS 16.4+ installed-PWA notifications only) — deliberately not built (too heavy for a personal dashboard).

Upgrade shipped — visibility-gated 60s poll. The existing checkFresh() now also runs on setInterval(…, 60000), started on load/resume and cleared while the page is hidden (zero battery/network in background). An open focus page self-refreshes shortly after a deploy with no user action. No service worker; cross-platform (Android, iOS, desktop).

Changes: sites/{mbr,sdc}/src/pages/focus.astro — added startPolling/stopPolling wired into the load + visibilitychange handlers. Rebuilt + deployed both focus.html. Docs updated: README.md (Focus Pages → freshness subsection), docs/focus-pages.md, LESSONS.md.

Verification (real Chromium):

  • Poll logic (stubbed, fast interval): no false nav when unchanged; poll detects a new build while open and navigates; repeat checks issued; polling pauses when hidden (fetch count flat). 4/4.
  • Live unattended E2E: opened talbotstevens.com/mbr/focus.html, left it untouched, pushed a new build mid-session → the open page auto-navigated to ?v=<new id> and rendered the new build within the poll window. 4/4.