Thread navigation/loading slows from unbounded metadata and eager large-history hydration
Supersedes openai/codex#21154. The original root cause was threads.title becoming the full first user message, but the local diagnostics now point to a broader performance problem in thread navigation and thread loading:
- unbounded thread metadata can bloat the SQLite thread-list/navigation path
- a SQLite-only trim is temporary because reconciliation can repopulate titles from JSONL history
thread/listcan still be huge whenfirst_user_messageis treated as a full prompt/history field instead of a bounded preview- opening a large thread can still be slow because the UI path eagerly hydrates/replays too much history
User impact
Codex Desktop becomes sluggish when switching chats and when opening/loading large threads. The visible UX impact is not just a database query delay: the app can appear stuck while the renderer parses, allocates, reconciles, and renders large thread payloads.
In local testing, trimming pathological titles helped chat switching a lot. Loading large threads was still slow afterward, which suggests there are at least two hot paths:
- list/navigation metadata payload size
- initial thread-open history hydration/rendering
Title bloat impact
I benchmarked an affected local SQLite DB backup against the same row set after shortening only pathological active titles. This isolates title length as the variable.
Active rows
<!-- linear:table-colwidths:133,133,133,133,133,133 -->
| DB | Active rows | Active title chars | Active first_user_message chars | Max title chars | Active titles > 120 |
| -- | -- | -- | -- | -- | -- |
| Bad backup | 134 | 14,610,549 | 14,614,033 | 675,773 | 94 |
| Same rows after title repair | 134 | 3,588 | 14,614,033 | 120 | 0 |
| Current repaired DB | 158 | 5,154 | 14,614,486 | 120 | 0 |
Thread-list style query
Query approximating the active thread navigation/list path that needs titles:
SELECT id, title, source, cwd, updated_at_ms
FROM threads
WHERE COALESCE(archived,0)=0
ORDER BY updated_at_ms DESC, id DESC
LIMIT 200;
Measured over 80 iterations:
<!-- linear:table-colwidths:133,133,133,133,133,133 -->
| DB | Rows | Result payload bytes | SQLite query median | SQLite query p95 | JSON encode median |
| -- | -- | -- | -- | -- | -- |
| Bad backup | 134 | 15,605,594 | 8.12 ms | 10.30 ms | 46.34 ms |
| Same rows after title repair | 134 | 25,074 | 0.15 ms | 0.19 ms | 0.14 ms |
| Current repaired DB | 158 | 34,343 | 0.17 ms | 0.19 ms | 0.17 ms |
Impact from title repair on the same rows:
- Result payload: 15.6 MB -> 25 KB, about 622x smaller.
- SQLite read median: 8.12 ms -> 0.15 ms, about 54x faster.
- JSON encode median: 46.34 ms -> 0.14 ms, about 331x faster.
This is before Electron IPC, UI reconciliation, rendering, and any extra chat-switching work.
Full list item including preview
If the list path also includes first_user_message, the duplicated title roughly doubles the heavy payload:
SELECT id, title, first_user_message, source, cwd, updated_at_ms
FROM threads
WHERE COALESCE(archived,0)=0
ORDER BY updated_at_ms DESC, id DESC
LIMIT 200;
<!-- linear:table-colwidths:133,133,133,133,133,133 -->
| DB | Rows | Result payload bytes | SQLite query median | SQLite query p95 | JSON encode median |
| -- | -- | -- | -- | -- | -- |
| Bad backup | 134 | 31,196,414 | 14.70 ms | 17.75 ms | 90.73 ms |
| Same rows after title repair | 134 | 15,615,894 | 7.12 ms | 8.55 ms | 46.22 ms |
| Current repaired DB | 158 | 15,626,192 | 7.08 ms | 8.50 ms | 45.40 ms |
So the title bug doubled the heavy list item payload: one copy in first_user_message, another copy in title.
Reconciliation can repopulate bad titles
SQLite is not the only source of truth. Codex can rebuild or reconcile thread metadata from rollout JSONL files and session_index.jsonl.
The local failure mode appears to be:
- the state extractor reads rollout events
- the first
EventMsg::UserMessagecan populate bothfirst_user_messageand fallbacktitle - later
ThreadNameUpdatedevents can overwrite the title - reconciliation/upsert writes the resulting metadata back into SQLite
That means a DB-only trim can be undone later if the underlying JSONL history has no later good ThreadNameUpdated event. In practice, the durable local repair needed both:
- trim
threads.titlein SQLite - append a later thread-name update to the affected JSONL histories so reconciliation keeps the bounded title
thread/list can still be too large after title repair
Bounding titles helps, but thread/list can still pay for full prompt-sized first_user_message values when those are mapped into Thread.preview.
One local active DB snapshot after repopulation showed:
active rows: 158
active title chars: 13,980,313
active first_user_message chars: 14,614,486
active titles >120 chars: 67
active first_user_message >120 chars: 105
active first_user_message >10k chars: 74
max first_user_message: 675,773 chars
This was not only RepoPrompt. Grouped by source:
vscode rows=86 first_user_message_chars=13,981,761 max=675,773 gt120=75 gt10k=54
exec rows=22 first_user_message_chars=627,261 max=44,430 gt120=22 gt10k=20
Direct app-server probe using /Applications/Codex.app/Contents/Resources/codex app-server:
thread/list archived=false modelProviders=[] useStateDbOnly=true limit=20 2.56s response=4,505,704 bytes
thread/list archived=false modelProviders=[] useStateDbOnly=true limit=100 7.95s response=14,628,806 bytes
thread/list archived=false modelProviders=[] useStateDbOnly=false limit=100 8.26s response=14,627,363 bytes
This suggests first_user_message should either be bounded as a preview field for list/read summaries, or the full field should not be selected/sent in thread/list.
Opening large threads is a separate hot path
Direct app-server timings show that metadata-only reads are fast, but full turn reads can be very expensive:
48.7 MB rollout, image-heavy:
thread/read includeTurns=false 62.8 ms response=850 bytes
thread/read includeTurns=true 11.65 s response=20,654,619 bytes
45.9 MB rollout, compaction-heavy:
thread/read includeTurns=false 27.8 ms response=875 bytes
thread/read includeTurns=true 3.39 s response=6,363,354 bytes
1.4 MB rollout with giant first user message/title/preview:
thread/read includeTurns=false 415.9 ms response=704,374 bytes
thread/read includeTurns=true 814.0 ms response=1,414,448 bytes
The large rollouts are not all prompt-import cases. Large contributors included image generation events, compaction payloads, function/tool outputs, MCP tool results, and exec output.
Separate CDP profiling inside Codex Desktop showed the UI layer can still be the bottleneck even when app-server calls are quick:
tested thread switch: about 13.4s to settle in the UI
direct app-server calls for comparable tested thread data: tens of ms
renderer heap spike during switch: >200 MB transient
long tasks during switch: repeated ~1.8-2.0s tasks
So the remaining thread-open UX issue appears to be renderer hydration/reconciliation/allocation, not only DB or app-server I/O.
UX requirement
The fix should keep the current user experience or improve it:
- switching to a thread should show usable content quickly
- older turns should continue loading automatically
- users should not have to click an extra "load older messages" control just to make opening a thread fast
- full history should remain available for scrollback, search, copy, and context inspection
- active assistant output and status should not be delayed behind background hydration
Suggested direction
I do not want to over-specify the implementation, but the fixes likely need to cover these areas:
- enforce a single bounded display-title invariant across all title producers and SQLite write boundaries
- make reconciliation preserve bounded titles instead of restoring the first-message fallback
- keep
thread/listpayloads bounded by treating previews as previews, not full prompt/history fields - make initial thread open staged/paged: metadata plus newest visible turns first, older history loaded automatically afterward
- avoid sending heavyweight replay payloads in the initial UI load when a placeholder/expand-on-demand representation would preserve the UX
- add performance regression tests or fixtures for large titles, large previews, and large rollout histories
20 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Additional local measurements point to two separate hot paths beyond the original
threads.titleduplication.1.
thread/listis still huge whenfirst_user_messageis full prompt textCurrent active DB state on the same machine:
This is not only RepoPrompt. Grouped by
source, activefirst_user_messagebytes are mostlyvscode, but there are alsoexecrows with long previews:Direct app-server probe using
/Applications/Codex.app/Contents/Resources/codex app-server:So bounding title helps thread switching, but
thread/liststill pays for full preview text because the state DB select includesthreads.first_user_message, and app-server maps it intoThread.preview.Suggested fix: treat
first_user_messageas a bounded preview field in list/read summaries, or stop selecting/sending the full field inthread/list. Preserve full user content in rollout/history; do not duplicate it into the list summary payload.2. Opening a thread can still be slow because
thread/read includeTurns=trueloads and serializes the full rollout historyDirect app-server timings:
The large rollouts are not all prompt-import cases. The largest active rollout contributors I saw include:
Suggested fixes:
thread/read includeTurns=truepath.thread/turns/listor a similar paginated endpoint for history hydration.In short: #21154/#21211 title bounding fixes one major multiplier, but there is also a preview/list payload issue and a full-history initial-load issue.
I put the local workaround and perf notes here: https://github.com/clairernovotny/codex-perf
It currently includes:
threads.list/threads.set_title)threads.readprefetch while native Codex thread navigation continuesThe Windows launcher also looks for the MSIX install shape under
C:\Program Files\WindowsApps\OpenAI.Codex_*\app.Additional diagnostics from another affected local profile. I am intentionally omitting local paths, thread ids, cwd values, and prompt contents.
Environment/context:
dbstatshowed almost all space was in thethreadstableBefore title repair:
title == first_user_message: 6,026titlechars: 497,767,675first_user_message: 5,656,533 bytesAfter applying only title capping/repair to 120 chars:
titlechars: 462,934first_user_message: 2,891,610 bytesPost-repair storage shape:
sum(length(title)): 462,934sum(length(first_user_message)): 497,772,414first_user_message: 927,895 charsstate_5.sqlitewas still about 506 MB, withthreadsabout 525,783,040 bytes, now mostly due to retainedfirst_user_messageFollow-up proof that the hot list path still carries the full first message as preview:
thread/listwithlimit,cursor,sortKey,archived: false, and local source kinds.codex app-serverthread/listmethod against a copied state DB and measured only response field names and string lengths, not contents.thread/listwithlimit=20after title repair, the response was 2,644,782 bytes.previewfield totaling 2,445,255 chars.previewlength in that response was 442,542 chars.length(api.preview) == length(threads.first_user_message)for 20/20 rows.first_user_messagelength for those rows: 2,445,255 chars.So title repair fixes the duplicated prompt-sized
titlefield, but thethread/list/sidebar response still appears to transport unboundedfirst_user_messageaspreview. Even if the UI visually truncates it, the hot list path is still paying to read, serialize, transfer, parse, and retain those large previews.Operational note: running the repair/compaction while a live Codex Desktop or CLI app-server is active is risky. In one attempt, the app-server respawned/held the DB during repair and the WAL temporarily grew to about 509 MB. The safe procedure was to run maintenance offline: fully quit/stop Codex app-server, run title repair, checkpoint/truncate WAL, then VACUUM.
This seems to support two separate fixes:
threads.title; generate/store bounded titles at write time and/or repair existing state.first_user_messageaspreviewfromthread/list/ recent-thread sidebar APIs. Either omit it from the hot list path, return a bounded preview, or store a separate capped preview column. Even with titles fixed, large first messages still dominate the table and full-list serialization cost.Adding another macOS repro. Codex 26.506.31421, one completed local thread with an 825MB rollout: opening/resuming caused
thread/resumeto take 252,892ms, then desktop droppedthread-stream-state-changedbecausepayloadBytes=405234701exceededmaxIpcFrameBytes=268435456. app-server RSS was ~4.5-5GB. This looks like eager full-thread hydration plus full-state IPC, not just thread-list/title bloat; the fix probably needs bounded resume/read plus paged turns/items and tail-only desktop opening.Follow-up / PR offer: I read the contribution docs and understand external PRs are invitation-only. If the team thinks this direction fits, I’d be happy to take a stab at an invited PR for the open-source app-server side.
Conceptually, the fix I’d propose is:
thread/read,thread/resume, and stream-state snapshots cannot produce 100MB+ payloadsThe desktop side still needs closed-code changes, but the app-server can provide the safe bounded contract and fail gracefully when a client asks for full hydration of an oversized rollout.
Additional Windows evidence from a much larger local session store:
Environment:
OpenAI.Codex_26.506.3741.0_x6426.506.31421Local session store summary:
The worst active thread in this profile:
Symptoms:
codex.exe app-serverprocess was observed around 1.5 GB private memory, and the renderer around 600 MB private memory while working with these large histories.The local observation matches this issue's root direction: initial thread open appears to eagerly read/parse/hydrate/render far too much of a huge
rollout-*.jsonlhistory. For a 5 GB JSONL, transparent filesystem compression or archiving can save disk space, but it does not solve the UI problem because the app still has to parse and render the giant logical history.Expected behavior for this scale:
compactedpayloads and tool outputs should have placeholder or expandable representations rather than being fully materialized immediately.Related storage issue:
I considered a local overlay workaround that points the thread at a slim active JSONL view and keeps the full rollout in an archive. It would make opening fast, but it would also hide old turns from the normal thread UI until restore, so it is not an acceptable substitute for an upstream lazy-loading/virtualized transcript fix.
Correction / clarification: the affected large threads do load on macOS Desktop for me. The client where I’m seeing the practical failure is mobile: the same very large threads appear to hang / never fully hydrate when opened there. Smaller threads load normally, so this still looks per-thread rather than a general account/app startup failure.
Environment / context:
Read-only local metadata from the Desktop session store for the affected threads:
function_call_outputrecord and multiple ~40-49 MBcompacted.replacement_historyrecords.compacted.replacement_historyrecords.So the stronger claim is not “Desktop cannot load these.” It’s: Desktop has very large, valid histories; mobile appears unable to hydrate/render those large threads even though smaller threads work. That still seems consistent with whole-thread hydration / rendering pressure, and a metadata-first or paginated/cursor-based thread read path would likely help this class of failure.
Happy to provide more sanitized metadata if useful, but I’m avoiding sharing raw rollout contents because they contain private conversation data.
I opened a separate issue with additional local evidence that seems related to this thread-list/history path: #24510. It includes state_5.sqlite active metadata size, the observed query/index shape, logs_2.sqlite TRACE/log evidence, long-running goal-session rollout stats, and a temporary mitigation script gist.
Adding another Windows reproduction with current Codex Desktop data. This is the same failure family as the issue title describes: the app becomes sluggish at the UI level, including typing in the composer and general navigation, while the underlying conversations are static local history.
Environment:
OpenAI.Codex26.608.1337.0Observed user impact:
Read-only local diagnostics from the affected profile:
Process snapshot while the UI was lagging:
Disk space was not the immediate bottleneck:
Important UX point:
The practical workaround should not be "archive old threads every time the app gets slow". Archiving can be a temporary escape hatch, but the product should keep thread-list and thread-open paths bounded even when many active conversations have large raw histories. In this reproduction, the direct suspect is not total disk pressure or one running model call; it is unbounded local display metadata (
title/first_user_message) being large enough to affect the renderer/list/navigation path.Expected behavior:
titleand preview-like fields should be capped before persistence and before renderer/list transport.thread/listshould not send full prompt/history-sizedfirst_user_messagevalues as preview payloads.I am intentionally not attaching raw DB rows or local thread IDs because they include private chat/project contents.
Severity escalation from the same Windows reproduction I added above.
After inspecting the local state more closely, this is not a case of the user simply having too many projects or too many manually maintained chats. The oversized active rows are overwhelmingly internal guardian/subagent rows:
The affected fields are also duplicated across display metadata:
So the local profile is not just large history. Internal guardian/subagent threads are entering the active thread metadata surface, and full history-like text is being persisted into title/preview-like fields.
This makes the workaround especially unreasonable for users:
User-impact note: this is severe enough that the affected user explicitly said they will switch paid usage to Claude next month if Codex Desktop cannot handle this class of issue. I am including that because this is not a small polish bug; it directly affects whether the desktop app is usable for long-running Codex workflows.
Requested product fix remains:
title,first_user_message, andpreviewat every write/reconciliation boundary.thread/listand renderer-facing thread metadata bounded regardless of raw transcript size.I hit a sharper version of this locally where thread metadata copied a very large first user message into SQLite and made Codex.app crash-prone.
The part I focused on is the metadata path, not the larger history hydration problem. The full transcript is already in the rollout, but
title,preview, andfirst_user_messagecan still end up carrying prompt-sized text intostate_5.sqlite. Once that happens, thread listing/discovery has to move a lot of text around before the user even opens the thread.I put together a small local patch that caps derived thread metadata text at 4096 UTF-8-safe characters while leaving the rollout transcript and model history untouched:
title,preview, andfirst_user_messageThreadMetadataPatchValidation passed locally:
just fmtjust test -p codex-statejust test -p codex-thread-storejust fix -p codex-statejust fix -p codex-thread-storeBranch is here in case it is useful for review or for an internal patch:
https://github.com/najibninaba/codex/tree/najibninaba/thread-metadata-cap
If this is aligned with the intended fix direction, I am happy to open a PR or adjust the patch to match the shape maintainers want.
Thread list metadata and replay payloads should be separate storage/read paths.
The list view needs bounded title/preview, timestamps, cwd/project key, model/provider, size, and source path. Full message bodies and large tool outputs can stay behind lazy hydration offsets until a thread opens. Reconciliation should preserve those preview bounds instead of repopulating unbounded first prompts.
---
_Generated with ax._
I hit a narrower version of the eager-history path described here: repeatedly opening an older local thread can spend time replaying/parsing the same rollout JSONL again within the same app-server process.
In the current local store path,
thread/readwithincludeTurns=trueeventually callsthread_store.read_thread(... include_history: true), andcodex-rs/thread-store/src/local/read_thread.rscallsRolloutRecorder::load_rollout_items(path)each time history is attached.thread/turns/listalso still reconstructs turns from loaded history. For larger local rollouts this means switching back to the same old thread can feel like it is loading from scratch each time.I put together a small patch branch here:
https://github.com/taoliuyohoho-rgb/codex/tree/codex/cache-local-rollout-history
What it does:
LocalThreadStoreThis is intentionally not a full fix for title/preview bloat or pathological multi-GB rollouts. It is just a small mitigation for repeated old-thread opens / repeated
thread/turns/listcalls against unchanged local rollout files in the same app-server process. A durable SQLite turn/item index or true paginated history backend would still be the more complete direction.Local validation on the branch:
cargo test -p codex-thread-store load_history_items_ --libcargo test -p codex-thread-storecargo clippy -p codex-thread-store --tests -- -D warningscargo test -p codex-app-server --test all suite::v2::thread_read::thread_read_can_include_turns -- --exactcargo test -p codex-app-server --test all suite::v2::thread_read::thread_turns_list_can_page_backward_and_forward -- --exactI tried opening this as a PR, but the repository currently limits PR creation to collaborators, so I am leaving the branch here in case it is useful for an internal patch or as a reference implementation.
Adding a Windows data point that strongly links Guardian auto-review rows to the unbounded thread metadata described here.
Environment:
state_5.sqlite; no rows or rollout files were changed.Active thread inventory:
sourcecontainingguardianGuardian metadata:
title,preview, andfirst_user_messagetitlecharacters in this profileRead-only timing:
SELECT *: about 2.93 s before Electron IPC or renderingDuring this diagnostic session, the Guardian row count increased from 1,710 to 1,712 across two additional escalated approval-review batches. That is temporal correlation rather than a controlled causality test, but it matches the rows'
source.subagent.other = guardianclassification.This also appears closely related to #32035, which reports internal Guardian subagent threads exposed with full prompt-like titles, and #25570, which confirms that AutoReview is currently serialized as
guardian_subagent.This profile suggests a particularly concentrated form of the issue:
first_user_message;titleandpreview;Potential product-side mitigations:
No prompt contents, local paths, task IDs, or account-specific data are included here.
Current Windows build still runs unbudgeted
tail_history; closing one affected window is a decisive A/BEnvironment:
Read-only inspection of the current production bundle still shows
loadRemainingConversationTurnsrepeatedly callingthread/turns/listfrom a tight continuation loop. Requests are tagged:but the loop has no delay, exponential backoff, page-count cap, time budget, byte budget, or resource-pressure gate between successful pages. “Background” is only a label here; it does not prevent the work from monopolizing the local backend.
Measured on one idle completed thread:
thread/turns/list: one request roughly every 9.2–10.5 secondsA/B control:
thread/turns/listloopNo repository, MCP, model workload, or system setting changed in that A/B. The affected thread was idle. This isolates the cost to automatic history completion/replay, not active agent work.
This behavior severely punishes exactly the long-running, tool-heavy workflows Codex is designed to support. Users should not have to close or abandon valuable threads to keep the Desktop responsive.
Required fix boundaries:
The latest Windows build still ships the same unsafe loader shape; this should not be considered fixed.
Follow-up: preserving long tasks without paying the full-history cost on every reopen
A long Codex task is valuable because it holds decisions, failed attempts, images, and project context. On affected Windows Desktop builds, that same history can become a liability: reopening a large legacy task can rebuild a very large JSONL file and request old history before the user asks for it, producing blank or slow task views, a busy app-server core, desktop stalls, repeated resume cost, extra memory pressure, and crashes.
Starting a new task reduces that pressure by giving up convenient continuity. I published an unofficial reversible bridge that tests a different tradeoff: preserve every message, open recent turns first, and load exact older pages only when the user scrolls upward.
For one explicitly selected task, CLM SHA-256 archives the complete original, builds a transactional SQLite Turn index with the user-installed backend, keeps the exact native checkpoint plus recent suffix at the canonical rollout path, and serves older pages through the existing history API. Restore reconstructs the full original layout and preserves records appended after activation.
Current proof:
This is still an unofficial, Windows x64, version-sensitive alpha that requires an existing native replacement-history checkpoint. It addresses the long-history resume path only; it is not a universal fix for Electron, Git Review, MCP, GPU, or scheduling problems, and it is not a substitute for upstream bounded resume plus frontend row and attachment virtualization.
Adding a Windows Desktop datapoint that appears narrower than the large-history cases already documented here: the thread-list backend remains fast, but the left conversation sidebar renderer becomes stuck after repeated scrolling.
Environment
26200, x64OpenAI.Codex_26.707.9981.0_x64__2p2nqsd0c76g026.707.72221Reproduction
The Settings round-trip is highly repeatable as a temporary recovery. It looks like rebuilding/remounting the main route resets the failed sidebar state.
Read-only local evidence
Across retained Desktop logs:
thread/listresponses were observedthread/listresponse time: about 1 msthread/listerror responses were observedResizeObserver loop completed with undelivered notifications.The local state DB contains only 215 threads. This does not look like a slow SQLite query or a very large thread-count problem on this machine.
Additional isolation:
Possible boundary
This looks consistent with a renderer-side sidebar virtualization / layout-observer lifecycle failure rather than the
thread/listbackend path. It may be adjacent to the renderer hydration/reconciliation work tracked here, but it is reproducible even when list RPC latency and local thread count are both low.This comment is intentionally sanitized; no raw logs, account identifiers, local paths, thread IDs, conversation titles, prompts, or response content are included.
macOS datapoint: Guardian metadata growth can prevent Desktop from opening at all
This appears to be the same unbounded-metadata failure, but with a complete startup failure rather than only slow navigation.
Environment:
26.707.72221(build5307)0.144.226.2(25C56), arm64Observed startup behavior:
Read-only inspection of
~/.codex/state_5.sqliteimmediately before the second repair:7,538thread rowstitle/preview/first_user_messagelength:81,034characters544titles over 200 characters491previews over 1,000 characters352first-user-message values over 2,000 characters515active oversized rows were internal subagent rowssource.subagent.other = guardianThe prior occurrence had fields up to roughly
93,757characters. A broad metadata truncation restored startup then, but also damaged some visible conversation names, demonstrating that manual cleanup can create user-facing metadata loss.Controlled A/B:
CODEX_HOME.quick_checkremainedok, and normal conversation titles were left intact.Recurrence evidence:
The failure chain on this profile appears to be:
title,preview, andfirst_user_message.Potential fix boundaries:
first_user_messagevalues through the thread-list startup pathNo prompt contents, thread IDs, account identifiers, or repository paths are included here.
Adding another observed symptom that may be related to the renderer hydration/repaint path described here.
On Codex Desktop, conversation rendering is occasionally very slow, and the currently open conversation can stop updating automatically. The model/backend appears to continue making progress, but the newest message or completed output is not reflected in the visible thread until some manual UI action triggers a refresh—for example, switching away and back to the thread, reactivating the window, or reopening the conversation.
This is intermittent rather than a deterministic per-thread delay, but it is especially disruptive in long-running conversations because it is unclear whether the turn is still running, stalled, or already complete but hidden by stale renderer state.
Expected behavior:
This may overlap with the staged hydration work proposed here, but the manual-reactivation requirement suggests there may also be an invalidation/subscription or renderer-state reconciliation issue, rather than only raw loading performance.