🐛 fix: PasturaBackButton のルーター読み出しを optional 化 (#1683) - #1684
Merged
Conversation
A TestFlight 1.3 (888) crash on iOS 26.6 terminated the app inside the NavigationStack push of SimulationView: the SwiftUICore `EnvironmentValues.subscript` assertion, reached from `UIKitBarItemHost.initializeSize()` via `BarAppearanceBridge.didMoveToWindow` → `NavigationButtonBar.ItemLayout.updateItemView`. iOS 26 sizes the bar-item host synchronously inside the transition, so the item's separate view graph can read the environment before `TabNavigationStack`'s `.environment(router)` reaches it, and the non-optional `@Environment(AppRouter.self)` traps rather than yielding nil. `PasturaBackButton` is the only toolbar item in the app that reads an `Observable` from the environment (all 42 `ToolbarItem` sites checked), so this one read covers the crash shape app-wide. The nil arm logs a fixed string and calls `dismiss()` instead of returning silently: five of the six callsites rely on the default pop, and `.navigationBarBackButtonHidden(true)` removes the system button, so a quiet nil would trade a crash for a screen with no visible way out. The log line is also the only way to learn in production whether the nil path is ever taken — the crash never reproduced, ADR-009 rules out render tests, and the failure needs a real push transition, so neither CI nor the suite can observe it. Considered and set aside: `sharedBackgroundVisibility(.hidden)` (via `hidingPasturaSharedBackground()`) sits on all six back-button toolbar items and is adjacent to `BarAppearanceBridge` in the stack. It is an appearance modifier and does not change environment propagation; even if it is the trigger, the proximate cause is the non-optional read, and tolerating a missing value removes the crash without giving the Liquid Glass capsule back. Reverting that opt-out is not warranted by one non-reproducible report. Found during the ADR-023 S5-4 soak cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGxx55Kkb7f18Z4FzSwhXV
Applies the three review suggestions on the fix commit. Documentation only — no behaviour change. The doc-comment claimed `dismiss()` "pops a NavigationStack destination the same way". In the exact failure mode it describes, the environment is incompletely propagated, so `dismiss()` can resolve to the default no-op `DismissAction`. What the nil arm reliably buys is the absence of a trap plus a log line; the recovery is a bonus when the environment is merely routerless. Stated that way now, so the next editor does not over-trust it. `navigation.md` gains the general form of the rule: a router read from inside a `ToolbarItem` must be optional, a body-level read stays non-optional. The incident note alone would not stop a future router-reading toolbar component from repeating #1683, and the six body-level reads should keep failing loudly on a genuine wiring mistake. `navigation-qa.md` gains scenario 19 as a triage recipe rather than a walkthrough — the crash cannot be staged on demand, so what a tester can act on is the Console filter that finds the nil arm on a real device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RGxx55Kkb7f18Z4FzSwhXV
) The crash narrative and the "keep the ? " guard already live in PasturaBackButton.swift and docs/qa/navigation-qa.md; navigation.md loads on every Views/App read, so keep only the generalized ToolbarItem rule there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ByMsx9msPnR9beMTrPcXvH
tyabu12
enabled auto-merge (squash)
September 6, 2026 13:08
CI ReportLintSwiftLint: passed Release Build (ADR-005 §8 guard)Release-iphoneos symbol guard: passed Demo Replay Drift GuardDemo replay drift guard: passed Test Results
Coverage74.66% line coverage |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reads
PasturaBackButton's router as an optional@Environment(AppRouter.self)so a missing router degrades instead of terminating the app.A TestFlight 1.3 (888) crash on iOS 26.6 (iPhone17,5, reported during the ADR-023 S5-4 soak) hit the SwiftUICore
EnvironmentValues.subscriptassertion — the "No Observable object of type X found" trap — fromUIKitBarItemHost.initializeSize()viaBarAppearanceBridge.didMoveToWindow→NavigationButtonBar.ItemLayout.updateItemView, inside theNavigationStackpush ofSimulationView. iOS 26 sizes the bar-item host synchronously during the transition, so the item's separate view graph can read the environment beforeTabNavigationStack's.environment(router)reaches it, and the non-optional read traps rather than yielding nil. The only Pastura frames weremain; there were noPasturaSharedEngineframes and the crash preceded.task, so the Kotlin engine behind the S5-4 toggle is not implicated.PasturaBackButtonis the only toolbar item in the app that reads anObservablefrom the environment (all 42ToolbarItemsites checked), so this one read covers the crash shape app-wide.The nil arm logs a fixed string and calls
dismiss()rather than returning silently. Five of the six callsites rely on the default pop and.navigationBarBackButtonHidden(true)removes the system button, so a quiet nil would trade a crash for a screen with no visible way out and nothing in the log to say why. Thedismiss()recovery is documented as best-effort: in the very failure mode described, an incompletely propagated environment can hand back the default no-opDismissAction, so the dependable gain is the absence of a trap plus an observable line.Set aside, with the rationale recorded in the commit body:
sharedBackgroundVisibility(.hidden)(viahidingPasturaSharedBackground()) sits on all six back-button toolbar items and is adjacent toBarAppearanceBridgein the stack. It is an appearance modifier and does not change environment propagation; even if it is the trigger, the proximate cause is the non-optional read, and tolerating a missing value removes the crash without giving the Liquid Glass capsule back. Reverting that opt-out is not warranted by one non-reproducible report.Test plan
scripts/xcodebuild.sh test— full suite green: 3724 unit tests in 303 suites, plus 10 UI tests.swiftlint lint --quiet --strict— clean.ScreenshotTourTestsalready exercises the non-nilrouter.pop()path, so the regression that is testable (breaking the normal tap) stays guarded, and theerror-level log is the substitute instrumentation for the arm that is not.Review
Reviewer model: Opus. Plan critique:
claude-kit:critic(Opus) — no Critical, four Warnings, all folded in before implementation (the loggeddismiss()fallback instead of a silent nil arm, the doc-comment and rule updates, and the recorded rationale for setting aside thesharedBackgroundVisibilityhypothesis).Code review verdict: PASS, 0 Critical / 0 Warning / 3 Suggestions. All three applied in
243b01e4:dismiss()as a guaranteed pop — softened to best-effort with the reason.navigation.mdstated the incident, not the rule — generalized to "a router read from inside aToolbarItemmust be optional; body-level reads stay non-optional", so a future router-reading toolbar component trips the rule instead of repeating 🐛 fix: PasturaBackButton の非 optional な環境読み出しが push 遷移中にクラッシュしうる #1683. The six body-level reads keep failing loudly on a genuine wiring mistake.navigation-qa.mdscenario 19 as a triage recipe: the Console filter (subsystem:app.pastura.Pastura category:PasturaBackButton) that finds the nil arm on a real device, since neither CI nor the suite can observe it.Nothing rejected or left unfixed.
Device QA
Required — the crash is a real-device, real-push-transition failure that the simulator did not surface.
PasturaBackButtonscreens (ScenarioDetailView,ResultsView,ResultDetailView,GalleryScenarioDetailView,ScenarioEditorView,SimulationView), confirm the back chevron pops exactly one screen, by tap and by edge swipe-back.SimulationView, confirm the back tap still routes through the confirm-on-leave dialog mid-run (theaction:override path, ✨ feat: Home P3 PR2 — confirm-on-leave pause for in-flight runs #673).hidingPasturaSharedBackground()opt-out is untouched, so this is a no-change check).subsystem:app.pastura.Pastura category:PasturaBackButton. A line readingno AppRouter in environment; dismissingmeans the nil arm was taken on a real device and belongs on 🐛 fix: PasturaBackButton の非 optional な環境読み出しが push 遷移中にクラッシュしうる #1683.Closes #1683
Part of #501
🤖 Generated with Claude Code
https://claude.ai/code/session_01RGxx55Kkb7f18Z4FzSwhXV