Skip to content

Fix #5303: stop browser pane re-running one-time setup on every CoreAnimation commit - #5311

Merged
austinywang merged 5 commits into
mainfrom
issue-5303-browser-render-loop
Jun 4, 2026
Merged

austinywang merged 5 commits into
mainfrom
issue-5303-browser-render-loop

Conversation

@austinywang

@austinywang austinywang commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5303.

Problem

A live 3-second sample of production cmux showed the main thread spending ~39% of on-CPU time re-evaluating BrowserPanelView.body and re-running handleBrowserPanelAppear() inside CoreAnimation commit handlers. For the portal-hosted browser pane, .onAppear re-fires on essentially every CA::Transaction::commit, and handleBrowserPanelAppear() was doing process-once work on every call:

  • UserDefaults.standard.register(defaults:) — re-registering the whole defaults dictionary every commit.
  • Five settings-normalization blocks that write @AppStorage (theme mode, import-hint variant, toolbar spacing, two profile-popover paddings) — writing state during the commit pass, which feeds SwiftUI invalidation.
  • refreshEmptyStateImportBrowsers() — cancelling and respawning a detached browser-detection Task every commit.

In addition, the repeated visibility re-assertion drove noteWebViewVisibility → restoreDiscardedWebViewIfNeeded → performNavigation → browserLoadRequest, re-navigating the WKWebView and churning the WebContent helper process (a plausible engine for the leak in #5302).

Root cause

.onAppear is being treated as a "run once / on transition" signal, but it is not reliable for a portal-hosted pane — it re-fires every commit. Doing one-time setup and @AppStorage writes in that path makes every spurious appear expensive and re-entrant.

Fix

Move the one-time work to where it belongs and gate the rest:

  • Process-once settings work moved to the model. UserDefaults.register(defaults:) and the five settings-normalization writes now live in BrowserPanel.normalizeBrowserDefaults(defaults:), run once per process via BrowserPanel.bootstrapBrowserDefaultsIfNeeded() from BrowserPanel.init. The process-scoped guard survives view remounts (a view-scoped flag would reset on identity change), and the injected UserDefaults makes the normalization unit-testable.
  • View-local first-appear seed gated by @State. performInitialBrowserPanelSetupIfNeeded() runs at most once per BrowserPanelView instance and only seeds view-local state (the empty-state import list).
  • handleBrowserPanelAppear() keeps only cheap, already-idempotent calls. Genuine state transitions (visibility, focus, URL, omnibar, profile, color scheme) are independently handled by the dedicated .onChange observers on body, so re-running the slim per-appear path is harmless.

This removes the heavy per-commit work and the @AppStorage-write-during-commit invalidation edge, eliminating the whole class of "appear re-fires every commit → re-does one-time setup / re-writes state / re-navigates the webview." A live webview is no longer restored and re-navigated by redundant appears (restoreDiscardedWebViewIfNeeded already guards on isDiscardedForMemory, and noteWebViewVisibility early-returns when visibility is unchanged).

Verification

  • Mechanism confirmed in code against the issue's live sample call tree.
  • After a build, the fix is verifiable by opening a browser pane and running sample <cmux-pid> 3: the BrowserPanelView.body / handleBrowserPanelAppear / browserLoadRequest frames under CA::Transaction::commit should be gone and main-thread CPU should drop.

Tests

Adds testRedundantVisibleNotificationsDoNotChurnLiveWebView to BrowserPanelWebViewLifecycleTests: once a webview is live and visible, 32 redundant noteWebViewVisibility(true, …) calls (the shape a spurious appear produces) must not churn lifecycle, replace the webview, or record a new transition — so no re-navigation is issued.

Adds BrowserDefaultsNormalizationTests: against a scratch UserDefaults(suiteName:), out-of-range/legacy stored values are rewritten to canonical form, registered fallbacks are available for unset keys, and already-valid values are left untouched.

Related performance evidence: #5305 (tracking audit; this PR removes the render-loop CPU engine documented there).

The view-level run-once gating itself is not cleanly unit-testable without SwiftUI hosting in the unit target, so there is no red-first commit for that piece; it is verified via the issue's sample and the behavioral guard above.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches hot-path browser panel lifecycle and UserDefaults on every panel init, but behavior is narrowed to idempotent normalization and existing visibility early-returns; regression coverage was added.

Overview
Fixes #5303 by stopping portal-hosted BrowserPanelView from treating every repeated .onAppear (CoreAnimation commits) as first-time setup.

Process-once work moves out of handleBrowserPanelAppear(): UserDefaults fallback registration and canonicalization of legacy/out-of-range browser settings now run via BrowserPanel.bootstrapBrowserDefaultsIfNeeded() / normalizeBrowserDefaults(defaults:) from BrowserPanel init, guarded once per process. The view no longer re-registers defaults or re-syncs theme/import-hint/toolbar/profile debug keys on each appear (removing @AppStorage-write-driven invalidation during commits).

View-once work is limited to performInitialBrowserPanelSetupIfNeeded() with didCompleteInitialBrowserPanelSetup, which only seeds the empty-state import browser list; per-appear handling keeps idempotent visibility/chrome/focus/history calls.

Tests add BrowserDefaultsNormalizationTests (scratch UserDefaults) and testRedundantVisibleNotificationsDoNotChurnLiveWebView to ensure redundant noteWebViewVisibility(true, …) does not replace the live WKWebView or churn lifecycle.

Reviewed by Cursor Bugbot for commit 5dfefa4. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Stops the browser panel from re-running one-time setup on every CoreAnimation commit, fixing #5303. Defaults registration and settings normalization now run once per process in the model; the view’s appear path stays light, avoiding render-loop churn and redundant WKWebView navigation.

  • Bug Fixes
    • Moved browser defaults/bootstrap to a process-once model init (bootstrapBrowserDefaultsIfNeeded()), which now always targets UserDefaults.standard; normalization remains in normalizeBrowserDefaults(defaults:) for unit tests.
    • Limited the view’s run-once guard to seeding the initial empty-state import list; .onAppear now does only cheap, idempotent work.
    • Added tests: BrowserDefaultsNormalizationTests and testRedundantVisibleNotificationsDoNotChurnLiveWebView; fixed test build by disambiguating the BrowserThemeMode enum in the normalization test.

Written for commit 5dfefa4. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Prevented redundant WebView recreation when the browser panel re-appears.
    • Ensured browser preferences are initialized and normalized once per launch to correct legacy or out-of-range values.
    • Added a view-level one-time guard to avoid repeated initial-appearance work for portal-hosted panes.
  • Tests

    • Added tests verifying visibility notifications no longer churn WebView lifecycle.
    • Added tests for defaults normalization and preservation of valid values.

`BrowserPanelView.onAppear` re-fired on every CoreAnimation commit for the
portal-hosted browser pane, and `handleBrowserPanelAppear()` did process-once
work on every call: `UserDefaults.register(defaults:)`, five settings
normalization blocks that write `@AppStorage`, and a respawned empty-state
import detection task. A live `sample` showed this burning ~39% of main-thread
CPU and re-issuing webview navigation inside commit handlers.

Split the appear path: a run-once guard (`didCompleteInitialBrowserPanelSetup`)
gates default registration, settings normalization, and the initial empty-state
populate into `performInitialBrowserPanelSetupIfNeeded()`, which now runs at most
once per view instance. The per-appear path keeps only cheap, idempotent calls;
genuine state transitions are already covered by the dedicated `.onChange`
observers on `body`. This removes the heavy per-commit work and the
`@AppStorage`-write-during-commit invalidation edge that fed the loop.

Moving normalization out of the repeated path also stops re-asserting webview
visibility work every commit, so a live webview is no longer restored and
re-navigated repeatedly (the WebContent churn behind #5302).

Adds a regression guard asserting redundant visible notifications on a live
webview do not churn lifecycle or replace the webview (so no re-navigation).
The view-level run-once gating itself is not cleanly unit-testable without
SwiftUI hosting; it is verified via the issue's sample.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 3, 2026 9:40pm
cmux-staging Building Building Preview, Comment Jun 3, 2026 9:40pm

@coderabbitai

coderabbitai Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Moves per-appear UserDefaults registration into a process-once BrowserPanel bootstrap, guards BrowserPanelView’s initial setup with a view-local @State to avoid repeated .onAppear work, and adds tests for WebView visibility idempotency and defaults normalization.

Changes

Idempotent browser pane initialization

Layer / File(s) Summary
BrowserPanel defaults bootstrapping
Sources/Panels/BrowserPanel.swift
Adds hasBootstrappedBrowserDefaults, bootstrapBrowserDefaultsIfNeeded(), and normalizeBrowserDefaults(defaults:); bootstrap is called from BrowserPanel initializer to register fallback UserDefaults and normalize legacy/out-of-range values.
BrowserPanelView one-time initialization guard
Sources/Panels/BrowserPanelView.swift
Adds @State didCompleteInitialBrowserPanelSetup and performInitialBrowserPanelSetupIfNeeded() to ensure initial empty-state import browser refresh runs once; removes inline defaults normalization from the appear handler.
Test coverage for idempotency and defaults
cmuxTests/GhosttyConfigTests.swift
Adds testRedundantVisibleNotificationsDoNotChurnLiveWebView() and BrowserDefaultsNormalizationTests (testNormalizeRewritesOutOfRangeAndLegacyValues, testNormalizePreservesValidValues) to validate WebView lifecycle idempotency and defaults normalization.

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly Related PRs

  • manaflow-ai/cmux#4243: Modifies BrowserPanelView appearance lifecycle and interactions with noteWebViewVisibility.
  • manaflow-ai/cmux#4244: Adds webview lifecycle tracking and discard/restore behavior affecting the same paths.

"I nibble at bugs beneath the moon,
I plant one-time seeds that take root soon,
No churn, no double hops, the webview sleeps tight,
Defaults snugly set by morning light,
🐇✨"

🚥 Pre-merge checks | ✅ 17 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing browser pane setup re-execution on every CoreAnimation commit, directly addressing issue #5303.
Linked Issues check ✅ Passed All objectives from #5303 are met: process-once work moved to model init, view-local seeding gated by @State, per-appear handling made idempotent, and redundant visibility/navigation eliminated via existing guards.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing #5303: bootstrap/normalization logic in BrowserPanel, view-level gating in BrowserPanelView, and tests for both. No unrelated modifications detected.
Cmux Swift Actor Isolation ✅ Passed All new Swift code maintains proper actor isolation: static methods in @MainActor class are isolated, private static Bool is safe, and @State is properly view-local. No background access.
Cmux Swift Blocking Runtime ✅ Passed No blocking/timing primitives in production code. Uses only non-blocking UserDefaults operations and @State guards. Test uses allowed deterministic RunLoop scaffolding.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift files (.swift). Check applies only to TypeScript, JavaScript, shell, and build/runtime scripts. Swift code is covered by separate swift-blocking-runtime check.
Cmux Algorithmic Complexity ✅ Passed All new code operates in O(1) constant time on fixed-size data. No nested loops, rescans, or collection operations. PR reduces CPU by moving setup from hot path to process bootstrap.
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns: only synchronous bootstrap functions, SwiftUI @State guards, and tests. No DispatchQueue.global(), fire-and-forget Tasks, Combine, or new completion handlers.
Cmux Swift @Concurrent ✅ Passed No violations found. Three new synchronous functions added; CPU-heavy async work properly isolated via Task.detached(). No missing @concurrent, invalid @concurrent, or unsafe MainActor calls.
Cmux Swift File And Package Boundaries ✅ Passed PR adds 79 lines to BrowserPanel.swift, under the 250-line threshold for mixed-responsibility concern. Focused #5303 fix moving init work from view to model. Qualifies as focused bug-fix exception.
Cmux Swift Logging ✅ Passed The PR adds no logging calls (print, debugPrint, dump, NSLog, or Logger) in production code or tests. All changes focus on refactoring initialization logic without introducing logging violations.
Cmux User-Facing Error Privacy ✅ Passed No user-facing error messages added. Changes are internal functions/tests with no sensitive data, vendor secrets, or credentials exposed to users.
Cmux Full Internationalization ✅ Passed All user-facing text uses String(localized:defaultValue:), string catalog has complete translations for all supported locales, and test code is developer-only.
Cmux Swiftui State Layout ✅ Passed PR adds @State Bool guard and moves settings work from view render to model bootstrap. No new @Published, render-time mutations, or layout issues.
Cmux Architecture Rethink ✅ Passed Clean separation: app-once defaults (model guard) vs view-local setup (view guard). No timing patches, observers, or split lifecycle. Sound architectural fix for #5303.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds UserDefaults bootstrap utilities to BrowserPanel model and BrowserPanelView, not standalone windows. No new NSWindow, NSPanel, or SwiftUI Window/WindowGroup created.
Description check ✅ Passed PR description thoroughly covers the problem, root cause, fix, verification approach, and added tests with clear technical detail.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5303-browser-render-loop

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 and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes #5303 by moving process-once browser defaults work out of BrowserPanelView.onAppear (which re-fires on every CoreAnimation commit for portal-hosted panes) and into BrowserPanel.init, eliminating the render-loop CPU engine and the redundant WKWebView re-navigation it caused.

  • Model bootstrap: UserDefaults.register(defaults:) and five settings-normalization writes now live in BrowserPanel.normalizeBrowserDefaults(defaults:), guarded by a static hasBootstrappedBrowserDefaults flag and called once per process from BrowserPanel.init; the injected UserDefaults parameter makes the normalization unit-testable against a scratch suite.
  • View-local guard: performInitialBrowserPanelSetupIfNeeded() limits first-appear work to seeding the empty-state import list via a @State flag; all other calls in handleBrowserPanelAppear() are independently idempotent.
  • Tests: BrowserDefaultsNormalizationTests verifies out-of-range and legacy values are canonicalized and valid values are left untouched; testRedundantVisibleNotificationsDoNotChurnLiveWebView asserts 32 spurious noteWebViewVisibility(true, …) calls do not replace the live WKWebView or record new transitions.

Confidence Score: 5/5

Safe to merge; the static guard is correctly serialized on @mainactor, the view's appear path is genuinely idempotent, and both new test suites cover the fixed invariants.

The root cause is correctly addressed. The static hasBootstrappedBrowserDefaults flag on a @mainactor class inherits MainActor isolation, so concurrent reads/writes are impossible. The @State guard bounds the first-appear write to a single extra pass, which is harmless. All remaining appear-path calls are independently idempotent. No new timing dependencies, blocking primitives, or actor-isolation mistakes were introduced.

No files require special attention.

Important Files Changed

Filename Overview
Sources/Panels/BrowserPanel.swift Adds static process-once bootstrap (bootstrapBrowserDefaultsIfNeeded / normalizeBrowserDefaults) invoked from init; static members are @MainActor-isolated by class annotation, reads-then-writes are safely serialized on the main actor, and the guard correctly survives view identity changes.
Sources/Panels/BrowserPanelView.swift Removes heavy per-appear work from handleBrowserPanelAppear; introduces @State guard didCompleteInitialBrowserPanelSetup to seed the empty-state import list once per view instance; remaining appear-path calls are all idempotent.
cmuxTests/GhosttyConfigTests.swift Adds testRedundantVisibleNotificationsDoNotChurnLiveWebView and BrowserDefaultsNormalizationTests; both test classes are appropriately annotated and follow existing patterns.

Sequence Diagram

sequenceDiagram
    participant App
    participant BrowserPanel
    participant UserDefaults
    participant BrowserPanelView
    participant SwiftUI

    App->>BrowserPanel: init(workspaceId:…)
    BrowserPanel->>BrowserPanel: bootstrapBrowserDefaultsIfNeeded()
    Note over BrowserPanel: hasBootstrappedBrowserDefaults == false
    BrowserPanel->>UserDefaults: normalizeBrowserDefaults(defaults: .standard)
    UserDefaults-->>BrowserPanel: register fallbacks + canonicalize legacy values
    BrowserPanel->>BrowserPanel: "hasBootstrappedBrowserDefaults = true"

    SwiftUI->>BrowserPanelView: .onAppear (1st — genuine)
    BrowserPanelView->>BrowserPanelView: performInitialBrowserPanelSetupIfNeeded()
    Note over BrowserPanelView: didCompleteInitialBrowserPanelSetup = false
    BrowserPanelView->>BrowserPanelView: refreshEmptyStateImportBrowsers()
    BrowserPanelView->>BrowserPanel: noteWebViewVisibility(true, …)

    SwiftUI->>BrowserPanelView: .onAppear (Nth — spurious CA commit)
    BrowserPanelView->>BrowserPanelView: performInitialBrowserPanelSetupIfNeeded()
    Note over BrowserPanelView: didCompleteInitialBrowserPanelSetup == true — early return
    BrowserPanelView->>BrowserPanel: noteWebViewVisibility(true, …)
    Note over BrowserPanel: visibility unchanged — early return
Loading

Reviews (4): Last reviewed commit: "Address review: make browser defaults bo..." | Re-trigger Greptile

Comment on lines +2222 to +2225
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.isLoading,
RunLoop.main.run(mode: .default, before: deadline),
Date() < deadline {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing timeout-guard assertion after RunLoop spin

If about:blank doesn't finish loading within 1 second the spin exits silently, and the subsequent noteWebViewVisibility(true, …) / XCTAssertEqual(…, .liveVisible) call may either pass by coincidence or produce a misleading failure without explaining that the load deadline was exceeded. The neighboring test testRestoredHistoryBackDoesNotEmitNewTabLifecycleState adds XCTAssertFalse(panel.webView.isLoading, "Timed out…") immediately after the same spin; this test should do the same.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 — added XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") after the spin, matching the neighboring tests.

— Claude Code

Comment on lines 798 to 837
@@ -766,7 +809,7 @@ struct BrowserPanelView: View {
BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
refreshBrowserChromeStyle()

let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
@@ -787,23 +830,10 @@ struct BrowserPanelView: View {
if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding {
browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding
}
panel.noteWebViewVisibility(
isVisibleInUI && isCurrentPaneOwner,
reason: "view.onAppear"
)
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")

// Populate the empty-state import list once; `handleCurrentURLChange`
// refreshes it on subsequent new-tab navigations.
refreshEmptyStateImportBrowsers()
panel.historyStore.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
focusModeShortcutHintMonitor.start()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 @State flag still writes state during the first CoreAnimation commit pass

performInitialBrowserPanelSetupIfNeeded sets didCompleteInitialBrowserPanelSetup = true before the @AppStorage writes, which correctly gates subsequent calls. However, that @State mutation itself happens inside .onAppear—the same commit pass the PR is trying to clean up—so the first appear still schedules a re-render that fires .onAppear once more, at which point the guard early-returns. The loop is now bounded to two passes rather than infinite, which is the real fix, but the @State flag is a new mutable piece of state that creates a second owner for "has one-time setup run?" alongside whatever owns the BrowserPanel model lifecycle.

Per the cmux-swift-architectural-rethink rule, UserDefaults.register(defaults:) and the five @AppStorage normalization writes are process-once / app-once work that belongs in a startup or model initialization site (e.g., the BrowserPanel init or a dedicated settings-boot function), not in a SwiftUI .onAppear handler gated by view-scoped @State. Moving them out would make the fix robust across view identity changes and remove the remaining first-appear re-render entirely.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

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.

Good call — moved UserDefaults.register(defaults:) and the five settings-normalization writes out of .onAppear into BrowserPanel.normalizeBrowserDefaults(defaults:), run once per process via bootstrapBrowserDefaultsIfNeeded() from BrowserPanel.init. The process-scoped guard survives view-identity changes (a @State flag would reset on remount), so the settings work truly runs once. The injected UserDefaults also makes it unit-testable; added BrowserDefaultsNormalizationTests. The view's first-appear path now only seeds view-local state (the empty-state import list).

— Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread cmuxTests/GhosttyConfigTests.swift
… init

Greptile (P2) flagged that `UserDefaults.register(defaults:)` and the five
`@AppStorage` settings-normalization writes are app-once work that should not
live in `BrowserPanelView.onAppear` gated by view-scoped `@State`: a `@State`
guard resets whenever the view changes identity (a remount re-runs it), so it
does not robustly bound the work under the very remount loop this PR addresses.

Move registration + normalization into
`BrowserPanel.normalizeBrowserDefaults(defaults:)`, invoked once per process via
`bootstrapBrowserDefaultsIfNeeded()` from `BrowserPanel.init` (before any setting
is read). The function takes an injected `UserDefaults`, so it is unit-testable
against a scratch suite without touching `UserDefaults.standard`. The view's
first-appear path now only seeds view-local state (the empty-state import list).

Tests:
- Add `BrowserDefaultsNormalizationTests`: out-of-range/legacy raw values are
  rewritten to canonical form and registered fallbacks are available; valid
  in-range values are preserved (red without normalization).
- Add the timeout-guard assertion after the RunLoop spin in the lifecycle
  regression test, matching the sibling tests (greptile + cubic nit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
austinywang and others added 2 commits June 3, 2026 14:29
GhosttyConfigTests.swift imports both the app target (which declares
BrowserThemeMode) and CmuxSettings (which declares a public same-named enum),
so a bare `BrowserThemeMode.dark` was ambiguous and failed the test-target
build. Resolve the app-target enum via the app-only `BrowserThemeSettings`
type instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile's design note: bootstrapBrowserDefaultsIfNeeded(defaults:) took an
injectable UserDefaults while its run-once guard is process-wide, so any call
after the first would silently no-op for a different suite. The bootstrap now
always targets .standard; tests keep exercising normalizeBrowserDefaults(defaults:)
directly with a scratch suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@austinywang
austinywang merged commit 2c35c54 into main Jun 4, 2026
21 checks passed
@austinywang austinywang mentioned this pull request Jun 4, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 5dfefa47 Deployed Jun 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants