Skip to content

watch: the rail loads from memory, then checks in the background for tokens bought since - #825

Merged
nategeier merged 1 commit into
mainfrom
feat/watchlist-background-holdings
Sep 21, 2026
Merged

nategeier merged 1 commit into
mainfrom
feat/watchlist-background-holdings

Conversation

@nategeier

Copy link
Copy Markdown
Member

Nate, with a screenshot of the /markets rail: "since the watchlist was loaded first time, I have since bought more tokens, can we load in the background to see if the user has more tokens they purchase, first load from memory, but background check for more tokens owned".

Both halves, plus a bug the first drive turned up: the rail never showed its own autofill.

Memory first

Every holdings read that answers is remembered in this browser (lib/watchlists writeHeldSnapshot, key pantessa.wallet.held.v1, per wallet, newest 4 wallets, dropped after a day). On the next load the rail paints each row's position from that memory while its own read is in flight, instead of popping the column in seconds later.

Memory paints; only a live read arms. Every Sell chip goes through lib/sell-gatelib/use-heldpeekHeld, and none of them read the snapshot — nothing is ever offered on a memory of a token that was since sold. The card door (walletEmpty) waits for the live read too.

The background check

The rail already re-read holdings once a minute on a visible tab, but only to reprice. The autofill ran once per mount, so a token bought after the page loaded waited for the next navigation.

Now the poll runs the same reconcile as the visit, gated by one rule (heldReconcileReason):

the read finds what happens
the first read for this wallet reconcile (the visit's autofill)
a symbol the last reconcile never saw reconcile — the token bought since
nothing new reprice only. No write, however many tabs poll
a holding gone from the wallet nothing — the ledger only learns what is held
a landed card purchase (recheckWallet) forced, past both caches

A removed row still stays off: the ledger is checked before anything is added, on both paths.

FOUND on main: the rail didn't show what the autofill added

Driving the first cut, the guest rail said "Nothing watched yet." for the whole visit while localStorage held ["ETH","AAPL"] and the Morning tape beside it read "2 SYMBOLS" off the same fill. Reproduced on origin/main (control build, same drive) — it is not new here.

Cause: two useWatchlists() instances mount on /markets (the rail and the tape). They share the module-level reconcile claim, so whichever won the race did the fill and kept the new list in its own React state; the other one showed the list as it was at load.

Fixed two ways:

  • One holdings reader per page — the tape takes useWatchlists({ holdings: false }): it keeps the lists without reading the wallet or reconciling a second time.
  • A fill is announced to the other instance (scoped to whose lists it is, never back to the announcer, so nobody toasts twice).

Gates

  • tsc --noEmit clean, npm run build clean
  • test:api3123 passed / 2 failed — the standing known-red (router select) and main's stale arc (live) WETH pin (Arc's WETH/USDC has liquidity now; A quote is not a price unless the pool can absorb it #820's territory, not this branch's). 8 new checks, and two re-pinned on purpose: the poll pin said the visible-tab clock "never reconciles", and the card door's forced re-read was pinned to a deleted timestamp.
  • Playwright + Chrome, a mock EIP-6963 wallet, holdings served through a route intercept:
    • guest 9/9 — first visit fills and is remembered; a cold load with a 6s-slow read paints all three positions from memory; a token added to the wallet mid-visit joins the list with no reload, with its position and the note, on one extra read; a row removed by hand stays off.
    • account 6/6 (real SIWE, throwaway wallet) — the rail shows the fill, the tape beside it counts the same list, a stock bought mid-visit syncs with one POST, and a quiet minute writes nothing.
    • dark 1440 + light 375 with the read hung: positions painted from memory, 0 horizontal overflow, no hydration errors.

No DDL, no env, no new dependency. The API route is unchanged.

Calls for you

  1. A day of memory. A snapshot older than 24h is dropped rather than painted. Shorter (an hour) if stale positions in a dead tab bother you — the live read replaces them within seconds either way.
  2. The check is the minute poll, not a push. A buy made in /chat shows on the rail within ~60s (plus the server's 45s wallet cache). Telling the rail directly when a transaction lands is a bigger wire — say the word and it's a follow-up.
  3. A tab return doesn't bypass the server cache. It re-reads on the same window rather than asking fresh=1, so a tab-flipper can't amplify reads onto six chains. Worst case is ~45s staler than a forced read.

Files

lib/watchlists.ts (the memory + heldReconcileReason, both pure) · lib/held-read.ts (one write site) · components/markets/watchlist/useWatchlists.ts (one shared reconcile, the poll calls it, the fill announcement, holdings: false) · components/markets/ai/MorningTape.tsx (one line) · scripts/test-api.ts.

🤖 Generated with Claude Code

…tokens bought since

Nate, on the /markets rail: "since the watchlist was loaded first time, I have
since bought more tokens… first load from memory, but background check for more
tokens owned".

Memory: every holdings read that answers is remembered per wallet in this
browser (lib/watchlists writeHeldSnapshot, one write site in lib/held-read), so
a cold load paints each row's position with the lists instead of popping it in a
read later. Memory paints; only a live read arms — peekHeld, lib/use-held and
every Sell chip stay live-only, and so does the card door.

Background check: the rail's minute poll now runs the same reconcile as the
visit, gated by heldReconcileReason — a first read for the wallet, a symbol the
last reconcile never saw, or a landed purchase asking. A read with nothing new
reprices and writes nothing. The autofill used to run once per mount, so a token
bought after the page loaded waited for the next navigation.

Found on the way, and present on main: a page runs useWatchlists() twice (the
rail and the Morning tape), they share the module-level reconcile claim, and
whichever won the race kept the filled list to itself — the rail said "Nothing
watched yet." for the whole visit while the tape beside it read "2 SYMBOLS" off
the same fill. The tape now takes { holdings: false } (one holdings reader per
page) and a fill is announced to the other instance, scoped to whose lists it
is and never back to the announcer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website Ready Ready Preview Sep 18, 2026 4:39pm UTC

Request Review

@nategeier
nategeier merged commit 7550247 into main Sep 21, 2026
2 checks passed
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