fix(control-panel): read the Experiment back through one refresh after every write (SPL-268) - #261
Merged
Merged
Conversation
…r every write (SPL-268) The Experiment detail is served by a React Query cache, so `router.invalidate()` alone re-runs the loaders and leaves the pre-write row on screen. The measurement and metadata forms used only that half, so a saved Conversion Window or Metric selection stayed stale in the cache that the Run draft dialog and the draft wizard's measurement step read from. This is the same gap SPL-264 diagnosed for Run start, which `useExperimentDetailRefresh` already exists to close. Both forms now route through that one hook instead of standing up a second read-back mechanism beside it, and a source invariant keeps them there: it names every caller of `updateControlPanelExperiment` and `stageAndStartControlPanelExperimentRun` and fails if one skips the hook or reaches for `router.invalidate()` on its own. The Metrics page had the mirrored contradiction — it spliced the write's own response into local state, which is exactly the local patching that `use-experiment-detail-refresh` cites ADR-0023 to forbid. It now re-reads. `router.invalidate()` is the whole read-back there because Metrics are plain route-loader data with no query cache in front of them, so the local mirror and its resync effect are gone. Issue: SPL-268 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. To trigger a review, include ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
… identifier (SPL-268)
The guard's caller predicate required `name({`, so it only saw call sites that
inline their object argument. Hoisting the payload to a `const` because it
outgrew the line width -- the ordinary thing to do -- took the file out of the
guard entirely, as did an aliased import, a wrapper module, or a space before
the paren. Match the bare identifier over comment-stripped source instead, and
require the refresh to be bound and awaited rather than merely named, so the
test stands on its own instead of leaning on the pinned file list.
Issue: SPL-268
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
The Experiment detail is served by a React Query cache (
experimentDetailQuery, subscribed inlib/experiment-detail-route.ts:16).router.invalidate()re-runs the route loaders but does not touch that cache, so a surface that uses only it shows the operator the state before their own write — the disguised default ADR-0036 forbids.useExperimentDetailRefreshexists precisely to clear both, and its own doc comment says so.SPL-264 is what that gap costs when a rendered field reads from the stale cache: a started Run was invisible in Run history until a reload. That one was fixed by #248. This PR closes the two remaining surfaces with the same gap, plus the mirrored contradiction on the Metrics page.
Changes
experiment-measurement-form.tsxandexperiment-metadata-form.tsxroute their post-write read-back throughuseExperimentDetailRefreshinstead ofrouter.invalidate()alone. No third mechanism added — the point is that one already existed and these two bypassed it.lib/experiment-detail-refresh-invariant.test.ts: it names every caller ofupdateControlPanelExperiment/stageAndStartControlPanelExperimentRunand fails if one skips the hook or reaches forrouter.invalidate()on its own. Same shape as the existingapp-scoped-link-invariant.test.ts. Because the bug is latent, this invariant is the entire regression story, so review round 1 hardened it — see Review round: the guard was defeatable below.metrics-page.tsxre-reads instead of patching locally. It had been splicing the write's own response intovisibleMetrics— exactly the local patchinguse-experiment-detail-refreshcites ADR-0023 to forbid.router.invalidate()is the whole read-back there because Metrics are plain route-loader data with no query cache in front of them. The local mirror, its resyncuseEffect, and the now-deadupsertMetric/removeMetricexports (and their unit test) are gone.Risk: LOW
reread(metrics-page.tsx:28-30) re-runs the whole route loader, which isloadScopedSessionandloadControlPanelMetrics— two server calls per Metric write, not one, and a deleted row lingers on screen for that round trip instead of vanishing on click. Still the right tradeoff: the alternative is showing the operator a row the Panel never read back.Test plan
RED → GREEN on the new invariant
RED, with only the two form files reverted to
dfc2c902and the new test in place:GREEN, with the fix applied:
That reverting the fix is exactly what flips it is the mutation proof: the test fails without the change and passes with it, and it names the offending files rather than asserting a generality.
Review round: the guard was defeatable
Round 1 was right that the caller predicate was too loose to carry the proof. It required
${fn}({, so it only saw call sites that inline their object argument. It now matches the bare identifier over comment-stripped source, and test 2 requires the refresh to be bound and awaited rather than merely named.Each escape shape was run through both predicates.
INVISIBLEmeans the file was not even collected as a caller, so all three tests passed while the surface did no read-back:name({const input = {…}; updateControlPanelExperiment(input)Tests 2 failed | 1 passed (3)import { updateControlPanelExperiment as save }updateControlPanelExperiment ({…})Test 2 got its own two probes, both of which the old test 2 passed:
probe-imports-only.tsx— namesuseExperimentDetailRefreshin an import it never calls.probe-bound-unawaited.tsx— bindsconst refresh = useExperimentDetailRefresh(…)and callsrefresh()without awaiting.Both now fail on routes every one of them through an awaited useExperimentDetailRefresh.
Two disclosures rather than quiet narrowing:
components/experiment-draft-run-step.tsxnamesstageAndStartControlPanelExperimentRunin a doc comment saying it does not call one directly, and correctly delegates touseExperimentRunStart. Resolved with acode()helper that strips comments before matching, not by trimming the table or adding an exception list. It is deliberately conservative — a trailing// nameafter code survives, which is a false red, never a false green.The guard's header comment now carries the reviewer's note on why "latent" is not "unobservable": the router builds a bare
new QueryClient()(router.tsx:8), so a newly mounteduseSuspenseQuerysubscriber renders the cached value first and refetches behind it, and any component seedinguseStatefrom that first render keeps the stale value permanently. A client-side<Link>between the setup tab and the draft wizard, or any read-only render of one of these fields, makes it assertable.Why there is no rendered e2e assertion for these two forms
Stated plainly because it matters for how much this PR proves. Neither form's staleness is observable in the rendered Panel today, so an e2e assertion for it would pass with and without the fix, which is worthless.
conversionWindowMs,description,owner, andtagsare read by no mounted surface other than the forms that own them, and those seed local state on mount. Every path that remounts them currently performs a fresh read for an unrelated reason: Run start refreshes (#248), a reload refetches, and a client-side nav remounts theuseSuspenseQuerysubscriber, which refetches atstaleTime: 0. The one reachable window — save, then open a surface that reads the cached detail while the subscriber stays mounted — renders stale data only until the background refetch lands, which is a race, not an assertion.So this is a latent correctness gap, closed at the level where it is actually decidable: the source invariant.
experiments-setup.spec.tsremains the rendered half of the proof for the field that is re-read (Run history), and it stays green.Runs
Control Panel e2e — fleet lock held,
acquireMachineLock("local-e2e-fleet"),--workers=1,SPLITCH_PLATFORM_TARGET=pr-ci, workspace deps built first:metrics.spec.tsis a real regression guard on the re-read: it creates, renames, and deletes Metrics and asserts each row appears and disappears with no reload, which now happens through the route loader rather than a local splice.No e2e re-run for the review round: it changed a test predicate only, no runtime code.
One plain
pnpm verify:ci, noTURBO_FORCE, on the review-round tree, exit code 0:The six cache misses are exactly the tasks this diff can affect:
//:format:check,//:knip, and@splitch/control-panel:lint,:typecheck,:build,:test— allcache miss, executing. No task belonging to the modified package replayed. (The earlier run on the original tree was85 successful / 76 cached, exit 0, with the same control-panel tasks executing.)Issue: SPL-268
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.