Skip to content

perf: don't animate setViewControllers on empty stack#5

Merged
Sajjon merged 2 commits into
mainfrom
perf_skip_invisible_first_push
May 4, 2026
Merged

perf: don't animate setViewControllers on empty stack#5
Sajjon merged 2 commits into
mainfrom
perf_skip_invisible_first_push

Conversation

@Sajjon
Copy link
Copy Markdown
Owner

@Sajjon Sajjon commented May 3, 2026

setRootViewControllerIfEmptyElsePush passed the caller's animated
parameter through to setViewControllers([vc], animated: animated) even
on the empty-stack branch — i.e. when seating the first VC into a
fresh nav controller. UIKit still spins a ~0.35s push-style animation
in that case, even though the user can't see it: the typical caller is
presentModalCoordinator which is about to follow up with a separate
navigationController.present(modalNav, animated: true) of its own.
The user only ever sees the modal-present animation; the inner
empty-to-first-VC animation is invisible-but-blocking, gating the
visible animation chain on it finishing first.

Net: ~1-2s perceived lag between "tap entry button" and "modal flow
appears" on iOS 26 simulators (worse on cold launches where UIKit
machinery for the new stack is also being inflated for the first time).

Fix: force animated: false on the empty-stack branch. The visible
modal-present animation now starts immediately. forceReplaceAllVCs- InsteadOfPush still honours the caller's animated because that path
is for in-stack replacements where the user is watching the
transition.

Bookkeeping: track didAnimate explicitly so the completion-routing
fall-through (transition-coordinator vs DispatchQueue.main.async)
matches the actual transition we performed.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

setRootViewControllerIfEmptyElsePush passed the caller's `animated`
parameter through to `setViewControllers([vc], animated: animated)` even
on the empty-stack branch — i.e. when seating the *first* VC into a
fresh nav controller. UIKit still spins a ~0.35s push-style animation
in that case, even though the user can't see it: the typical caller is
`presentModalCoordinator` which is about to follow up with a separate
`navigationController.present(modalNav, animated: true)` of its own.
The user only ever sees the modal-present animation; the inner
empty-to-first-VC animation is invisible-but-blocking, gating the
visible animation chain on it finishing first.

Net: ~1-2s perceived lag between "tap entry button" and "modal flow
appears" on iOS 26 simulators (worse on cold launches where UIKit
machinery for the new stack is also being inflated for the first time).

Fix: force `animated: false` on the empty-stack branch. The visible
modal-present animation now starts immediately. `forceReplaceAllVCs-
InsteadOfPush` still honours the caller's `animated` because that path
is for in-stack replacements where the user *is* watching the
transition.

Bookkeeping: track `didAnimate` explicitly so the completion-routing
fall-through (transition-coordinator vs DispatchQueue.main.async)
matches the actual transition we performed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.77%. Comparing base (89a884e) to head (ef719e4).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main       #5   +/-   ##
=======================================
  Coverage   87.77%   87.77%           
=======================================
  Files          30       30           
  Lines        1562     1562           
=======================================
  Hits         1371     1371           
  Misses        191      191           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to reduce perceived latency when starting a flow on an empty UINavigationController, especially for modal coordinators, by avoiding UIKit’s hidden first-push animation and aligning completion handling with the transition that actually ran.

Changes:

  • Force setViewControllers([vc], animated: false) when seating the first view controller into an empty navigation stack.
  • Split the previous empty-stack/replace-all branch so forceReplaceAllVCsInsteadOfPush can still use the caller’s animated flag on non-empty stacks.
  • Track didAnimate explicitly so completion dispatch uses the transition coordinator only when an actual animated transition occurred.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 147 to +149
} else {
pushViewController(viewController, animated: animated)
didAnimate = animated
Comment on lines +133 to +137
if viewControllers.isEmpty, viewIfLoaded?.window == nil {
// Only suppress the initial empty-stack animation when this nav
// controller is still off-screen. Typical case: a brand-new
// modal nav controller that is about to be
// `present(animated: true)`-ed by the caller. In contrast, an
@Sajjon Sajjon merged commit 9cda1ec into main May 4, 2026
7 checks passed
@Sajjon Sajjon deleted the perf_skip_invisible_first_push branch May 13, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants