Milestone reassignment shows stale membership in milestone-filtered issue lists until reload (client cache invalidation gap) #318
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#318
Loading…
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?
What's wrong
After removing a milestone from an issue and assigning a different one, opening the milestones page and clicking View issues for the new milestone navigates to the issue list filtered by that milestone — and the list shows stale membership (the issue appears under its old milestone grouping, or the newly-assigned issue is missing) until a full page reload fixes it.
Reproduction path
/:owner/:name/issues?milestone=B— the filtered list is wrong on first paint; a browser reload corrects it.Root cause analysis (code evidence — stale cache invalidation, two cooperating gaps)
The server is not the problem:
PatchIssueupdates the thread, callss.updateIndex(..., cardOf(th))(internal/issues/service.go:527—Card.Milestoneincluded,model.go:107), andmoveMilestoneadjusts the milestone counters. The issues-list endpoint iswriteJSONwithCache-Control: no-store(internal/issues/http.go:416-424,:149). The stale read is client-side, in theuseDatapromise-cache, via two gaps:Gap 1 — milestone-PATCH invalidation never reaches the other pages' caches. The
issueSSE frame maps to invalidation keys["issue:{full}:{num}", "issues:{full}:*"](web/src/lib/collab.js:43) — theissues:{full}:*prefix WOULD cover both stale surfaces: the milestones page'sMilestoneIssuesentry (keyissues:{full}:milestone:{id},Milestones.jsx:25) and the issues list window (keyissues:{full}:{JSON(query)},Issues.jsx:41). ButuseCollabStreamsubscribers are per-page and the issue page filters its frames:useCollabStream(..., ["issue","issue_event"], (frame) => Number(frame.num) === Number(num()))(Issue.jsx:364) — theacceptcallback is applied beforeinvalidateCollab(collab.jsx:26-30), which is fine for same-issue frames, but the mutation's own invalidation therefore only runs in the tab that receives the frame. Navigating client-side (Solid router, no reload) carries the olduseDataLRU entries over: the milestones page rendersMilestoneIssuesfrom a pre-mutation cached window, and the filtered issues list on the new route can hit a cached window keyed identically to a pre-mutation query.Gap 2 — the milestones page doesn't subscribe to
issueframes at all.Milestones.jsxhas nouseCollabStream— so if the milestones page is open (or revisited client-side within the TTL/LRU horizon), itsMilestoneIssueslists and counts never invalidate on issue mutations. The file header says "Refetches after every save" — but only milestone saves invalidate; issue-side membership changes (PATCH milestone on an issue) don't touch this page's caches.Why a reload fixes it: a full reload drops the in-memory
useDataLRU and re-fetches throughno-storeendpoints — correct data. That matches the reported symptom exactly.Fix direction
issues:{full}:*(all list windows) plusmilestones:{full}(the milestones page counts). The mutation knows what it touched; don't rely on the SSE round-trip through a page that may unmount mid-navigation. Precedent:landedVisibleinImport.jsxinvalidates cross-page keys after its own mutation.issueframes (useCollabStream(full, repoClient, ["issue"])— noacceptfilter) so itsMilestoneIssueswindows and counts self-heal while mounted, matchingIssues.jsx:52.accept-before-invalidate pattern: filtering frames on the issue page means frames for other issues' mutations don't invalidate shared list caches in that tab. The filter should gate refetching the thread, not cache invalidation — or the invalidation forissues:{full}:*should run regardless offrame.num. This is the systemic half; verify againstinvalidateCollaband keep the narrow fix minimal.Acceptance criteria
issues:{full}:*andmilestones:{full}at the mutation site (not dependent on SSE delivery to a still-mounted page).accept-filter on the issue page's stream no longer suppresses shared-cache invalidation (or an equivalent narrow fix is documented).web/test/unit/.Fix ready for review: #321 (branch
fix/issue-318). Mutation-site invalidation (invalidateIssueLists) on all 8 issue-page mutation tails, milestones page subscribes toissueframes,issueframe map gainsmilestones:{full}, issue-page accept filter dropped. Tests green (14/14 targeted; full suite green except pre-existingsmoke.test.jsenvironmental hang, identical on clean main); vite build passes; browser check open (loopback blocked). Not merged — awaiting review.Review of PR #321 (fix/issue-318), verified in scratch worktree /tmp/pr321 (since removed):
PASS — prefix scope (data.js:326-338):
issues:{full}:covers both stale surfaces — Issues.jsx:39 query windows AND Milestones.jsx:32milestone:{id}entries — plusmilestones:{full}counts. Per-repo scoping confirmed by test (other repo + thread keys untouched). No over-invalidation across repos/threads.PASS — all 8 mutation tails routed via afterMutation (Issue.jsx): comment, commentAndClose, close(reason), reaction menu, summary chips, generic patch (title/body/reopen), labels, milestone. No missed path: assignees are display-only (Issue.jsx:508, no mutation UI), events pagination is a read.
PASS — accept-filter drop safe: per-key self-scoping holds. This page caches exactly one
issue:{full}:{num}+ its events window; other nums' keys miss cache = silent no-ops. Own frames still invalidate; removal only ADDS shared-prefix invalidation. No page misses its own updates.PASS — no Milestones double-invalidate problem: pages never mount simultaneously (one route); Milestones SSE goes through scheduleInvalidate (coalesced per-tick set). Only redundancy is the issue page's own SSE echo refetching list keys afterMutation already hit — 1 extra coalesced batch per human-rate click, single-flighted. Bounded, acceptable.
PASS — P7/no polling: no new timers; Milestones reuses useCollabStream SSE; afterMutation is synchronous direct invalidate.
PASS — storm risk: 8 tails x prefix is human-rate (button clicks), each invalidates only the handful of cached keys under the repo prefix. SSE-burst coalescing untouched. No sequential store round trips added (client cache only).
PASS — no new deps (package.json untouched; imports are existing modules). Doc entry (08_ui_sdk.md) matches code, including the pull-pages follow-up note. Whitespace re-indent of the #311 bullet is churn but harmless.
VERIFY: node tests 592/592 pass (320+272 across two batches; full glob in one call exceeds the 180s runner window — batch 2 alone takes ~170s due to timer-based SDK tests, pre-existing). New issue-invalidation.test.js 4/4 + collab-lib pin pass. vite build + esbuild SDK bundle succeed (chunk-size warning pre-existing). No browser drive (node tests + reasoning, per review brief).
No fixes pushed — nothing to fix. MERGE RECOMMENDATION: ready to merge.
Fixed by PR #321 (review clean; prefix scope + all-tails + no-storm verified; 592/592), merged. Closing.