Skip to content

Upstream batch E: inbox navigation, live thread caches, deferred chunks - #36

Merged
theRealBithive merged 9 commits into
mainfrom
feat/upstream-batch-e
Sep 16, 2026
Merged

theRealBithive merged 9 commits into
mainfrom
feat/upstream-batch-e

Conversation

@theRealBithive

@theRealBithive theRealBithive commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Batch E of the upstream back-merge, and the last of the five. Two upstream commits were in scope; one is picked, one is skipped.

What is different after deploying

Switching conversations no longer takes the page down to a spinner. The inbox route had loaderDeps naming the selection and every filter, so picking a row re-ran the loader and the whole outlet went to the pending state while it did. The loader now runs once, for the server render, and the list and the thread arrive on their own.

A thread warmed by hovering its row is no longer a snapshot. Before this, a live frame for any conversation other than the selected one was dropped. A row you hovered and opened a minute later showed what the server said at hover time — new messages, read receipts, reactions and deletions in between were simply not there, and nothing refetched because the cache was fresh. Every cached thread now follows the stream, whether or not it is the one on screen. Rows nobody has looked at still get no cache.

Three ticket frames now reach the browser at all. ticket_message, ticket_updated and ticket_read were being published and dropped — EventSource discards a named frame with no registered listener, silently. A ticket's inbox row and its cached thread went stale with no symptom.

A connection that comes up after a failed attempt now catches up. The reconnect refetch only fired for a second successful open. An initial connection that failed once and succeeded on the retry counted as a first connect, so the gap it left was never caught up.

The admin ships less on first load. The post modal, the workflow builder and the TipTap editor are code-split behind their own chunks, each with a skeleton holding its place. Four @fontsource/inter static weights are gone (the variable font already covers them), and avatars and content images are lazy.

Nothing changes for a visitor. No migration.

The two decisions

344bcaf62 (QuackbackIO#554, "update all dependencies to latest") is skipped. It is one commit holding 1,447 lockfile lines and two major bumps — the shape Renovate produces one pull request per package for, and Renovate is now actually configured to do that (v0.13.3-exkulpa.32). Taking it would move the whole tree at once with no per-package review and no way to revert one bump. Recorded in UPSTREAM.md with that reason.

f8062929a (QuackbackIO#553) is picked, with three conflicts resolved.

  • content-html.ts — upstream dropped our aspect-ratio: auto keyword while adding the lazy-loading attributes. The two are independent, so this keeps both. Without auto, a pasted screenshot is pinned to the editor extension's 500×500 default for good.
  • admin.tsx — upstream parallelises the workspace-role import with a billing lock-out helper and redirects an over-quota workspace to /admin/billing. Billing is excluded from this fork, and shouldLockAdminToBillingFn does not exist here. The hunk is dropped; a test now holds that no string in that route can name a billing destination.
  • inbox.tsx — the loader rewrite against our own divergence.

The contract

E1–E11 were confirmed before any test was written. E12 and E13 were added during the work and confirmed the same way: the hover-prefetch debounce and the code-split boundaries are both new non-trivial logic that none of E1–E11 reached, and deriving a property for them from the code they were about would have proved nothing.

E1 Switching conversations never takes the surrounding page down to a spinner.
E2 A prefetched or previously-opened thread stays in step with the live stream.
E3 A prefetch nobody is waiting on is abandoned; a fetch the open pane waits on never is.
E4 A response already in flight never wins over the event that raced it.
E5 No cache is created for a conversation nobody has looked at.
E6 Every frame kind the inbox relies on is listened for.
E7 A clean first connection does not catch up; one that follows a failure does.
E8 A priority or SLA change reorders the queues that sort by them. Held one step weaker than it reads: agentEventChangesInboxList answers on the event's kind and never looks at which field moved, so the test pins "a conversation change reorders". The predicate is untouched by this pick.
E9 One normalizer, read by the page and the loader alike.
E10 A pasted screenshot keeps its proportions; an image below the fold is not fetched.
E11 The admin area never redirects to a billing page.
E12 A thread is warmed only when the pointer rests on its row.
E13 The heavy admin chunks are not in the first load, and a skeleton holds their place.

Nine suites, 97 tests. Two of them are properties rather than examples: the reconcile state machine as one law over the six situations a row can be in, and the stream's catch-up as two conservation laws plus one boundary.

Gates

  • Diff coverage: 85 of 85 added lines executed. Nothing named, nothing excused.
  • Mutation: 1 file, 28 mutants, 100.00%, 0 survived, 0 recorded as equivalent. The first run scored 82.14% with five survivors; all five were real holes and all five are now caught — the detail is in c76d2d58f.
  • typecheck (both projects), lint, i18n: clean.

reconcileCachedThread is the one file added to the mutation manifest. The other thirteen the change touches are named by the gate and left ungraded on purpose: use-conversation-stream.ts and content-html.ts are large files whose behaviour these suites reach only in part, inbox.tsx is a route, and the components are mostly Tailwind strings — the same call batch C made. An entry asserts the listed suites pin the whole file, and none of these would.

What is left open

  • The hover-prefetch delay (120 ms) and the launch-checklist poll (30 s) are tuning constants. The suites exercise them and say in their own words that the number is not the promise.
  • packages/widget is untouched.

🤖 Generated with Claude Code

mortondev and others added 9 commits September 16, 2026 09:02
QuackbackIO#553)

* perf(inbox): stop blocking navigation on selection, defer heavy chunks, entity-patch SSE rows

Remove loaderDeps from the inbox route so conversation switches and
filter changes are served by client queries instead of blocking the
outlet behind the pending spinner; thread pane shows a skeleton while
loading, with hover prefetch and longer thread cache lifetimes.

Defer TipTap out of the admin eager graph (lazy PostModal, dialog
editors), lazy-load the workflow builder, and stream feedback/users
lists via fire-and-forget prefetch. Parallelize the admin auth/billing
guard, drop duplicate Inter weights, and lazy-load avatars and
rendered content images.

Spike a normalized conversation-entity upsert so conversation SSE
events patch thread/list rows directly, refetching only on
scope-membership changes.

* fix(inbox): invalidate lists when conversation priority changes over SSE

* fix(inbox): keep inactive thread caches live and fingerprint sla state

Apply message/read/reaction/delete events to prefetched or recently
visited thread caches so selecting them within staleTime cannot render
stale, and include SLA deadlines in the list-invalidation fingerprint
so sla-sorted queues reposition on SLA changes.

* refactor(inbox): drop the entity-cache oracle and keep switching instant

Conversation SSE goes back through the existing list-refresh predicate
instead of a membership fingerprint, and first-load prefetch is awaited
again so hydration is deterministic. Auth stays sequential after parallel
imports.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): keep hover-prefetched threads in sync with SSE

Selecting a prefetched row during its freshness window skipped refetch, so a
message that landed after hover but before click was missing. Patch any
cached thread with the same reducer, and cancel an in-flight prefetch so its
response cannot pin a snapshot that missed the event.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): do not cancel the open thread's in-flight fetch

Cancelling a hover prefetch is still right, but the same helper also ran for
the selected pane. An SSE event arriving before the first thread response
then left useQuery on a skeleton with nothing to restart it. Wait for a
mounted fetch and apply afterwards; only cancel observer-less prefetches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): reapply SSE patches after an in-flight thread refetch

A stale thread can be refetching when the event arrives. Patching the
current snapshot is not enough: the older response can land afterward,
overwrite the patch, and stay fresh. Apply again once that fetch settles.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): deliver ticket SSE frames to the stream hook

EventSource drops named events with no listener, so ticket_message /
ticket_updated / ticket_read never reached the inbox handler. Hover-prefetched
ticket caches then stayed fresh and stale until remount. Register those names
alongside the conversation frames.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): keep ticket_updated ahead of in-flight detail prefetches

Hover prefetch of ticketDetail can finish after the SSE DTO is written and
overwrite it; the 60s staleTime then serves stale status/assignee/priority.
Reapply the event DTO after that fetch settles.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): stale prefetched threads after an SSE reconnect

The stream recreates EventSource without Last-Event-ID replay. List/count
invalidation on reconnect left hover-prefetched thread and ticket-detail
caches fresh, so select could reuse a snapshot that missed the gap. Mark
those keys stale so the open pane refetches and a later select does too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): normalize loader search instead of casting location.search

The loader omits loaderDeps so conversation switches do not remount, which
also means it never receives validateSearch's result. Casting the raw URL
left ?q=123 as a number (.trim() throws), skipped ?c= SSR prefetch, and
forwarded rejected facets. Re-run the same normalizer, and coerce numeric q.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(inbox): treat a failed first SSE open as a reconnect

A hover prefetch during the retry window could stay fresh across events
the stream never delivered. onReconnect now also fires on the first
successful open after a failed attempt or mint miss. The lazy post-modal
skeleton can dismiss by clearing ?post=.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit f806292)
QuackbackIO#554 is skipped. It is one commit holding 1,447 lockfile lines and two
major bumps, which is the shape Renovate produces one pull request per
package for — and Renovate is now configured to actually do that
(`v0.13.3-exkulpa.32`). Taking the commit would move the whole tree at
once with no per-package review and no way to revert one bump.

QuackbackIO#553 is picked, with three conflicts resolved in the commit before this
one. Two are ours to keep: the lazy-image attributes join our
`aspect-ratio: auto`, and the admin route's billing lock-out is dropped
because this fork carries no billing gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pick moves live-event handling off the open thread and onto every
thread cache that exists, splits the heavy admin chunks out of the first
load, and takes the inbox loader off its dependencies. None of that is
visible in a screenshot, and all of it is visible to someone whose
prefetched thread is quietly a minute behind.

Nine suites against the confirmed list:

  reconcile-cached-thread.contract  E2 E3 E4 E5 — the state machine as one
    law over the six situations a row can be in: the cache ends up holding
    the event or holding nothing, never the response it raced.
  use-conversation-stream.contract  E6 E7 — every declared frame kind has a
    listener (read off the event union, which is the only oracle for "a
    kind was added"), and the catch-up fires once per gap.
  inbox.search-and-loader.contract  E1 E8 E9 — a junk facet is as absent as
    an absent one, and the loader, which no longer receives the validated
    search, really does re-read it through the same normalizer.
  inbox.stream-caches.contract      E2 E5 E7 — the call site: a message
    lands in a thread nobody has selected, and creates nothing for a thread
    nobody has looked at.
  conversation-list-prefetch.contract  E12 — resting warms, sweeping does
    not, and nothing is left running after the list goes away.
  deferred-chunks.contract          E13 — each chunk held behind a gate the
    test opens, so the waiting state is somewhere to stand rather than a
    race to win.
  content-html-images.contract      E10 — every image the renderer can emit
    is lazy, and a stored box is reserved without pinning the screenshot to
    it.
  admin.no-billing-gate.contract    E11 — the guard lets a team member
    through, and no string in the route can name a billing destination.
  admin-sidebar (extended)          the launch poll's cadence, which carries
    no contract number and says so.

E12 and E13 were added to the list and confirmed before these were
written: the hover debounce and the code-split boundaries are both new
non-trivial logic that E1–E11 did not reach.

Diff coverage: 85 of 85 added lines executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module the pick adds is the only one of the fourteen it touches that
is a state machine, so it is the one that goes into the mutation
manifest. The first run scored 82.14%: five mutants survived, and every
one of them was a real hole rather than an equivalence.

  `find({ queryKey, exact: true })` → `exact: false`, and → `{}`
      Both make the module patch a cache it was not asked about. Caught
      by a neighbour cached under a longer key: with either mutant a
      cache appears at a key nobody opened, which is E5.
  `if (query.state.fetchStatus !== 'fetching') return` → `if (false)`
      A settled thread is then put back on the network for every event.
      Caught by counting the query function's calls.
  `cancelQueries({ queryKey, exact: true })` → `exact: false`, and → `{}`
      Abandoning one hover prefetch then abandons the neighbour's, or
      the whole application's in-flight work. Caught by leaving two
      other fetches in flight and asserting they survive.

28 of 28 mutants caught, 100.00%, nothing recorded as equivalent.

The type fixes alongside are the typechecker's reading of the new
suites: `JSX.Element` is not a namespace here, a spy's call tuple needs
its element typed, and a conversation's SLA deadline lives on `sla`,
not on a `slaDueAt` field that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five entries, three of them re-hits:

  12x  the parallel-load flakes, with the shard loop's own contribution:
       piping each shard through `tail -25` keeps the summary and throws
       away the FAIL lines above it, so the list of what to re-run had to
       be reconstructed from a truncated log.
  4x   mounting a real route, now with the cheap way in — mock
       `createFileRoute` to hand its options back and render
       `Route.options.component` — and the trap that cost two rounds: a
       dynamic `import()` of a route module inside a test spends the
       test's own 20s timeout on the module graph.
  3x   the hand-typed TypeID, generalised past `.rejects.toThrow()`: where
       an id is parsed before the behaviour happens, a fabricated id turns
       every negative assertion into a tautology. Three of five hover
       tests passed that way.
  1x   a composite query key hides both halves of a key assertion — the
       negative one silently.
  1x   a negated character class in a source-text rule matches newlines,
       so an absent-word check spans the whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pick puts the rich text editor behind `lazy()` in both compose dialogs, so
the editor arrives a microtask after the render rather than with it. Two suites
reached for it synchronously. Only one test went red — CI's shard 4 on the new
conversation dialog — because the second test in a file finds the chunk already
resolved by the first, which is also why the feedback dialog's suite stayed
green and failed the moment either test ran on its own.

Both now await the element (`findBy*`). The div carrying the paste and drop
handlers renders outside the Suspense boundary and is unaffected, so C1-C5 and
F1 assert exactly what they asserted before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`warmedKeyText(warmed)).toContain(id)` passed whether or not the loader warmed
the thread: the list key packs every facet into one pipe-delimited segment and
carries the open item's id too, so the id was found either way. E9's positive
direction was reading the list key back.

The assertion now names `conversationKeys.agentThread(id)` and a second test
holds the other side — no thread key for a URL that names no open item, none for
an id the normalizer rejects, and exactly one more warmed key once a real id is
there. Checked by disabling the loader's thread branch: both go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two entries from the batch E CI failure: a suite that mounts a newly lazy
component passes in file order and fails alone, and `gh run view --log` returns
a job's log without the summary line that says which test failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The row is filled in the pick's own pull request, which is the convention here
and, since `main` takes no direct push, also the only place it can go: the tag
is cut immediately after this merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@theRealBithive
theRealBithive merged commit 5432d0e into main Sep 16, 2026
11 checks passed
@theRealBithive
theRealBithive deleted the feat/upstream-batch-e branch September 16, 2026 09:03
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.

3 participants