Upstream batch E: inbox navigation, live thread caches, deferred chunks - #36
Merged
Merged
Conversation
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>
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.
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
loaderDepsnaming 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_updatedandticket_readwere being published and dropped —EventSourcediscards 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/interstatic 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 inUPSTREAM.mdwith that reason.f8062929a(QuackbackIO#553) is picked, with three conflicts resolved.content-html.ts— upstream dropped ouraspect-ratio: autokeyword while adding the lazy-loading attributes. The two are independent, so this keeps both. Withoutauto, 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, andshouldLockAdminToBillingFndoes 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.
agentEventChangesInboxListanswers 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.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
c76d2d58f.reconcileCachedThreadis 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.tsandcontent-html.tsare large files whose behaviour these suites reach only in part,inbox.tsxis 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
packages/widgetis untouched.🤖 Generated with Claude Code