Tell the user their run expired instead of showing a blank form - #36
Merged
Conversation
Four places where a query that FAILED, or never ran at all, was rendered as a
confident statement about the world. Found by a Codex audit of the two surfaces
reported as "fields not filled out, no error"; each is verified against the
source rather than taken on trust.
The rule applied throughout is the one `resolveQueryState` already encodes:
failure outranks emptiness, because a failed query has no rows *because it
failed*. These call sites simply predated it.
1. LINK GAP -- the worst of them (CompetitorsTabBody.tsx)
`rows={linkGapQuery.data?.rows ?? []}` fed a table whose zero-row message is:
"No link gap found -- every domain linking to this competitor also links
to you."
That is a strong, flattering claim about the user's backlink profile, and a
comparison they never paid for could make it. Same shape for Keyword Gap's
"No keywords found for this comparison".
2. COMPETITOR DISCOVERY (CompetitorsTabBody.tsx / CompetitorsPage.tsx)
`competitorsQuery.data?.rows ?? restored?.result.rows ?? []` collapsed
failure, never-run and genuine-zero into one empty array, which the table
reported as:
"No competitors found. Try a domain with more organic visibility."
-- a claim about the user's site when in fact we never got an answer. Codex
identified the trigger: hard-refreshing a URL containing `target`.
Authorization is mount-session state so it resets to false, while
auto-restore is only enabled when `target` is EMPTY, so neither the live
query nor the stored run fires.
The caller now passes what those rows MEAN (`isError` / `isFetching` /
`hasResult`), because the array alone cannot say. A restored past run counts
as a real answer.
3. KEYWORD GAP TOTALS (KeywordGapOverview.tsx)
Only the active mode is fetched -- correct, the other two are separately
metered and must not auto-run. But a disabled react-query stays `isPending`
forever, so the other two cards sat on loading dots indefinitely, reading as
"still working" rather than "not run". Dots now mean `isFetching`; otherwise
the card says "Select to run".
4. PAGE EXPLORER BACKLINKS (PageExplorerService.ts, page-explorer.ts,
PageExplorerResults.tsx)
The backlink lookup is a separate best-effort subcall: on failure it is
caught, logged, `backlinks` stays null, and the parent result still
succeeds. Right call -- a Backlinks API hiccup should not sink the keyword
view -- but a FAILED PAID SUBCALL and a page that genuinely has no backlink
data both rendered as two dashes with nothing to tell them apart. This is
the most likely thing behind the original "Backlinks and Ref. domains show
dashes" report.
Adds `backlinksStatus: "available" | "no-data" | "error"`. The
`.default("no-data")` is load-bearing: a required field would fail
`safeParse` against every already-cached payload, and auto-restore drops a
failed parse SILENTLY, so old runs would quietly stop restoring.
DELIBERATELY NOT "FIXED"
Several blanks are correct under the no-auto-spend rule and only needed honest
wording, never a fetch: Keyword Gap's inactive modes, Link Gap not auto-running
from URL state, and the restored Page Explorer snapshot. Nothing here starts a
metered request that did not start before -- every change is presentational or
adds a status field.
STILL OUTSTANDING from the same audit
- `useAutoRestoredRun` callers ignore its `isError`, and a schema-mismatch
parse failure returns null with no error state at all, so a run you performed
can vanish silently.
- `PageExplorerPage.tsx:129` -- `snapshotQuery.data ?? null`, `isError` never
read, so a failed on-page fetch removes the whole card.
- `RecentRunsList.tsx:31` -- `query.data ?? []`, and selection failures are
swallowed by `.catch(() => undefined)`.
VERIFICATION
pnpm ci:check clean; 2,129 tests passing across 224 files; tsc clean. Not yet
exercised in a browser against live failing queries -- the states are reachable
by construction but the wording has not been seen on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #34 and #35, closing the rest of the restore-layer findings. THE PROBLEM `hydrate` returned `RestoredRun | null`, and that single `null` meant three completely different things: * there is no past run for this tab * there IS a run and its stored result is gone * there is a run and its result no longer matches this tab's schema Callers rendered all three as the tab's ordinary "you have never run this" empty state. So the middle case -- by far the most common, because run payloads lived under a prefix Cloudflare deletes after 7 days (see #34) -- looked exactly like a tab you had never opened. That is the same error-as-absence antipattern this codebase eliminated in its query layer, still living in the restore layer. THE CHANGE `hydrate` now returns a discriminated `RestoreOutcome`: { status: "none" } { status: "expired", label, lastRanAt } { status: "ready", run } The row itself is the proof: if a row exists but the payload does not, that is an EXPIRY, not an absence, and it can be said out loud. `useAutoRestoredRun` surfaces this as `outcome` plus an `expired` bundle, and Domain Overview's empty state now explains itself rather than pretending the tab is fresh. `unreadable` (schema mismatch) is reported too. It was the most dangerous of the three: a schema change would have silently stopped every stored run from restoring, with nothing anywhere to indicate it. ALSO FIXED IN RecentRunsList - `query.data ?? []` hid the whole history section on a failed fetch, so a transport error read as "you have only run this once". Now offers a retry. - Selecting a run swallowed failures with `.catch(() => undefined)`, leaving a button that appeared to do nothing when clicked. Now marks the row. - Its existing `restored == null` expiry check would have SILENTLY BROKEN under the new return type -- `null` is no longer reachable, so the check would always be false and an expired run would look successful. Updated to test `status`. Flagging it because tsc could not catch it: the comparison stays legal, it just stops being true. `useLastRunInput` reads the new shape as well; it only wants a prefill value and has nothing to say about expiry, so both non-ready cases stay null. VERIFICATION pnpm ci:check clean; 2,129 tests passing across 224 files; tsc clean. NOT DONE, and deliberately not rushed: - The Local SEO city picker. Diagnosed fully -- the server already accepts `locationCode` (default 2840, the whole United States) and the client never sends it, which is why the UI tells users to type a city into the business NAME field. The fix needs that code threaded into the query key AND the metered authorization key; get the second one wrong and changing city fires a paid lookup with no click. That is the one class of bug this project guards hardest against, so it wants a fresh session, not the tail of a long one. - D1 prune, so rows do not outlive the 90-day payload the way they outlived the 7-day one. - `content_brief`'s separate defect (a 2-day-old payload already missing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
flyrocketseo | 2ebe0aa | Aug 05 2026, 08:33 AM |
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.
Tell the user their run expired instead of showing a blank form
Follow-up to #34 and #35, closing the rest of the restore-layer findings.
THE PROBLEM
hydratereturnedRestoredRun | null, and that singlenullmeant threecompletely different things:
Callers rendered all three as the tab's ordinary "you have never run this"
empty state. So the middle case -- by far the most common, because run payloads
lived under a prefix Cloudflare deletes after 7 days (see #34) -- looked exactly
like a tab you had never opened. That is the same error-as-absence antipattern
this codebase eliminated in its query layer, still living in the restore layer.
THE CHANGE
hydratenow returns a discriminatedRestoreOutcome:The row itself is the proof: if a row exists but the payload does not, that is
an EXPIRY, not an absence, and it can be said out loud.
useAutoRestoredRunsurfaces this as
outcomeplus anexpiredbundle, and Domain Overview'sempty state now explains itself rather than pretending the tab is fresh.
unreadable(schema mismatch) is reported too. It was the most dangerous ofthe three: a schema change would have silently stopped every stored run from
restoring, with nothing anywhere to indicate it.
ALSO FIXED IN RecentRunsList
query.data ?? []hid the whole history section on a failed fetch, so atransport error read as "you have only run this once". Now offers a retry.
.catch(() => undefined), leaving abutton that appeared to do nothing when clicked. Now marks the row.
restored == nullexpiry check would have SILENTLY BROKEN underthe new return type --
nullis no longer reachable, so the check wouldalways be false and an expired run would look successful. Updated to test
status. Flagging it because tsc could not catch it: the comparison stayslegal, it just stops being true.
useLastRunInputreads the new shape as well; it only wants a prefill valueand has nothing to say about expiry, so both non-ready cases stay null.
VERIFICATION
pnpm ci:check clean; 2,129 tests passing across 224 files; tsc clean.
NOT DONE, and deliberately not rushed:
locationCode(default 2840, the whole United States) and the client neversends it, which is why the UI tells users to type a city into the business
NAME field. The fix needs that code threaded into the query key AND the
metered authorization key; get the second one wrong and changing city fires a
paid lookup with no click. That is the one class of bug this project guards
hardest against, so it wants a fresh session, not the tail of a long one.
7-day one.
content_brief's separate defect (a 2-day-old payload already missing).Co-Authored-By: Claude Opus 5 noreply@anthropic.com