Skip to content

Tell the user their run expired instead of showing a blank form - #36

Merged
ThinkingSpade merged 2 commits into
mainfrom
fix/restore-outcome-states
Aug 6, 2026
Merged

Tell the user their run expired instead of showing a blank form#36
ThinkingSpade merged 2 commits into
mainfrom
fix/restore-outcome-states

Conversation

@ThinkingSpade

Copy link
Copy Markdown
Owner

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

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

ThinkingSpade and others added 2 commits August 1, 2026 02:27
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
flyrocketseo 2ebe0aa Aug 05 2026, 08:33 AM

@ThinkingSpade
ThinkingSpade merged commit 2bd5a96 into main Aug 6, 2026
3 checks passed
@ThinkingSpade
ThinkingSpade deleted the fix/restore-outcome-states branch August 6, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant