The mcp_<server>_ prefix clause in matchesMcpServer was invented by the
redesign, not a persisted format (mcp_prefix is only ever used as the
exact mcp_<serverName> pluginKey), and it claimed longer server names
sharing a prefix: with servers github and github_extra, removing github
also stripped github_extra's tokens. The predicate now only matches exact
or delimiter-bounded shapes.
An off-page selected skill whose per-id lookup failed with a transient
error (retry disabled) vanished from the selected list until remount. Any
settled lookup failure now keeps the placeholder entry so the allowlist id
stays visible and removable; only in-flight lookups are briefly hidden.
The hook only filtered the raw server name and suffix-delimiter tokens,
so confirming removal in the selected-tools section left persisted
prefix-format tokens (mcp_<server>, mcp_<server>_<tool>) in the form and
the row reappeared as selected. It now shares the matchesMcpServer
predicate with the selection logic so removal can never lag selection.
MCP selection accepts every historical token format (server placeholder,
raw server name, mcp_-prefixed, and per-tool ids in prefix/suffix shapes)
but removal only filtered the new placeholder plus the server's current
tool ids, so a legacy token left the server permanently selected and its
tools still expanded after save. Selection and removal now share a
matchesMcpServer predicate.
Creating an action from the marketplace on an unsaved agent opened an
editor whose save was guaranteed to fail; it now surfaces the existing
save-the-agent-first error, matching the action-removal guard.
The model picker button keeps its tight px-1 with a provider icon but gets
px-3 in the empty Select-a-model state so the placeholder is not flush
against the border.
buildCatalog gated built-ins, MCP, and skills on their capabilities and
permissions but pushed regular plugin tools unconditionally, so deployments
that removed the tools capability still offered attachable tool cards in
the marketplace. The loop now requires AgentCapabilities.tools, matching
the old Add Tools gate.
skills_enabled is the master opt-in for the skill allowlist, and an empty
allowlist with the flag on means the full accessible catalog. Selection
edits now sync the flag on empty/non-empty transitions via a shared
skillsEnabledTransition helper: picking the first skill enables it so the
choice takes effect on save, and removing the last one disables it so the
agent doesn't silently escalate to every skill. Mid-selection edits leave
the flag alone, preserving the Advanced kill switch's
disable-without-clearing behavior.
Agents loaded from the API carry only tool_resources.*.file_ids; the
client-only context/knowledge/code file entry arrays were read directly,
so existing attachments rendered as empty and could not be removed. A new
useAgentFileEntries hook restores the legacy derivation (agent files query
merged into the file map via processAgentOption) and now feeds AgentConfig,
the item dialog, and the selected-items pipeline.
An optimistic favorite written over an unpopulated cache seeded the list
with only the toggled item, and cancelQueries killed the initial fetch
that would have corrected it, hiding existing favorites until reload. The
optimistic write now only applies over known data; otherwise onSettled
invalidates so the authoritative list is refetched.
Also unnest the version date-label ternary and drop an unused form watch
flagged by CI.
The Create New menu exposed MCP server creation to users without the
MCP_SERVERS create permission and action creation on deployments with the
actions capability disabled; both entries are now gated like their
pre-redesign counterparts, and the button hides when neither applies.
Selected skills missing from the first catalog page (limit 100) were
dropped from the Skills section entirely, leaving them impossible to
inspect or remove. useResolvedSkills restores the per-id lookup: off-page
skills are fetched individually and confirmed misses (deleted or no longer
shared) stay visible under an Unavailable skill placeholder so the stale
allowlist entry remains removable.
Swap the ToolCard action-bar order so the star sits rightmost with the
configure/info icon to its left. Every card can be favorited but only some
are configurable, so anchoring the star keeps it in a consistent position
across the grid.
Reintroduce the favorite star from the old skill picker, generalized to
every marketplace item kind except per-agent actions. Cards in the Tool
Library and Skills dialogs get a hover-revealed star (always visible once
favorited), and the existing Favorites views in both dialogs now filter to
starred items.
Favorites persist in a dedicated ToolFavorite collection, one document per
(user, itemType, itemId) with a unique compound index, exposed through
atomic per-item PUT/DELETE endpoints under /api/user/settings/favorites/
tools. Per-item writes are idempotent and race-free across tabs/devices
(the unique index backstops concurrent toggles), reads are a single
index-backed query capped at 100 favorites per user, and the client keeps
React Query as the source of truth with optimistic updates. Handlers live
in @librechat/api with a thin route wrapper; methods follow the
data-schemas factory pattern with tenant isolation.
The favorites filter now matches on compound kind:id keys instead of bare
ids, closing a cross-kind collision where a tool and a skill sharing an id
would both match. The skill-favorites data-service stubs and the reserved
TUserFavorite.skillId field are replaced by the new tool-favorites service.
Tooltips go back to z-150 globally; inside a dialog they now borrow the
depth-aware popover z-index so they still clear nested dialogs (the Tool
Library item dialog) without outranking freshly opened modals everywhere
else. The default dialog close icon returns to its original size, and the
lc-field pointer-focus suppression ships with the package next to Input and
Textarea so external consumers get the whole mechanism from @librechat/client.
Connecting an MCP server from the item dialog now enables all of its tools
once the connection settles, deselect-all keeps the server attached via its
placeholder token instead of detaching it, adding a server writes the token
so a zero-tool attachment survives a save, and removing a server from the
tools list asks for confirmation again. Consume-only servers are excluded
from the catalog, matching the old select dialog.
Also share the catalog/selection pipeline between ToolsSection and the
marketplace through useAgentItems, hoist NEW_ACTION_ID next to ActionItem,
drop unused status/view union members and stale TranslationKeys casts,
document the phase-2 Favorites/Made-by-you views, fix the needs-setup dot
semantics and card focus suppression, remove the redundant close button in
CreateSkillDialog, move useInputModality into @librechat/client so external
consumers can mount it, and delete dead files and orphaned translation keys.
Drive the tool credential Save button from the real mutation state so it
shows a spinner while the request is in flight, and add a Cancel button
when re-editing already-saved credentials so the edit can be dismissed.
Swap Code Interpreter's thin btn-neutral bar for the same dashed dropzone
(DropzoneContent + dropzoneClassName) File Search already uses, so the two
capabilities' upload UIs are consistent.
- Remove the Cancel button (the flow auto-closes on connect / times out)
- Show the URL in a read-only single-line scrollable input (cursor moves
through it, not fully visible) with the shared CopyButton's smooth
Copy/Check icon swap, matching the OAuth callback-URL field
- Put the primary Continue with OAuth action (icon trailing) and an
icon-only QR toggle together in a row at the bottom, below the URL
- The QR reveals between the description and the URL with a smooth height
animation (grid-rows 0fr to 1fr, matching MCPToolItem's reveal)
- Skills picker: per-card visibility (public) and shared-author badges,
category filtering, and an in-place Create skill flow that auto-attaches
the new skill without leaving the builder
- MCP: inline Connect button in the first dialog plus a dedicated OAuth
dialog (continue, copyable URL, QR code) shown only when OAuth is required
- Web search: auth-aware affordance, settings cog when user-provided and an
info icon when system-defined
- Remove orphaned com_ui_unavailable/com_ui_initializing keys and the dead
Tools/MCPToolItem component
waitFor resolves as soon as BookmarkNav's data hook has fired once, but the Suspense resolution commit can still have a trailing render pass pending on slow runners (Windows CI shards). The first stream tick then flushes that leftover pass alongside the tick, inflating the memoized children's render counts past the captured baseline (Expected: 1, Received: 2). Flush pending commits with an empty async act() before capturing baselines.
getUserPrincipals resolves a user's ACL principals on nearly every
authenticated request. It fetched full group documents (including entire
memberIds arrays) only to read each group _id, and always issued a
separate User lookup for idOnTheSource.
- Project { _id: 1 } on the memberIds group query so it returns only ids
and can be served from the { memberIds: 1 } index instead of fetching
and decoding whole group docs.
- Accept role and idOnTheSource from the already-loaded request user and
thread them from the capability middleware, collapsing the hot path to
a single indexed group query (idOnTheSource: null means known-local).
* 🧠 fix: Default Bedrock thinking maxTokens to model max output
Thinking tokens share the maxTokens output budget with tool-call
arguments (e.g. a create_file content), so the low Bedrock defaults
(8192 for enabled thinking, ~4096 server-side for adaptive when unset)
truncated large authored files mid-argument — surfacing as
OutputTruncationError once reasoning actually emits.
Default maxTokens to the model's full max output via
anthropicSettings.maxOutputTokens.reset(model), mirroring the
direct-Anthropic path. Explicit maxTokens/maxOutputTokens are respected.
* fix: canonicalize number-first Claude aliases before resolving max output
* 🧠 fix: Apply Bedrock thinking config to bare inference-profile model IDs
The Bedrock request parser gated thinking config, sampling handling, and the
anthropic_beta headers on the model ID literally containing `anthropic.`. When
a deployment uses an application inference profile, the LibreChat model ID is a
bare `claude-*` (e.g. `claude-sonnet-5`) that maps to the profile ARN — so the
gate never matched, no `thinking` config was sent, and reasoning models
returned empty thinking blocks (most visibly: Claude Sonnet 5 never streamed
reasoning, while `us.anthropic.claude-opus-4-8` did).
Match on the `claude` family token instead of the `anthropic.` prefix so
prefixed (`anthropic.`, `us.`, `global.`) and bare inference-profile IDs are
handled identically. Verified e2e against live Bedrock via the agents SDK: a
bare `claude-sonnet-5` now sends `{type:'adaptive', display:'summarized'}` and
streams reasoning. Non-Claude Bedrock models (llama/cohere) and pre-thinking
Claude (3.5 sonnet) are unaffected.
* 🧹 fix: Strip stale thinking fields for non-thinking Claude Bedrock IDs
Follow-up to the bare-ID matching change: broadening the anthropic guard to
match bare `claude-*` meant a non-thinking Claude profile (e.g. a bare
`claude-3-5-sonnet` inference profile) took the Claude cleanup branch, which
kept persisted `thinking`/`anthropic_beta`/`output_config` from a
previously-selected thinking model — leaking unsupported fields after a model
switch. Extract `isThinkingModel` and, in the Claude cleanup branch, strip the
thinking fields when the model isn't thinking-capable. Also fixes the
pre-existing prefixed `anthropic.claude-3-5-sonnet` case (which already kept
stale thinking). Thinking-capable models (sonnet-5, 3.7-sonnet) still keep
their config.
* 🩹 fix: Preserve user anthropic_beta on non-thinking Claude cleanup
The non-thinking stale-cleanup deleted amrf.anthropic_beta, but that is the
generic Bedrock Anthropic beta field and may carry a user opt-in (e.g.
max-tokens-3-5-sonnet-2024-07-15 for extended output on Claude 3.5). Strip only
the thinking-specific fields (thinking/thinkingBudget/effort/output_config) and
leave anthropic_beta intact.
* fix: clear persisted AMRF (output_config, thinking, generated betas) on bare Bedrock profiles
* fix: preserve persisted effort on resume + strip stale thinking/betas across bare profiles
* fix: normalize string/comma-delimited anthropic_beta before stripping generated betas
Models sometimes pass edit_file's `edits` as a JSON-encoded string
(or stringify individual edit entries) instead of a real array. That
failed validation with "Provide old_text and new_text, or a non-empty
edits array" and forced a full retry round-trip.
normalizeEditArgs now JSON-parses a stringified `edits` value (and
stringified entries) before validating. Non-strings and unparseable
strings are left untouched, so the existing explicit errors still fire.
* feat: exclude create_file/edit_file from eager execution
Side-effecting host file-authoring tools should not be speculatively
eager-executed: a write can land before the turn commits, and the eager path's
incrementally-streamed args can diverge from the final tool call, tripping the
SDK's 'changed after eager execution' guard so the model is told the write
failed and loops (observed with create_file writing a large file to /mnt/data).
Pass excludeToolNames so these tools run on the normal ToolNode path with the
final args. Requires @librechat/agents with eager-exclusion support; older
versions ignore the field.
* chore: Bump `@librechat/agents` to v3.2.56
* refactor: reorder imports in run.ts for clarity
* fix: also exclude execute_code/bash_tool from eager execution
The eager 'changed after eager execution' corruption isn't specific to file
authoring — any tool with a large free-form streamed arg is exposed. Observed
live: a bash_tool heredoc (a full Python script in `command`) tripped the guard
and the write never landed. execute_code (`code`) and bash_tool (`command`)
carry large args and run code (side effects), so exclude them from eager
alongside create_file/edit_file.
* feat: wire codeSessionToolNames so create_file/edit_file share the code sandbox
Activates the agents#283 capability: pass create_file/edit_file as
codeSessionToolNames so their exec session/files fold into the shared code
session and a file they write is visible to later execute_code/bash_tool calls
(and the existing session is injected into their requests). No-op until
@librechat/agents ships codeSessionToolNames (agents#283).
* test: guard code-tool eager/session wiring in createRun
Asserts createRun passes excludeToolNames (create_file/edit_file/execute_code/
bash_tool) and codeSessionToolNames (create_file/edit_file) to Run.create — the
wiring the create_file->bash_tool sandbox-sharing chain depends on, which was
silently missing before. Guards against a future edit dropping it. Mirrors the
run-summarization test harness (mocks Run.create).
The full create_file->bash_tool chain runs through the real code sandbox and
can't run in the mock CI harness; the SDK mechanism is covered by
@librechat/agents unit tests, and this guards the LibreChat wiring.
* style: fix prettier formatting in run-codeTools test
* chore: Bump `@librechat/agents` to v3.2.57
* fix: preserve role SHARE permissions across boot in initializeRoles
* chore: sort role method spec imports
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
The `@librechat/api` build migrated to tsdown (rolldown/oxc) in #13595.
tsdown externalizes third-party deps and uses strict CJS interop, so a
default import of the Firebase v9+ modular SDK — whose CJS entry is
`__esModule`-marked with only named exports and no `default` — resolves
to `undefined`. `firebase.initializeApp(...)` then throws:
TypeError: Cannot read properties of undefined (reading 'initializeApp')
crashing startup whenever the Firebase file strategy is configured
(`fileStrategy: firebase` or a granular `fileStrategies` entry).
Switch to the idiomatic modular named import (`initializeApp`) and use
the already-imported `FirebaseApp` type for the return annotation.
* fix: download original file from artifact preview panel for office documents
The preview panel download button serialized the rendered HTML preview
instead of the original binary for office artifacts (pptx/xlsx/docx)
produced by the code interpreter, so users got an `index.html` text
scrape rather than the file. The inline chat card was unaffected because
it downloads the real file via `useAttachmentLink`.
Thread the original-file download metadata (filepath/file_id/source/user)
through `fileToArtifact` onto the Artifact, and update `DownloadArtifact`
to fetch the original file through that same path for preview-only office
artifacts. Text, source, and markdown artifacts keep the blob path so
their in-panel content (and edits) still download as-is.
Closes#14002
* fix: require a usable route before downloading the original artifact file
A shared link to a non-snapshotted code-execution office artifact strips
source/user and deletes filepath while keeping file_id (share
sanitization + applyShareFileRoute). The preview-panel download gate
treated that lone file_id as sufficient, so it routed to an empty
useCodeOutputDownload fetch and downloaded nothing instead of falling
back to the preview-content blob.
Take the original-file branch only when useAttachmentLink can actually
fetch: a non-empty filepath (http target, share route, or code-output
URL) or full local-file metadata (isLocallyStoredSource + file_id +
user). Export isLocallyStoredSource from LogLink so the panel reuses the
same predicate.
* fix: only show artifact download success after the file is delivered
useAttachmentLink swallows fetch errors (an expired code-output URL or a
404 share download) and resolves without throwing, so the preview-panel
download button flipped to the success checkmark even when no file was
downloaded.
Return a boolean from handleDownload (true once a download is initiated,
false on error/empty response) and only mark the artifact download as
succeeded when a file was actually delivered. The return value is
ignored by the existing onClick callers.
Add an optional `showInMenu` flag to model specs. When set to false, the
spec is dropped from the model selector menu and from the client startup
config (GET /api/config), but remains resolvable server-side by name — a
request that sends `spec: "<name>"` still works, since server-side
resolution uses the full, unfiltered list.
Unlike `showIconInMenu` (which only hides the icon), this hides the whole
entry. The flag is optional and defaults to listed, so existing specs are
unaffected.
Adds an `excludeHiddenModelSpecs()` helper (applied before
`sanitizeModelSpecs`) plus unit tests.
A large SKILL.md/file authored in one create_file call can exceed the model's
max output token budget; the response is cut off before the content argument
finishes, the arg is dropped, and the handler returns a bare 'content is
required'. The model reads that as a forgotten field and retries the same
oversized write, looping.
Make the error actionable: tell the model the response may have been truncated
and to keep the main file lean, moving bulky sections into separate files
written in their own calls.
* ✨ feat: Add Claude Sonnet 5 Support
Wire up the claude-sonnet-5 model across token, pricing, and model-list
config:
- Context window (1M) and max output (128K) in @librechat/api token maps
- Standard pricing ($3/$15 per MTok) and cache rates in data-schemas tx
- 128K output-token carve-out in anthropicSettings (the family-wide 64K
rule capped Sonnet 5 below its real limit); Bedrock/Vertex thinking and
1M-context detection already cover sonnet major >= 5 generically
- Add to shared Anthropic, Bedrock, and Vertex default model lists, plus
the .env.example examples
- Tests for context/output/pricing/matching across the affected packages
* ✅ test: Align Sonnet 5+ maxOutputTokens defaults with 128K spec
getLLMConfig defaults flow from anthropicSettings.maxOutputTokens.reset(),
which now returns 128K for Sonnet 5+. Update the future-proofing assertions
in llm.spec.ts (Sonnet 5.x and 6-9.x) that still expected the old family-wide
64K cap. Haiku stays 64K; Opus stays 128K.
* 🎚️ fix: Gate Sonnet 5 capability behaviors (sampling, thinking)
Adding claude-sonnet-5 to the default list exposed it without the Anthropic
capability gates, all confirmed against the live API:
- omitsSamplingParameters: Sonnet 5 returns 400 on non-default temperature/
top_p/top_k ('deprecated for this model'); now dropped so selecting the
model with saved sampling settings no longer fails.
- requiresExplicitThinkingDisabled: omitting 'thinking' runs adaptive ON by
default on Sonnet 5, so disabling thinking now sends { type: 'disabled' }
(verified: 200, no thinking block) instead of omitting the field.
- omitsThinkingByDefault: thinking.display defaults to omitted (empty thinking
blocks); the display resolver now returns 'summarized' for Sonnet 5+ so the
Thoughts UI keeps working (verified: 757-char summary returned).
Gates apply to both the direct Anthropic and Bedrock paths. Tests added in
bedrock.spec and llm.spec.
* 🩹 fix: Sonnet 5 Bedrock availability + thinking-off persistence
Round-2 Codex review (all verified against the live API / Anthropic docs):
- Sonnet 5 is NOT available on the legacy Bedrock InvokeModel/Converse surface
(Anthropic docs: 'use Claude in Amazon Bedrock or Claude Platform on AWS'),
which is what LibreChat's ChatBedrockConverse uses. Removed it from the
default Bedrock model lists (config + .env.example). Opus 4.8/4.7/Fable 5
stay — those ARE reachable via InvokeModel. Sonnet 5 remains on the direct
Anthropic API and Vertex, where it works.
- Reverted the Bedrock-side explicit-disabled thinking handling added last
round: with Sonnet 5 off Bedrock, no Bedrock model needs { type: 'disabled' },
so that path (and its round-trip concern) no longer applies.
- Direct Anthropic path: a persisted { type: 'disabled' } thinking object now
normalizes to a boolean flag in getLLMConfig, so a user's Sonnet 5
'thinking off' setting stays off across the model_parameters round trip
instead of flipping back to adaptive (a truthy object skipped the disabled
branch).
* ↩️ fix: Restore Sonnet 5 on Bedrock (Converse) — verified live
Reverses the round-2 removal: Sonnet 5 IS available on AWS Bedrock. Tested
live via the Converse API:
- global.anthropic.claude-sonnet-5 returns a normal response
- bare anthropic.claude-sonnet-5 needs an inference profile — but that's
identical to the already-shipping Opus 4.8 / Fable 5 / Sonnet 4.6 entries,
which all fail bare on-demand the same way
- temperature=0.5 -> 400 'deprecated for this model'; thinking {type:disabled}
suppresses reasoning — same as the direct API
The 'legacy' Bedrock docs page that claimed Sonnet 5 wasn't on the surface is
stale. Restored:
- anthropic.claude-sonnet-5 in bedrockModels + .env.example
- the Bedrock explicit-disabled thinking handling (requiresExplicitThinkingDisabled
-> { type: 'disabled' })
- the Finding 4 round-trip fix in bedrockInputSchema (coerce a persisted
disabled AMRF.thinking to thinking=false instead of !!thinking -> true), with
an end-to-end schema->parser test proving 'thinking off' stays sticky.
Direct-path round-trip fix (getLLMConfig thinkingFlag) is unchanged.
* 💵 fix: Sonnet 5 intro pricing + sticky disabled thinking on Bedrock reload
Round-4 Codex review (both verified):
- Pricing: Anthropic lists Sonnet 5 at introductory $2/$10 per MTok (cache
$2.50/$0.20) through 2026-08-31, reverting to $3/$15 ($3.75/$0.30) on
Sep 1 (confirmed on platform.claude.com/pricing). The static tx multiplier
table is used for real balance transactions, so the post-intro rates were
overcharging ~50% during the launch window. Switched to the intro rates with
a revert comment on both the token and cache entries.
- Bedrock disabled-thinking persistence: initializeBedrock feeds persisted
model_parameters straight through bedrockInputParser (NOT bedrockInputSchema),
where additionalModelRequestFields is a known key — so a prior
thinking:{type:'disabled'} was ignored and rebuilt as adaptive on reload.
bedrockInputParser now surfaces a persisted disabled AMRF.thinking as
thinking=false so it re-emits {type:'disabled'}. Verified end-to-end against
the real initializeBedrock call path.
At rest (no hover/focus) the rail now reads like a minimap: only the
in-viewport message ribs are at full opacity while the rest fade to 40%,
updating live as you scroll. Replaces the blanket nav opacity-30 with
per-rib opacity driven by the existing viewport-visibility highlight, so
hover/focus still brings every rib to full opacity for the fisheye.
* ✨ feat: Dock-Fisheye Message Nav Rail with Instant Hover Preview
* 🎚️ refactor: Uniform resting ribs + clickable cursor for message nav
* 🧹 fix: One rib per message in nav rail (dedupe nested message-render)
* 🎯 fix: Accurate fisheye focus + click-anywhere-to-jump in message nav
- Measure rib centers relative to the column (getBoundingClientRect) instead
of offsetTop, which was relative to the positioned <nav> and shifted the
pointer->rib mapping by the chevron height (hovered line wasn't the peak,
preview showed an earlier message).
- Column-level click jumps to the focused rib, so clicking anywhere the
preview is showing works even when the pointer is off the thin line.
- Restore @librechat/client jest stub to keep the unit isolated.
* 💡 fix: Highlight only the hovered rib white in message nav
* 🫥 style: Transparent message nav (drop pill background)
* ♿ feat: Keyboard focus mirrors hover (magnify + highlight + preview) in message nav
Tabbing to / Shift+Alt+M focusing a rib now drives the same fisheye
pipeline as pointer hover via onFocus/onBlur on the column: the focused
rib magnifies, highlights white, and shows the shared preview. Also
addresses Codex finding on keyboard-focus previews.
* 🩹 fix: Live tooltip preview + legacy media-query fallback in message nav
- Derive the shared preview text from entryById at render time instead of
snapshotting it into tip state, so a streaming/updating message refreshes
the open tooltip without leaving and re-entering the rail.
- Feature-detect MediaQueryList.addEventListener and fall back to
addListener/removeListener so the reduced-motion watcher no longer throws
(and breaks the nav) on Safari/iOS < 14.
Addresses both Codex findings on review 4601236141.
* perf: memoize FavoritesList and BookmarkNav to prevent streaming re-renders
ConversationsSection re-renders during message streaming as its
conversation-list query and title generation update the cache. Its
FavoritesList and BookmarkNav children were not memoized, so they
re-rendered on every parent commit despite their props and
subscriptions never changing during a stream.
Wrap both in React.memo to insulate them from the parent cascade.
Their props (toggleNav, isSmallScreen, tags, setTags) are referentially
stable, so memo fully decouples them. Add a regression test asserting
FavoritesList does not re-run when its parent re-renders with stable
props.
* test: verify ConversationsSection insulates Favorites/Bookmarks from streaming re-renders
Renders the real ConversationsSection (mocking only data hooks) and
forces repeated re-renders via a subscription it depends on, mirroring
the conversation-list/title-generation cache churn during streaming.
Asserts FavoritesList and BookmarkNav do not re-render, proving the
parent passes referentially stable props so React.memo holds in the
real render path (not just with hand-fed stable props).