The unit suite reported all 1937 files green and then failed the job with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
pending`, naming a different scoring test each time.
Vitest replaces globalThis.console with a sink that forwards every write
to the main thread as an onUserConsoleLog RPC call. When a worker
finishes a test file it awaits the calls in flight and then rejects
whatever was started after that point, so a console write from work that
outlived its test lands in the gap and fails the run. The blame goes to
the file being torn down rather than to the test that started the work,
which is why the named file never logged anything itself.
Three paths detach a promise and log on the handle: fireAndForget, the
stale-while-revalidate cache refresh, and the offline query persister.
They now register the handled handle in a set that both suite setups
drain in afterEach, so no test leaves work in flight. The registry is
inert outside test: nothing there awaits it, and holding a reference to
an unsettled promise would retain it for the life of a worker. No
production behaviour changes, so there is no changelog entry.
Evidence. A focused harness that starts failing background tasks and
returns hit the teardown error 10 runs out of 10 before the change and
0 out of 10 after. Instrumenting the real suite showed two detached
console writes per run escaping their test, both from the rollup tail;
after the change, none, and thirteen writes per run now complete inside
the test that started them.
The guard asserts the behaviour, not a count: a registered task must
keep settleBackgroundTasks from resolving ahead of it, and the set must
drain back to empty. It goes red on unregistered work, on a disabled
registry, on an unwrapped call site, on a sweep that matches nothing,
and on a setup that drops the hook.
Four small debts from the audit tail. The FHIR bundle's missing
Immunization resource gets the same written-decision paragraph the visits
exclusion carries — deliberate follow-up, not oversight. The service
worker's offline allowlist gains /api/daily/digest (both copies, policy
module and sw.js, plus the test) so the Today rail renders on an offline
reopen. The two Telegram plumbing tables that link to an account by a
bare userId string are frozen as a closed NO_FK_BY_DESIGN set in the wipe
guard — a third such table now fails the test instead of passing rule 1
silently. And deploy.md documents the manual digest-pin bump end to end:
imagetools inspect, the env update, and the three-probe /api/version
verification with the too-fast-deploy warning.
Each behaviour here was verified by removing the code that provides it and
confirming the test went red, then putting it back. Two of them did not, and
both were rewritten rather than kept:
The restore-side persister check asserted that the delegate's own dashboard
could not be read back inside a shared record. Deleting the guard left it
green, because the record-scoped hash already makes a snapshot written in one
record unreadable in another. It now counts what landed in the cache, so the
guard it names is the thing it measures — and the weaker line is kept beside
it with a note saying why.
The switcher's account rows were asserted from server-rendered markup. Radix
portals do not render server-side at all, so an earlier draft was measuring
the renderer. Those rows moved to the browser suite; what stays here is the
gate, which fails sharply — with the `canSwitch` bail removed the two cases do
not return different markup, they throw.
Two more shapes worth naming. The cache dimension is exercised through a real
QueryClient built from the app's own defaults, because "the hash string
changed" and "one record's data cannot be read while inside another" are
different claims. And the invalidation is a plain function over a client with
three live entries seeded, with a test that asserts they start valid — without
it, a client that failed to seed and one where everything was invalidated look
the same from every other assertion.
Every cached read in this app is about one health record, and nothing in the
key factory said which one — there was only ever the signed-in account's. Once
a browser can act on somebody else's, `["dashboard","snapshot"]` names two
different answers and the cache cannot tell them apart. That is this repo's
known cache-poison class, on health data, across two people.
One line at the root rather than a dimension threaded through twenty-three key
modules: a `queryKeyHashFn` that folds the record id into the cache identity of
every entry. Key arrays are untouched, which is the part that matters — prefix
invalidation and the import-time dependent-key bundles both match on the array
and keep working. Prefixing the arrays would have broken every bundle silently.
The hash is defence in depth. Switching is a full page reload, so the
in-memory cache is empty on the other side of it; the layers that survive a
reload are the IndexedDB snapshot and the service worker's data cache, and
those are handled by refusing to write either while a switch is on, and by
skipping the restore. Nothing of the owner's record ever reaches the disk of a
delegate's device, so a stale scope marker can cost an empty restore and never
another person's data.
The persister's per-account binding read the account payload's id, which under
a switch is the delegate in both states — it could not tell the two apart. It
now asks the record scope, which can.
- delete current and legacy HealthLog static page and data caches
- preserve unrelated CacheStorage namespaces during activation and logout
- keep IndexedDB query-cache removal in the session-end boundary
- exercise signed-in service-worker install at CacheStorage boundary
- require data-free offline navigation and immutable public assets
- require current and legacy session cache cleanup
Kill the above-the-fold first-load flash on `/`: the hero digest and the
batched chart series still fired client-side after JS download + hydrate,
so the hero and charts painted empty and filled in seconds later (LCP
~5.5 s mobile-throttled while the snapshot already dehydrated).
Extend the existing `page.tsx` snapshot prefetch to also seed the daily
digest and the chart series batch into the same dehydrated TanStack cache,
under keys byte-identical to the client's:
- Extract the series-batch route core into `readSeriesBatch(...)` so the
RSC and the route share one code path (no HTTP-to-self); the route thins
to auth + Zod + the shared reader, behaviour unchanged.
- New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes`
(the single implementation of the visible-with-data type-set derivation,
order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow`
(profile-tz end-of-day minus the coverage window). Server and client run
the same function over the same snapshot payload, and the window is
computed once server-side and threaded to the client as a prop, so the
`chartSeriesBatch` key matches on both sides by construction.
- Render the hero data-first so the SSR HTML carries the populated hero
(the LCP element) — hydration-safe because the digest is dehydrated and
never restored from the IndexedDB persist allowlist; bound that invariant
with comments on both the hero gate and the allowlist.
- Warm the shared chart-runtime chunk during hydration.
Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail
soft to the client-fetch path on any error; a ~400 ms soft budget bounds
the cold-day digest cost. No contract change, no visual change.
Mutations ran in TanStack's default 'online' network mode, which PAUSES a
write fired offline: isPending stays true forever, onError never fires,
and the paused mutation is dropped on reload — a health entry vanishes
with no 'not saved' signal. Switch mutations to 'always' network mode so
an offline write rejects immediately and the form surfaces an honest
failure, and add a global OfflineMutationToaster backstop for call sites
without their own onError. Correct the offline banner copy, which promised
changes would auto-save.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
The target editor invalidated a bare ["insights"] array on save and reset.
That literal sat outside the centralised factory, so the broad insights
fan-out it intended never matched the reads, which key through
insightsTargets/insightsRoot. Route the editor's reads and invalidations
through userThresholds(), insightsRoot(), insightsTargets() and analytics()
so a saved target actually refreshes every insights surface.
Migrate the other call sites the lint rule does not reach onto the factory:
the Coach conversation rail + thread reads, the per-metric personal-records
badge, the per-day measurement drill-down, the dashboard intake
compliance-chart fan-out, the per-medication side-effects list, the
onboarding auth invalidations, the notification-preferences read, and the
PWA persister's auth-me lookup.
Add the matching factory keys: coachConversations, coachConversation,
personalRecordsByMetric, measurementDrilldown, complianceChartInline and
medicationSideEffects. Each new key reuses the prefix its reads already use
so no invalidation shape drifts.
Drop the health-score card's inert onAskCoach prop and its now-dead
pass-through from the hero strip, the buildDashboardBands re-export that no
caller reached through the snapshot module, an unused tour-progress status
type, and an orphan service-worker cache helper.
Cap the serialized snapshot at 2 MB and skip the write when it's over — an
oversized blob is the likeliest IndexedDB quota trigger and its offline value
is marginal, so keeping the last good snapshot beats evicting it. When a write
still hits QuotaExceededError, log it and drop the persisted blob so the next
flush has room, instead of silently swallowing the failure and leaving the
offline cache stuck on a stale snapshot the user can't explain.
The allowlist and per-account binding are untouched.
The offline layer wrote far too much to disk and cleared it too rarely.
The query persister used a denylist (only auth/session/admin/tokens), so
every other family — coach, insights, illness, labs, mood, cycle — was
dehydrated to IndexedDB in plaintext; the service worker likewise
prefix-cached GET /api/insights, which serves decrypted Coach
conversations. Neither store carried an account marker and both were
wiped only on explicit logout, so a session that ended by expiry or a
401 left health data on disk for the next account on a shared profile.
Flip the persister to a strict allowlist (dashboard snapshot, per-chart
series, widget layout — the offline-dashboard set only) and stamp each
payload with the live account id, discarding on mismatch. Drop
/api/insights and /api/analytics from the worker allowlist. Wipe the
IndexedDB blob and the data/page caches on every session end — the
401/expiry redirect and the logout path both route through one helper.
restorePersistedQueryCache hydrated the dehydrated snapshot
unconditionally, so a restore that resolves in an effect AFTER the page
has already fetched a fresh result overwrote that live query with the
stale (often empty) on-disk copy — a read opened straight after a
mutation then showed pre-mutation data. Filter the dehydrated state to
keys the live cache does not already hold before hydrate, so restore only
fills gaps; a query already present is by definition fresher than disk.
Scope the post-restore invalidation to the keys actually hydrated so a
live query is never disturbed. Adds an invariant test covering the
clobber case.
The offline service worker cached allowlisted GET /api/* reads with
stale-while-revalidate, serving the cached pre-mutation copy first and
revalidating in the background. Online, a list fetched straight after a
create or update rendered the old (or empty) data until the next paint,
breaking read-after-write.
Switch the data branch to network-first: always fetch fresh from the
network online and refresh the cache behind it; fall back to the last
cached copy only when the network fails (offline). The offline fallback,
the allowlist plus auth/token/secret-body boundary, and the
release-versioned cache name are unchanged.
Add a regression guard to the cache-policy test that reads public/sw.js
and asserts the network-first wiring and the absence of the old
stale-while-revalidate handler.
An installed PWA opened offline rendered empty skeletons forever: the
service worker hard-excluded every /api/* path and there was no client
query persistence. Close both gaps for read value.
Service worker: add a stale-while-revalidate branch for an allowlist of
safe GET /api/* reads (dashboard snapshot + widgets, measurements,
medications, insights, analytics, version) in a release-versioned
healthlog-data cache. Two-layer refusal — a path deny guard for any
auth/token/session/login segment plus a body-level secret refusal for
hlk_/hlr_/sk- shapes and no-store responses — so a secret-shaped body or
an auth surface can never be persisted. Mutations stay network-only.
Client: add a dependency-free IndexedDB query persister that hydrates the
last successful reads before the first authenticated paint, build-version
and age gated, debounced on cache change, with an auth/session/admin/token
denylist. Cleared on logout alongside the SW page and data caches so a
shared device never leaks one account's data.
Offline banner now states it is showing last-synced data when a cached
snapshot is present. Bump release to v1.18.6.