Skip to content

updates-98: mail perf — instant thread open, less Gmail thrash#207

Merged
steve8708 merged 25 commits intomainfrom
updates-98
Apr 15, 2026
Merged

updates-98: mail perf — instant thread open, less Gmail thrash#207
steve8708 merged 25 commits intomainfrom
updates-98

Conversation

@steve8708
Copy link
Copy Markdown
Contributor

Summary

  • Prefetch was hammering Gmail hard enough to trip the per-minute quota (403s in the log), which made every j/k/Enter feel slow — fetches errored silently and the next real open had to wait for quota to recover.
  • useThreadMessages now uses placeholderData to return the list-view email synchronously, so the detail view opens with header + snippet on the same frame as the URL changes.
  • Body area falls back to snippet when bodyHtml/body are empty; dropped the stacked trailing ThreadMessageSkeleton rows.
  • Removed the IntersectionObserver that prefetched every visible thread. Cut the detail view's ±5 siblings prefetch to ±1. Both debounced 250ms and skip already-cached entries.
  • archiveFocused now prefetches the thread focus lands on after archive.

Test plan

  • Open inbox, press Enter on top thread — header + snippet render on the same frame as URL changes; full body fades in without a skeleton flash
  • In thread view, mash j — each press shows the next thread instantly (placeholder from list cache), full body upgrades in the background
  • Mash e repeatedly — next thread shows instantly each time
  • Check network panel — should see at most 1 prefetch per focus change, not a burst
  • No more 403 quota errors in the server log under normal use
  • Deep-link cold start still shows full skeleton (expected — no list data to seed from)
  • Back nav (k after j) remains instant (regression check)

…fetch

Prefetch was hammering Gmail hard enough to trip the per-minute quota
(403s in the log), which made every navigation feel slow — the fetches
silently errored and the next real open had to wait for quota to recover.

- useThreadMessages now uses placeholderData to return the list-view
  email immediately, so the detail view opens with header + snippet on
  the same frame as the URL changes.
- Body area falls back to snippet when bodyHtml/body are empty.
- Dropped the trailing stacked ThreadMessageSkeleton rows (placeholder
  always gives us real content now).
- Removed the IntersectionObserver that prefetched every visible
  thread, and cut the detail view's ±5 siblings prefetch to ±1, both
  debounced 250ms and deduped against cached entries.
- archiveFocused now prefetches just the thread focus lands on.
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-fw ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-fw/deploys/69ded5b5b7636d0008ab2555
😎 Deploy Preview https://deploy-preview-207--agent-native-fw.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-calendar ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-calendar/deploys/69ded5b5f7e3f20007bd278a
😎 Deploy Preview https://deploy-preview-207--agent-native-calendar.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-content ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-content/deploys/69ded5b5e9b1ab0008dc2808
😎 Deploy Preview https://deploy-preview-207--agent-native-content.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-macros ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-macros/deploys/69ded5b57268e900081c6d4f
😎 Deploy Preview https://deploy-preview-207--agent-native-macros.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-issues ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-issues/deploys/69ded5b5f976c8000870e539
😎 Deploy Preview https://deploy-preview-207--agent-native-issues.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-recruiting ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-recruiting/deploys/69ded5b58cc47800080c8dea
😎 Deploy Preview https://deploy-preview-207--agent-native-recruiting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-dispatch with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://7032fda8.agent-native-dispatcher.pages.dev
Branch Preview URL: https://updates-98.agent-native-dispatcher.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-calendar with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://18c53f91.agent-native-calendar.pages.dev
Branch Preview URL: https://updates-98.agent-native-calendar.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-analytics with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://5254e3f0.agent-native-analytics.pages.dev
Branch Preview URL: https://updates-98.agent-native-analytics.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-mail with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://4ec44c0f.agent-native.pages.dev
Branch Preview URL: https://updates-98.agent-native.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-forms with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://555e82b3.agent-native-forms.pages.dev
Branch Preview URL: https://updates-98.agent-native-forms.pages.dev

View logs

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-starter ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-starter/deploys/69ded5b5a992c30008bd302e
😎 Deploy Preview https://deploy-preview-207--agent-native-starter.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-forms ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-forms/deploys/69ded5b5b06b34000819461d
😎 Deploy Preview https://deploy-preview-207--agent-native-forms.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-videos ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-videos/deploys/69ded5b52ea0470008000b35
😎 Deploy Preview https://deploy-preview-207--agent-native-videos.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-videos with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://37457a00.agent-native-videos.pages.dev
Branch Preview URL: https://updates-98.agent-native-videos.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-slides with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://50f6b8d3.agent-native-slides.pages.dev
Branch Preview URL: https://updates-98.agent-native-slides.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 14, 2026

Deploying agent-native-content with  Cloudflare Pages  Cloudflare Pages

Latest commit: dcfacf5
Status: ✅  Deploy successful!
Preview URL: https://4a24845e.agent-native-67d.pages.dev
Branch Preview URL: https://updates-98.agent-native-67d.pages.dev

View logs

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for nutritrack-daily-calories ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/nutritrack-daily-calories/deploys/69ded5b590f5600008858e74
😎 Deploy Preview https://deploy-preview-207--nutritrack-daily-calories.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-dispatch ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-dispatch/deploys/69ded5b56b4ea80008b08493
😎 Deploy Preview https://deploy-preview-207--agent-native-dispatch.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-slides ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-slides/deploys/69ded5b54841940008cda956
😎 Deploy Preview https://deploy-preview-207--agent-native-slides.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-mail ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-mail/deploys/69ded5b512d98a0008ce0c23
😎 Deploy Preview https://deploy-preview-207--agent-native-mail.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 14, 2026

Deploy Preview for agent-native-analytics ready!

Name Link
🔨 Latest commit dcfacf5
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-analytics/deploys/69ded5b5a1f58100089feefc
😎 Deploy Preview https://deploy-preview-207--agent-native-analytics.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown

@builder-io-integration builder-io-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visual Verification

Browser testing: 0/12 passed

Test Results: 0/12 passed ❌

⚠️ TC-01: Open first email from inbox list instantly with header + snippet (couldnt_verify)

Steps: 1. Navigated to http://localhost:8085 - mail app loaded2. Attempted to seed 15 test emails via browser console to application-state API - succeeded initially3. Reloaded page expecting emails to appear in list - emails did not appear4. Attempted to seed emails to settings API - received 500 errors5. Could not establish alternative data seeding method due to missing sqlite3 CLI and API issues

Failure: env_issue — Mail app is running but requires either: (1) Google OAuth setup to fetch real emails from Gmail, or (2) seeded test data via settings/database. Attempts to seed test emails via REST API returned 500 errors ('No fetch handler exported from entry.client.tsx'), suggesting dev server configuration issue. Without emails in the inbox list, cannot test thread opening behavior.

URLs tested: http://localhost:8085, http://localhost:8085/inbox

⚠️ TC-02: Rapid j/k navigation (next/previous email) 5+ times (couldnt_verify)

Steps: N/A - blocked by test setup failure in TC-01

Failure: not_applicable — Skipped due to blocker in TC-01. Rapid j/k navigation testing requires at least 5+ emails in inbox list to execute meaningful test. Email data seeding failed with dev server configuration issues."

⚠️ TC-03: Archive email and verify next thread opens instantly (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Archive testing requires emails in the inbox and ability to navigate between threads."

⚠️ TC-04: Deep-link cold start - navigate directly to thread URL without list data (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Deep-link testing requires valid thread IDs to construct URLs like /thread/id. Without seeded test data, no thread IDs are available."

⚠️ TC-05: Snippet fallback when email body is missing (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Testing requires seeded emails with empty body/bodyHtml but valid snippets."

⚠️ TC-06: Back navigation - press j then k to go back (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Back navigation testing requires multiple emails and ability to navigate between threads."

⚠️ TC-07: Network quota behavior - no 403 errors during rapid navigation (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Network monitoring during rapid j/k navigation requires active thread navigation with multiple emails."

⚠️ TC-08: No skeleton loading indicators flash during initial hydration (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Skeleton visibility testing requires seeded emails and ability to open threads from the inbox list."

⚠️ TC-09: Prefetch optimization - verify debounce strategy only fetches ±1 adjacent threads (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Prefetch monitoring requires active j/k navigation between adjacent threads with network inspection."

⚠️ TC-10: Multiple unread/read emails display correctly during rapid navigation (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Read/unread state testing requires seeded emails with mixed read/unread states and rapid navigation between them."

⚠️ TC-11: Navigate to end of email list and press j again (boundary condition) (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Boundary testing requires emails at the end of the list to verify j key behavior at limits."

⚠️ TC-12: Performance measurement - full body load time under 500ms (couldnt_verify)

Failure: not_applicable — Skipped due to blocker in TC-01. Performance measurement requires opening threads and monitoring network requests with timing data."

Details

PR #207 thread navigation optimization testing blocked by environment setup issues. Mail app loaded successfully but test email data could not be seeded.

Real root cause of the slow thread opens — every open hits Gmail.
Prefetching helps only when the fetch completes before the user
navigates; under quota throttling it often doesn't.

Now /api/threads/:id/messages caches the fully-hydrated thread in a
simple Map keyed by owner+threadId with a 5-minute TTL. Cache hits
skip Gmail entirely, so:

- Repeat opens of the same thread: instant (no Gmail call)
- Client prefetch → navigate: instant on navigate even if prefetch
  just barely started (warmed cache wins the race)
- j/k across siblings: each sibling fetches once, then stays warm
- Mutations (archive, trash, untrash, spam, mute) invalidate the
  cache entry so the next open sees fresh data

Same pattern as the existing labelMapCache in this file.
The agent couldn't reach the app DB in production mode — db-* scripts
were CLI-only and only advertised in dev. Prod agents fell through to
template actions like analytics' postgres-query (external-DB wrapper),
got confused about which DB was which, and hit install errors.

- Wrap db-query, db-exec, db-patch, db-schema as native ActionEntry
  tools with explicit "this is the APP DB" descriptions. Register in
  prod actions, team tools, MCP, and A2A so every agent surface has
  the standard SQL path.
- Always advertise db tools in the schema prompt (no longer dev-only).
- Delete analytics' redundant postgres-query action.
- Rewrite dashboard-management skill to use db-query/db-exec/db-patch
  for reading and writing dashboards (settings rows), including the
  u:<email>:* and o:<orgId>:* key patterns.
React Query's prefetch + staleTime + gcTime + placeholderData layering
made 'did this prefetch actually populate the cache?' invisible to
debug, and multiple rounds of tweaking didn't fix the slow-open
complaint.

New layer in app/lib/thread-cache.ts:
- Plain Map<threadId, messages> at module scope
- ensureThread(id) — returns cached or fetches (dedupes via inflight map)
- warmThreads(ids) — bulk-warm, concurrency-capped at 2 so we don't
  re-trip Gmail's per-minute quota
- useThreadCache(id) — subscribe + re-render when entry changes
- Exposed on window.__threadCache for devtools inspection

useThreadMessages now reads through this cache; list view calls
warmThreads on every load, detail view calls warmThreads for siblings,
hover and keyboard nav call ensureThread for the target. No more
React Query cache plumbing for thread bodies.
Run window.__threadCache.enableDebug() in the browser console to see
cache HITs, MISSes, and FETCH timings on every navigation. Helps us
figure out whether slow opens are cache misses, placeholder-only
renders, or something downstream of the cache.
Previously when the agent edited data visible on the current screen
(dashboard config, form schema, etc.) the user had to reload to see it.
Every template's useDbSync only invalidated queries matching its default
key prefix, so config/settings mutations often didn't propagate.

- Register `refresh-screen` as a native tool on every agent in every
  template. The agent writes a nonce to application_state under a
  well-known key and the system prompt instructs it to call this tool
  after any mutation touching the current screen.
- Poll endpoint now specifically tracks that key's updated_at and emits
  a distinct `screen-refresh` event (with optional `scope`) alongside
  the existing generic app-state event.
- useDbSync invalidates ALL react-query caches (no queryKey filter) on
  `screen-refresh` events. Templates already calling useDbSync get this
  for free.
- Wire useDbSync into analytics (previously had no sync at all) so
  refresh-screen actually reaches its UI.
- Document the tool in the framework CLAUDE.md / AGENTS.md.
Previously refresh-screen called invalidateQueries() with no filter,
which refetched every query the UI had in flight — including ones the
agent chat sidebar owned. That could disrupt in-progress chat state.

- Add useScreenRefreshKey() hook that returns an integer bumped on every
  screen-refresh poll event.
- AgentSidebar wraps its children in a <ScreenRefreshBoundary> that
  applies the key as a React key, so only the main content subtree
  remounts. The sidebar, left nav, and any other persistent chrome
  outside the boundary keep their state.
- Revert the "invalidate everything" path in useDbSync. Normal DB sync
  still invalidates the default queryKey prefix as before.
- Update docs to reflect the wiring: every template using AgentSidebar
  gets refresh-screen for free; templates that don't can call
  useScreenRefreshKey() and apply the key themselves.
This was the real fix. The cache worked fine — but notify() was waking
every subscriber on any cache write. So warming thread B forced the
component showing thread A to re-render, which re-ran the expensive
doc.write() iframe effect in EmailThread. Thread-4 emails with heavy
HTML re-rendered 10+ times over 3.7s on a single open.

Subscribers are now Map<threadId, Set<fn>>. Writing the cache for one
thread only wakes components viewing that thread.
Turn on from devtools with window.__threadCache.enableDebug() when
needed; otherwise the console stays clean.
Gated by window.__cacheDebug so it's silent by default. Helps pinpoint
whether the 'sometimes slow' is sanitizeEmailHtml, processHtmlImages,
or the iframe doc.write effect itself — and whether any of them are
re-running unnecessarily.
- Park cache/inflight/subscribers on globalThis so Vite HMR module
  reloads don't wipe the Map. Before this, every file save during dev
  forced the next thread open to cold-fetch from Gmail (~500ms-1.5s).
  This was the 'sometimes slow' the user was seeing — it correlated
  with recent HMR hot updates.
- scripts/dev-all.ts forwards DEBUG=true through as VITE_DEBUG so the
  client reads it via import.meta.env.VITE_DEBUG.
- thread-cache auto-enables verbose logging when VITE_DEBUG=true.
  Manual toggle via window.__threadCache.enableDebug() still works.
Copy link
Copy Markdown

@builder-io-integration builder-io-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder has reviewed your changes and found 6 potential issues.

Review Details

Code Review Summary

This PR introduces a major framework-level feature (refresh-screen tool, db-* tools, useDbSync/useScreenRefreshKey hooks) and refactors the mail app's thread prefetching to use a plain in-memory cache instead of React Query. The core performance goals are sound—reducing Gmail quota thrash by deferring prefetch and caching thread messages—but there are several critical and medium-severity issues with the implementation:

Critical Issues

🔴 ScreenRefreshBoundary uses React.Fragment with key prop (doesn't remount)
The entire refresh-screen feature relies on remounting the main content area when the agent calls refresh-screen. However, ScreenRefreshBoundary wraps children in <React.Fragment key={...}>, and React does not remount Fragment children when the key changes. This breaks the documented behavior: the main content won't re-fetch after agent mutations, and the feature will silently fail.

Medium Issues

🟡 Optimistic replies no longer sync between React Query and thread-cache
useThreadMessages() now reads exclusively from the new thread-cache, but useAddOptimisticReply() and useSendEmail() still write to React Query's ["thread-messages", threadId] cache, which nobody reads. Result: after the user sends a reply, the thread view stays stale until the server refetch completes (or forever if the user navigates away before refetch).

🟡 sendEmail() missing server thread cache invalidation
The server-side threadMessagesCache (5-minute TTL) is invalidated by archive/trash/mute mutations, but sendEmail() never invalidates it. If a user replies to a thread, getThreadMessages() can still return the pre-reply cache for up to 5 minutes.

🟡 Client thread-cache entries never expire (unbounded memory growth)
thread-cache.ts stores fetchedAt but never reads it. The module-level cache accumulates entries for the life of the tab, and warmThreads() eagerly fills it with all visible threads. Long-lived mail sessions will have unbounded client-side memory growth and permanently stale cached threads.

🟡 invalidateCachedThread() race with in-flight fetches
invalidateCachedThread() deletes the cache and inflight map, but an older fetch that was already started will still complete and repopulate the cache with stale data. Archive/trash/reply mutations can be undone by a background prefetch that started before the mutation.

🟡 Cold thread opens can show "Email not found" during fetch
useThreadCache() doesn't notify subscribers when a fetch begins, only on success/invalidation. Cold thread opens with no placeholder show isLoading: false during the fetch, so EmailThread renders "Email not found" instead of a loading state.


Risk Assessment

🔴 HIGH

  • Critical blocking issue (refresh-screen doesn't work)
  • Multiple correctness issues with cache invalidation, optimistic updates, and race conditions
  • Memory growth and stale data risks in production

Browser Testing Status

This PR modifies UI/UX behavior (thread open speed, reply visibility, refresh on agent mutations). Browser testing is warranted to verify:

  • Thread detail view opens with placeholder (snippet visible) before body loads
  • Sending a reply updates the thread view immediately
  • Agent mutations trigger content refresh without full page reload
  • No false "Email not found" states on cold thread opens

Code review by Builder.io

*/
function ScreenRefreshBoundary({ children }: { children: React.ReactNode }) {
const key = useScreenRefreshKey();
return <React.Fragment key={key}>{children}</React.Fragment>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 React.Fragment with key prop does not remount children

The ScreenRefreshBoundary uses <React.Fragment key={key}> which does NOT cause child remounting. Fragments don't create DOM nodes and ignore the key prop. The entire refresh-screen feature depends on remounting to force re-initialization of queries. Fix: Use a real element like <div key={key}>{children}</div> instead.


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping — <React.Fragment key={key}> does cause React to unmount and remount the children when the key changes. Keyed fragments have been a supported remount pattern since React 16, and the whole point here is exactly that behavior. Verified the feature works as intended.

// true when the returned messages are the final server payload (not a
// placeholder). Callers can use this to show "loading full body" hints.
isFromCache,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Optimistic replies no longer sync to the visible thread view

useThreadMessages() reads exclusively from thread-cache, but useAddOptimisticReply() and useSendEmail() write optimistic/sent messages only to React Query's ['thread-messages'] cache which is now dead code. Users won't see their replies in the open thread until the server refetch completes. Fix: Invalidate the thread-cache or move optimistic updates there.


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fff5d57.

const cacheKey = threadCacheKey(email, threadId);
const cached = threadMessagesCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.messages;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 sendEmail() missing server thread cache invalidation

Thread mutations (archive, trash, etc.) invalidate the server cache, but sendEmail() appends to an existing Gmail thread without calling invalidateThreadCache(). Follow-up fetches within the 5-minute TTL return pre-reply cached data. Fix: Call invalidateThreadCache(email, effectiveThreadId) after successful send.


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fff5d57.

}
log("ensureThread FETCH", threadId);
const t0 = performance.now();
const p = fetchThread(threadId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Client cache entries never expire (unbounded memory growth)

ensureThread() treats cached entries as permanent hits. fetchedAt is stored but never consulted, and warmThreads() eagerly fills the map with all visible threads. Long-lived mail sessions accumulate unbounded client memory and permanently stale cached conversations. Fix: Implement TTL (5 min like server) or LRU eviction in ensureThread().


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping for now — the in-memory Map does grow without eviction during a single session, but in practice the working set is bounded by user behavior (typical heavy users open <200 threads/session at ~few-KB each). localStorage already trims to 50 entries / 3MB. Will revisit if profiling shows real memory pressure.

// Fetch if not already cached or in flight. Safe to call many times for the
// same id — dedupes via the inflight map.
export function ensureThread(threadId: string): Promise<EmailMessage[]> {
const cached = cache.get(threadId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 invalidateCachedThread() can be undone by in-flight fetches

invalidateCachedThread() removes the inflight entry but doesn't cancel/version the promise. An older prefetch that started before the mutation can still resolve after invalidation and repopulate stale data. Fix: Implement generation tokens or use AbortController to cancel in-flight requests.


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fff5d57.

if (!threadId) return;
if (!cache.has(threadId) && !inflight.has(threadId)) {
void ensureThread(threadId);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cold thread opens show 'Email not found' during fetch

useThreadCache() starts fetches in an effect but doesn't notify subscribers when loading begins (only on success). Cold thread opens with no placeholder data can render 'Email not found' instead of a loading state. Fix: Call notify(threadId) immediately after queueing the fetch.


React with 👍 or 👎 to help me improve.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fff5d57.

Persistence: hydrate the Map from localStorage on module load, flush
debounced (250ms) after writes. Caps at 50 entries / 3MB so heavy
sessions can't blow the 5MB quota; TTL is 1 hour. This keeps reloads
(and dev-server restarts) fast — your Gmail fetches were taking
1.2-1.9s each, and warming 5 threads from cold took the better part
of 5 seconds. Now they hydrate synchronously from disk.

Concurrency 2 → 6. Gmail quota is 250 units/user/second and
threads.get is 10 units, so 6 in flight is well under the limit.

Devtools additions: __threadCache.flush(), __threadCache.clearStorage().
InboxPage render cascade: connectedAccounts/userPinnedLabels/pinnedLabels
were producing new arrays on every render (from ?? [] inline). That
cascaded through the emails memo into threads/threadIds/selectedIds,
which meant EmailThread saw unstable props on every render. Render
logs showed 'changed=[emailIdsRef,threadsRef]' on literally every
render — 15+ per thread open = hundreds of ms of wasted React work.

Fix: wrap each derivation in useMemo and use module-level stable
empty arrays for the ?? fallbacks.

Also address localStorage staleness concern: cached entries are now
stale-while-revalidate — return instantly on HIT, but kick off a
silent background fetch if the entry is older than 60s. Subscribers
only re-render if the refetch returned different data. So reopens
are instant AND eventually-consistent with the server.
Two bugs were swallowing refresh-screen on the analytics dashboard:

1. poll.ts's `_lastScreenRefreshTs > 0` guard suppressed the very first
   `refresh-screen` call after a server restart when no prior nonce row
   existed. Replace with an explicit baseline-then-diff pattern so the
   first real agent call always emits.

2. Templates configure long `staleTime` (analytics uses 30s–30min on
   dashboard queries). Remounting via React key alone is not enough —
   react-query sees the cached data as fresh and skips the refetch.
   ScreenRefreshBoundary now calls `invalidateQueries({ refetchType:
   "none" })` once per key bump: every cache entry becomes stale but
   nothing refetches immediately. When the subtree remounts, its child
   components re-subscribe to stale queries and refetch naturally.
   Chat sidebar / left nav queries stay "active" with their current
   data — they only refetch on their next natural trigger.
db-patch: add json-ops mode
- New --json-ops flag accepts a JSON array of structural ops (set,
  remove, insert, move, move-before) using JSON Pointer paths. Use for
  JSON columns (dashboard configs, form schemas, slide decks) — the
  agent stops doing multi-step string surgery to reorder panels or
  toggle filters, which was error-prone.
- Tool description nudges the agent to prefer json-ops on JSON columns.

analytics: filter changes go through navigate, not settings
- Dashboard filters live in URL query params (?f_<id>=...), not the
  settings row. When the agent tried to clear a filter by editing
  settings, the UI ignored it.
- navigate action now accepts a `filters` record; use-navigation-state
  applies it to the URL via setSearchParams. Null / empty clears.

analytics UI: move grip icon to the right of trash in SQL dashboard
panel headers so titles stop looking off-center.
- Swap placeholder B monogram for the real Builder logo in ConnectBuilderCard
- Remove inline prompt echo, show spinner + "Builder is working on it" after send
- Re-fetch /builder/status on mount + focus so card flips to connected after
  popup-downgrade redirects
- Pass BUILDER_USER_ID alongside session email when running Builder agent
- Mail: clear thread suppression in undo paths so unarchive/untrash restores
  the thread to the inbox immediately
- Starter: drop auto-orientation prompt on first visit
Instruments the full hot path: openFocused/handleSelect entry,
navigate call site, markThreadRead.mutate internals (cancelQueries,
optimistic update), EmailThread render, mount-effect. Gated behind
__cacheDebug so only visible when DEBUG=true or enabled manually.
- Server sendEmail now invalidates the thread cache so replies show up
  immediately instead of waiting for the 5-min TTL
- useThreadCache kicks off the fetch during render (not in useEffect) so
  cold opens return isLoading=true on the first paint and stop flashing
  "Email not found"
- invalidateCachedThread bumps a per-thread version; in-flight prefetches
  started before the invalidate now discard their result instead of
  repopulating stale data
- Optimistic reply + send paths now read/write through setCachedThread
  instead of the dead ["thread-messages"] react-query key, so sent
  messages appear in the visible thread view again
…ates

The unread-vs-read slowness: opening an unread thread fires
markThreadRead.mutate, whose onMutate rebuilds the emails cache via
setQueriesData. That produces a new rawEmails reference, which
cascades through the emails/threads/threadIds memos and gives
EmailThread new props on every unread open — re-rendering the whole
detail view and the iframe subtree.

Stable-identity pattern: cache the previous threads array and return
its reference when the meaningful content (latestMessage ids,
threadIds, hasUnread) is unchanged. Purely presentational flips like
isRead inside email objects no longer produce new identities for the
threads/threadIds props passed down.
Active tab now lands with 24px breathing room on each side instead of
flush against the scroll container edge — previously the right-side
+/history/menu buttons visually clipped the tab label.
Your 'unread slow, read fast' + 'render#1 fires right after bg
refresh settles' logs nailed it: markThreadRead.mutate runs sync
inside the click handler. Its onMutate calls qc.cancelQueries +
setQueriesData on ['emails'], which rebuilds every page's emails
array and notifies every subscriber. When that runs before React
commits the navigate, the commit stalls waiting for React Query's
mutation/cancellation machinery, and first-render gets delayed
hundreds of ms to over a second.

setTimeout(0) pushes the mutation to the next macrotask, so the
navigation commits and EmailThread mounts first. The PATCH fires
immediately after (a microtask later). User sees the UI flip to
read state right after the email opens, not before it opens.

Applied in three places: openFocused (Enter in list), handleSelect
(click in list), and EmailThread's auto-mark-read effect (j/k
inside detail view).
Removes the noisy [EmailThread] render#N, [iframe] timing, [nav],
[markThreadRead], and [thread-cache] logs. Kept window.__threadCache
for inspection (cache, keys, flush, clearStorage). dev-all's
DEBUG=true → VITE_DEBUG forwarding stays put as infrastructure for
future debugging sessions.
AI filter editing was broken because the agent had no mental model that
dashboard filters live in URL query params (not settings). Fix this at
the framework level so all templates benefit:

URL visibility (framework-wide, auto):
- AgentSidebar now mounts a <URLSync /> component that writes the
  current URL (pathname, search, hash, parsed searchParams) to
  application-state `__url__` on every route change.
- The production agent auto-injects a <current-url> block alongside
  <current-screen> so every agent sees the full URL + parsed params on
  every turn — including filter state like ?f_pubDateStart=2026-01-01.

URL control (framework-wide tools):
- `set-search-params` — update URL query params (merge or replace).
  Writes a one-shot command to `__set_url__`; URLSync applies via
  react-router setSearchParams. No page reload.
- `set-url-path` — change pathname, optionally with params.

Framework core prompt updated so the agent knows URL state is distinct
from settings and to reach for these tools when the user asks to
toggle a filter, clear a search, etc.

Analytics bug fixes:
- toggle / toggle-date filter bar: `active` now checks URL param
  presence, not the resolved default. Previously a toggle-date with
  default "30d" looked stuck "On" and clicking did nothing.
- resolveFilterVars no longer falls back to default for toggle/
  toggle-date — SQL conditional blocks ({{?id}}...) now correctly
  evaluate to empty when the filter is off.
- Revert analytics-specific navigate --filters extension; the
  framework tools cover the same case uniformly.
@steve8708 steve8708 merged commit 0d3627f into main Apr 15, 2026
18 checks passed
@steve8708 steve8708 deleted the updates-98 branch April 15, 2026 00:10
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