Skip to content

fix(web): stop cached queries from keeping closed views in memory - #9020

Merged
peterchinman merged 5 commits into
mainfrom
peter/closed-pane-leaks
Oct 28, 2026
Merged

peterchinman merged 5 commits into
mainfrom
peter/closed-pane-leaks

Conversation

@peterchinman

Copy link
Copy Markdown
Contributor

Why

On iOS, the native app sometimes reloads from scratch. Each reload is iOS killing WebKit's WebContent process for going over its memory limit (ActiveSoft 1536 MB on an iPhone 12 mini). In the last 7 days, the most-affected user had 42 kills, 24 of them within 10 minutes of the previous restart. In more than half of their kills, and of the next-most-affected user's, a channel was open.

On an iPhone 12 mini with a production account, opening the same 13 Home items twice grew WebContent from 699 MB to 1264 MB. After a forced GC, 7 closed channel panes and 14,309 detached elements were still in memory.

Heap snapshots traced most of those panes to TanStack Query. A cached query keeps its last observer's options. So a queryFn, select or initialData created inside a hook keeps that hook's scope alive after the view closes, and through the scope's query proxies and Solid owner it keeps the pane's DOM too. That lasts until the query is collected: 10 minutes by default, and never for the session-long queries.

Query ─ options.select (inline) ─ hook scope ─ query proxy ─ Solid owner ─ closed pane + DOM
Query ─ options.select (module function)                                    (pane collectable)

How it works

Each holder's cached callbacks move to module-scope option factories that take plain IDs and flags, as the "Query callback lifetimes" section of .agents/rules/web/data.md already asks. The calendar fix is separate: an unowned-memo leak in the FullCalendar wrapper.

With all five fixes, the same device and account retained 3 closed panes after about 24 opens, down from 7. Memory grew about 290–390 MB, down from 565 MB. The Home items differ between runs, so the MB figures vary by roughly ±100 MB, and the pane count is the more reliable measure.

What

  • lib/queries/soup/graphql/optimistic-done.ts: usePendingGraphqlSoupDone passes module-scope viewerIdFromInfo and createDoneSessionId as the ["auth","userInfo"] select and the doneSession initialData. Both queries live for the whole session, so the last channel or thread to mount the hook was retained indefinitely. This was the largest holder.
  • lib/queries/preview/preview.ts: itemPreviewQueryOptions copies id, type and messageId into a plain request object. ItemPreview passes its own props as the entity, so the cached REST queryFn retained the message that contained the mention chip.
  • MentionsMenu/hooks/useBookingLinkMention.ts (in every composer's mentions menu) and features/block-form/queries/booking-sources.ts: the scheduling settings and public-profile queries come from module-scope factories.
  • features/email-thread/queries/thread-operations.ts: the operations queryFn closes over the thread ID only and reads the previous result from the cache instead of hook state.
  • lib/fullcalendar-solid/FullCalendar.tsx: FullCalendar calls datesSet outside any Solid owner. The handler spread every root option to find it, which turned conditional prop getters into memos that were never disposed and kept closed calendar panes alive. It now reads only the datesSet prop.

Follow-ups

  • 3 closed panes are still retained after GC, and their holder isn't identified yet. Finding it needs a heap snapshot, which jetsams the phone, so it means a simulator build on an account with channel threads.
  • About 58 other hooks still define query callbacks inline, the same pattern. They should be converted and backed by an ast-grep rule, in a separate PR.

Why: FullCalendar fires datesSet from its own render, outside any Solid
owner. The handler spread every root option to find datesSet, which ran
each prop getter; conditional props compile to memos, so every date change
left memos that were never disposed. They stayed subscribed to calendar
settings and kept closed calendar panes alive.

What: read only the datesSet prop in the handler.
Why: useThreadOperations built its queryFn inside the hook, closing over the
thread view's threadId accessor and hook state. TanStack keeps the last
options on the cached query for gcTime (10 minutes) after the view closes, so
every opened email kept its whole pane, DOM included, alive that long.

What: build the options in a module-scope factory over the plain thread ID,
and read the previous result from the cache instead of hook state.
Why: usePendingGraphqlSoupDone defined its userInfo select and doneSession
initialData inline. Both queries live for the whole session and keep the
options of their last observer, so those callbacks retained the hook's scope
and, through its query proxies, the whole closed channel or thread that last
mounted the hook. On device, closed channel views stayed in memory after GC.

What: pass module-scope callbacks (viewerIdFromInfo, createDoneSessionId) so
the cached options no longer close over the hook.
Why: ItemPreview passes its own props as the preview entity, and the cached
REST preview queryFn closed over that object. Each closed channel message with
an entity preview stayed reachable through the query cache until gcTime.

What: itemPreviewQueryOptions copies the id, type, and message ID into a plain
request object before building the queryFn.
Why: The mentions menu (in every channel composer) and the form booking step
defined their scheduling settings and public profile queryFns inline. The cached
callbacks retained the hook scope, keeping closed channels and forms alive until
gcTime.

What: module-scope query option factories over the resolved scope, team, and
profile IDs.
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 602691b and 0897c7e.

Files selected for processing: 6
  • apps/web/src/features/block-form/queries/booking-sources.ts
  • apps/web/src/features/email-thread/queries/thread-operations.ts
  • apps/web/src/lib/core/component/LexicalMarkdown/component/menu/MentionsMenu/hooks/useBookingLinkMention.ts
  • apps/web/src/lib/fullcalendar-solid/FullCalendar.tsx
  • apps/web/src/lib/queries/preview/preview.ts
  • apps/web/src/lib/queries/soup/graphql/optimistic-done.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Summary

Summary by CodeRabbit

  • Improvements
    • Booking settings and profile information are handled consistently across personal and team booking links.
    • Thread operation status reflects pending activity more accurately, including when results are served from cache.
    • Calendar date changes continue to update date information while handling callbacks separately.
    • Item previews retain the relevant identifiers for channel and other item types.
    • GraphQL completion sessions use consistent initial data.

The changes add shared query-option factories for booking settings and public profiles. They move thread-operation query configuration into a factory and refresh the thread cache when pending operations are no longer present. The FullCalendar controller receives a separate dates callback accessor. Preview requests are normalized to include only the required fields. GraphQL query callbacks use module-scoped functions.

Priority: Normal

Merge Risk: Minimal · up to 0897c

No merge-blocking behavior change is established by the supplied evidence.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title uses conventional commit format with the fix(web): prefix, describes the main memory-retention change, and is 65 characters long.
Description check Passed The description clearly explains the memory issue, the affected query callbacks and FullCalendar handler, the implemented fixes, test results, and follow-up work.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

@peterchinman
peterchinman merged commit e90c565 into main Oct 28, 2026
30 checks passed
@peterchinman
peterchinman deleted the peter/closed-pane-leaks branch October 28, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant