web: state goes stale after iOS PWA resume — resync on SSE reconnect (liveResource wrapper + always-fresh wake) #54
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#54
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The UI often stops updating until a manual reload — painful as an iOS PWA where reload is awkward. Root cause (traced, not speculative):
internal/events/bus.go,internal/httpapi/sse.go). When iOS backgrounds the PWA the socket dies and every event in that window is lost forever. On foregroundwake()reconnects the stream (web/src/sse.ts) and the live dot goes green — but nothing refetches: theconnectedsignal only drives the dot. A run that finished while the phone was locked stays "working" on screen until some future event happens to touch it; for an ended run that can be never.wake()declines to force-cycle (sse.tsSTALE_MS gate), so a socket iOS already killed can sit "green" for up to ~65s after resume until the watchdog fires — and then still no refetch (see 1).Not in scope: changes made directly on the Forgejo UI produce no lab event at all (only mutations through lab/labctl publish). That is a separate feature (webhooks/poller) and explicitly out of scope here. No server changes in this issue.
Design (resolved with operator)
Resync on reconnect:
sse.tsdispatches a client-synthesizedresyncpseudo-event on everyonopenexcept the first of the app session. It never comes over the wire — do not add it toEVENT_TYPES/ the EventSource listener loop; type it asLabEventType | 'resync'on the subscribe surface.Always-fresh wake: on
visibilitychange→visible(pluswindowpageshowandfocuslisteners as iOS belt-and-braces, removed inclose()), force-cycle the stream viafail(source, true)unless an event (heartbeats count) arrived within the last ~10s (FRESH_MS = 10_000— replaces the STALE_MS comparison inwake()only; the 65s silent-death watchdog stays as backstop). The 10s guard is the anti-flap: a fresh open setslastEventAt, so rapid app-switcher peeks don't thrash. Net effect: returning to the app always yields a fresh stream + resync burst (~6 GETs — rate limiting is login-only, negligible for a single operator).createLiveResourcewrapper (newweb/src/lib/liveResource.ts): ownscreateResource+ event subscriptions + resync so future views cannot forget resync. Signature mirrorscreateResource(same return tuple, so call sites keep[data, { refetch }]), plus specs:Behavior: matching event → (debounced) refetch;
resync→ immediate unconditional refetch that cancels any pending debounce; all subscriptions and timers cleaned up viaonCleanup. UsesuseEvents()internally (must be called in a component root). Centralize thesolid/reactivityeslint escape the raw handlers currently carry —matchpredicates may read params/props fresh per event.Migration (hybrid, all in one PR)
Move every plain resource+subscribe site to the wrapper:
components/AppShell.tsx(ShellFrame instances):run.changedunscoped;run.messages.changedunscoped withdebounceMs: MESSAGES_DEBOUNCE_MS.routes/Repos.tsx: repos ←repo.changed; instances ←run.changed; RepoCard CRs ←cr.changedscopedevent.repoID === props.repoID.routes/Credentials.tsx:repo.changed→ repos;run.changed→ instances.routes/NewRun.tsx:repo.changed→ repos;claude.auth.changed→ auth.components/ClaudeAuthCard.tsx:claude.auth.changed.routes/History.tsx:run.changedunscoped.routes/RepoIssues.tsx: three resources (issues, ready, labels) each onissue.changedscoped toparams.id; the labels one additionally gated on builtin binding in itsmatch.routes/IssueDetail.tsx,routes/RepoLabels.tsx: same pattern as above.routes/RepoCRs.tsx,routes/CRDetail.tsx:cr.changedscoped.routes/RepoSettings.tsx:repo.changedscoped.components/ParkedSection.tsx:parked.changedscoped.components/AFKStrip.tsx: one resource, specs =issue.changed(scoped) +run.changed(unscoped) +parked.changed(scoped).Deliberately bespoke (keep raw subscriptions, add an explicit
subscribe('resync', …)line and a comment pointing at the wrapper):routes/RunChat.tsxstays raw wholesale — the message accumulator is not a resource (cursor/seq-merge + fetch tokens, ADR-0016). Addresync→void refetchRun(); void refetchMessages();alongside the existingrun.changed/run.messages.changedhandlers. Do not restructure the accumulator or the transcript-rotation logic.stores/cloneProgress.tsuntouched — data-bearing events, never fetches. A stale progress entry after an outage is covered because Repos refetchesrepo.changedstate on resync.Tests (units only — agreed scope; no e2e, no new UI)
sse.test.ts: resync fires on 2nd open, not 1st; visible-wake force-cycles when lastEventAt > FRESH_MS even while nominally connected; no cycle under FRESH_MS;pageshow/focustrigger wake;close()removes the new listeners; watchdog behavior unchanged.lib/liveResource.test.ts: refetch on matching event; skip on non-matching; debounce coalesces; resync refetches unconditionally and cancels a pending debounce; dispose unsubscribes and clears timers.Acceptance
npm test, lint, andnpm run buildgreen inweb/. No Go changes.Land-PR audit for PR #59 (
afk/54→main, head553ff8f)Verdict: PASS
Checked:
ci / native (pull_request)is green on the head SHA (6m10s) — covers web/ vitest, eslint, prettier, and vite build, exactly the touched surface.ci-nixcorrectly did not run: the diff touches no*.nix/go.mod/go.sum, and per ADR-0023 web-only diffs cannot rot the nix build. Nothing was re-run.main(verified viagit merge-tree), single commit.afk/54, workingCloses #54).sse.tsresync-on-reconnect (skips first open, not inEVENT_TYPES, no backoff reset) and FRESH_MS=10s always-fresh wake withpageshow/focuslisteners removed inclose()— matches the resolved design verbatim, 65s watchdog retained.createLiveResourceimplements both arities with tuple passthrough (incl.mutate), per-spec debounce timers, and unconditional resync refetch that cancels pending debounces; all teardown viaonCleanup. All 15 migration sites preserve their original scoping predicates (incl. ProviderAuthCard's defensive no-provider match, RepoIssues' builtin-gated labels resource, AppShell's messages debounce). RunChat stays raw with hand-wired resync;cloneProgress.tsuntouched — both as the issue prescribes. One benign divergence: the issue'sClaudeAuthCard/claude.auth.changedreferences predate the provider-seam generalization (#53); the PR correctly targets the renamedProviderAuthCard/provider.auth.changed.No blockers found. Awaiting explicit human confirmation to merge.