Repository navigation
Fix #5303: stop browser pane re-running one-time setup on every CoreAnimation commit #5311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0d50e8e
5f6df91
aaffaf4
ecbc20b
5dfefa4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2205,6 +2205,58 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { | |
| XCTAssertEqual(panel.webViewLifecycleState, .liveVisible) | ||
| } | ||
|
|
||
| /// Regression guard for the issue #5303 render loop: `BrowserPanelView.onAppear` | ||
| /// re-fired on every CoreAnimation commit and re-asserted webview visibility, | ||
| /// which restored + re-navigated the webview repeatedly. Once the webview is live | ||
| /// and visible, redundant visibility notifications (the shape a spurious appear | ||
| /// produces) must be no-ops: no lifecycle churn and no webview replacement, so no | ||
| /// re-navigation is issued. | ||
| func testRedundantVisibleNotificationsDoNotChurnLiveWebView() { | ||
| let panel = BrowserPanel( | ||
| workspaceId: UUID(), | ||
| initialURL: URL(string: "about:blank")!, | ||
| isRemoteWorkspace: false | ||
| ) | ||
| defer { panel.close() } | ||
|
|
||
| let deadline = Date().addingTimeInterval(1.0) | ||
| while panel.webView.isLoading, | ||
| RunLoop.main.run(mode: .default, before: deadline), | ||
| Date() < deadline {} | ||
|
Comment on lines
+2222
to
+2225
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If 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!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added — Claude Code |
||
| XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") | ||
|
|
||
| panel.noteWebViewVisibility(true, reason: "test.visible.first") | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| XCTAssertEqual(panel.webViewLifecycleState, .liveVisible) | ||
|
|
||
| let webViewAfterFirst = panel.webView | ||
| let instanceIDAfterFirst = panel.webViewInstanceID | ||
| let reasonAfterFirst = panel.webViewLastVisibilityChangeReason | ||
| let changeAtAfterFirst = panel.webViewLastVisibilityChangeAt | ||
|
|
||
| var observedStates: [BrowserWebViewLifecycleState] = [] | ||
| var cancellable: AnyCancellable? | ||
| cancellable = panel.$webViewLifecycleState.dropFirst().sink { state in | ||
| observedStates.append(state) | ||
| } | ||
| defer { cancellable?.cancel() } | ||
|
|
||
| // Simulate `.onAppear` re-firing many times in one commit storm. | ||
| for index in 0..<32 { | ||
| panel.noteWebViewVisibility(true, reason: "test.visible.spurious-\(index)") | ||
| } | ||
|
|
||
| XCTAssertEqual(panel.webViewLifecycleState, .liveVisible) | ||
| XCTAssertTrue(observedStates.isEmpty, "Redundant visible notes churned lifecycle: \(observedStates)") | ||
| XCTAssertTrue(panel.webView === webViewAfterFirst, "A live webview must not be replaced by redundant visibility notes") | ||
| XCTAssertEqual(panel.webViewInstanceID, instanceIDAfterFirst) | ||
| XCTAssertEqual( | ||
| panel.webViewLastVisibilityChangeReason, | ||
| reasonAfterFirst, | ||
| "Redundant visible notes must early-return without recording a new transition" | ||
| ) | ||
| XCTAssertEqual(panel.webViewLastVisibilityChangeAt, changeAtAfterFirst) | ||
| } | ||
|
|
||
| func testRestoredHistoryBackDoesNotEmitNewTabLifecycleState() { | ||
| let discardedAt = Date(timeIntervalSince1970: 300) | ||
| let panel = BrowserPanel( | ||
|
|
@@ -2245,6 +2297,59 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { | |
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| final class BrowserDefaultsNormalizationTests: XCTestCase { | ||
| /// Moving default registration + settings normalization out of | ||
| /// `BrowserPanelView.onAppear` into the model bootstrap (issue #5303) keeps the | ||
| /// canonicalization behavior: an out-of-range or legacy raw value stored in | ||
| /// defaults is rewritten to its canonical form, and registered fallbacks are | ||
| /// available for unset keys. | ||
| func testNormalizeRewritesOutOfRangeAndLegacyValues() throws { | ||
| let suiteName = "cmux.browserDefaultsNormalizationTests.\(UUID().uuidString)" | ||
| let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) | ||
| defer { defaults.removePersistentDomain(forName: suiteName) } | ||
|
|
||
| // Out-of-range / invalid raw values that must be canonicalized. | ||
| defaults.set("not-a-real-mode", forKey: BrowserThemeSettings.modeKey) | ||
| defaults.set("not-a-real-variant", forKey: BrowserImportHintSettings.variantKey) | ||
| defaults.set(999, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) | ||
| defaults.set(999.0, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) | ||
| defaults.set(-5.0, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) | ||
|
|
||
| BrowserPanel.normalizeBrowserDefaults(defaults: defaults) | ||
|
|
||
| XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeSettings.defaultMode.rawValue) | ||
| XCTAssertEqual(defaults.string(forKey: BrowserImportHintSettings.variantKey), BrowserImportHintSettings.defaultVariant.rawValue) | ||
| XCTAssertEqual(defaults.integer(forKey: BrowserToolbarAccessorySpacingDebugSettings.key), BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing) | ||
| XCTAssertEqual(defaults.double(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey), BrowserProfilePopoverDebugSettings.defaultHorizontalPadding, accuracy: 0.0001) | ||
| XCTAssertEqual(defaults.double(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey), BrowserProfilePopoverDebugSettings.defaultVerticalPadding, accuracy: 0.0001) | ||
|
|
||
| // Registered fallbacks are available for keys that were never set. | ||
| XCTAssertEqual(defaults.string(forKey: BrowserSearchSettings.searchEngineKey), BrowserSearchSettings.defaultSearchEngine.rawValue) | ||
| } | ||
|
|
||
| /// Already-canonical, in-range values must be left untouched (no clobbering of | ||
| /// valid user settings during normalization). | ||
| func testNormalizePreservesValidValues() throws { | ||
| let suiteName = "cmux.browserDefaultsNormalizationTests.\(UUID().uuidString)" | ||
| let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) | ||
| defer { defaults.removePersistentDomain(forName: suiteName) } | ||
|
|
||
| let validSpacing = BrowserToolbarAccessorySpacingDebugSettings.supportedValues.last ?? BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing | ||
| // Resolve the app-target theme mode via the app-only settings type; the bare | ||
| // `BrowserThemeMode` is ambiguous here because this file also imports | ||
| // `CmuxSettings`, which declares a same-named enum. | ||
| let validThemeRaw = BrowserThemeSettings.mode(for: "dark").rawValue | ||
| defaults.set(validThemeRaw, forKey: BrowserThemeSettings.modeKey) | ||
| defaults.set(validSpacing, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) | ||
|
|
||
| BrowserPanel.normalizeBrowserDefaults(defaults: defaults) | ||
|
|
||
| XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), validThemeRaw) | ||
| XCTAssertEqual(defaults.integer(forKey: BrowserToolbarAccessorySpacingDebugSettings.key), validSpacing) | ||
| } | ||
| } | ||
|
|
||
| final class BrowserNewTabNavigationSeedTests: XCTestCase { | ||
| func testPreservesOriginalRequestHeadersMethodBodyAndBypassHost() throws { | ||
| let url = try XCTUnwrap(URL(string: "https://www.linkedin.com/redir/redirect?url=https%3A%2F%2Fexample.com")) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Stateflag still writes state during the first CoreAnimation commit passperformInitialBrowserPanelSetupIfNeededsetsdidCompleteInitialBrowserPanelSetup = truebefore the@AppStoragewrites, which correctly gates subsequent calls. However, that@Statemutation 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.onAppearonce 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@Stateflag is a new mutable piece of state that creates a second owner for "has one-time setup run?" alongside whatever owns theBrowserPanelmodel lifecycle.Per the
cmux-swift-architectural-rethinkrule,UserDefaults.register(defaults:)and the five@AppStoragenormalization writes are process-once / app-once work that belongs in a startup or model initialization site (e.g., theBrowserPanelinitor a dedicated settings-boot function), not in a SwiftUI.onAppearhandler 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)
There was a problem hiding this comment.
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.onAppearintoBrowserPanel.normalizeBrowserDefaults(defaults:), run once per process viabootstrapBrowserDefaultsIfNeeded()fromBrowserPanel.init. The process-scoped guard survives view-identity changes (a@Stateflag would reset on remount), so the settings work truly runs once. The injectedUserDefaultsalso makes it unit-testable; addedBrowserDefaultsNormalizationTests. The view's first-appear path now only seeds view-local state (the empty-state import list).— Claude Code