diff --git a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs index 92eaa90f9..90ec56ec4 100644 --- a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs +++ b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using UnityEditor; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -36,26 +37,77 @@ public void ShouldResolveOnFocusReturn_WhenPlaying_ReturnsFalseAndDefers() } [Test] - public void ConsumeOnEnteredEditMode_WhenDeferred_ReturnsTrueOnceAndClears() + public void ShouldResolveOnPlayModeStateChange_WhenDeferralRestoredAfterReload_ResolvesOnceAndClears() { - // Verifies a deferred focus return runs exactly once after Play Mode ends. + // Verifies a deferral restored from SessionState after a Play Mode domain reload runs exactly once + // when Edit Mode returns, even though this instance never saw the Editor leave Edit Mode. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: true); - bool firstConsume = deferral.ConsumeOnEnteredEditMode(); - bool secondConsume = deferral.ConsumeOnEnteredEditMode(); + bool firstResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); + bool secondResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); - Assert.That(firstConsume, Is.True); - Assert.That(secondConsume, Is.False); + Assert.That(firstResolve, Is.True); + Assert.That(secondResolve, Is.False); Assert.That(deferral.IsDeferred, Is.False); } [Test] - public void ConsumeOnEnteredEditMode_WhenNothingDeferred_ReturnsFalse() + public void ShouldResolveOnPlayModeStateChange_AfterAPlaySession_ResolvesOnceEvenWhenUnfocused() { - // Verifies leaving Play Mode without a skipped focus return does not trigger a resolve. + // Verifies every Play session runs one preflight when Edit Mode returns, without a focus return and + // while unfocused, because Unity's own post-Play import otherwise raises the reload dialog after Stop. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); - Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False); + bool resolvesOnExitingEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.ExitingEditMode, + isFocused: false); + bool resolvesOnEnteredPlayMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredPlayMode, + isFocused: false); + bool resolvesOnEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: false); + bool resolvesOnSecondEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: false); + + Assert.That(resolvesOnExitingEditMode, Is.False); + Assert.That(resolvesOnEnteredPlayMode, Is.False); + Assert.That(resolvesOnEnteredEditMode, Is.True); + Assert.That(resolvesOnSecondEnteredEditMode, Is.False); + } + + [Test] + public void ShouldResolveOnPlayModeStateChange_WhenLeavingEditMode_KeepsFingerprintsAcrossPlayReloads() + { + // Verifies a domain reload while entering or leaving Play Mode keeps the pre-Play fingerprints, + // so an external change made before or during Play is still detected when Edit Mode returns. + ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); + + deferral.ShouldResolveOnPlayModeStateChange(PlayModeStateChange.ExitingEditMode, isFocused: true); + bool shouldRecord = deferral.ShouldRecordBaselineOnInitialize( + isFocused: true, + restoredSceneSnapshots: true); + + Assert.That(shouldRecord, Is.False); + Assert.That(deferral.IsDeferred, Is.True); + } + + [Test] + public void ShouldResolveOnPlayModeStateChange_WhenEditModeReturnsWithoutAPlaySession_DoesNotResolve() + { + // Verifies Edit Mode return resolves only for a scheduled preflight, not on every state change. + ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); + + bool shouldResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); + + Assert.That(shouldResolve, Is.False); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index 72f29e73d..f4154bf86 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -102,6 +102,11 @@ private static void HandleFocusChanged(bool isFocused) private static void HandlePlayModeStateChanged(PlayModeStateChange state) { + bool shouldResolve = _focusReturnDeferral.ShouldResolveOnPlayModeStateChange( + state, + EditorApplication.isFocused); + // The deferral must outlive the domain reloads on both sides of Play Mode. + SessionState.SetBool(FocusReturnDeferredSessionStateKey, _focusReturnDeferral.IsDeferred); if (state != PlayModeStateChange.EnteredEditMode) { return; @@ -115,15 +120,12 @@ private static void HandlePlayModeStateChanged(PlayModeStateChange state) SaveSceneSnapshotsToSessionState(); } - bool shouldResolve = _focusReturnDeferral.ConsumeOnEnteredEditMode(); - SessionState.SetBool(FocusReturnDeferredSessionStateKey, false); VibeLogger.LogInfo( "external_scene_entered_edit_mode", "Entered Edit Mode", new { prunedScenePaths, shouldResolve }, includeStackTrace: false); - // An unfocused Editor gets the same preflight from its next focus return. - if (shouldResolve && EditorApplication.isFocused) + if (shouldResolve) { ResolveForFocusReturn(); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs index ac9f25790..7edea2a9b 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs @@ -1,7 +1,10 @@ +using UnityEditor; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Remembers that a focus-return Scene preflight was skipped during Play Mode so it can run once Edit Mode returns. + /// Remembers that the Scene preflight must run once Edit Mode returns: for a focus return skipped during + /// Play Mode, and for every Play session. /// internal sealed class ExternalSceneFocusReturnDeferral { @@ -28,6 +31,30 @@ public bool ShouldResolveOnFocusReturn(bool isPlayingOrWillChangePlaymode) return false; } + /// + /// Decides whether the preflight must run for this Play Mode transition. Leaving Edit Mode schedules + /// one preflight for when Edit Mode returns, and that preflight runs whether or not the Editor is + /// focused: Unity's own post-Play refresh can import a changed Scene before any focus return, and only + /// the preflight's import-then-reload keeps the reload dialog from appearing. The caller passes the focus + /// state and resolves exactly when this returns true, so ignoring focus stays part of this tested decision + /// instead of a condition the caller could add back. + /// + public bool ShouldResolveOnPlayModeStateChange(PlayModeStateChange state, bool isFocused) + { + if (state == PlayModeStateChange.ExitingEditMode) + { + IsDeferred = true; + return false; + } + + if (state != PlayModeStateChange.EnteredEditMode) + { + return false; + } + + return ConsumeDeferral(); + } + /// /// Decides whether Initialize may replace the restored fingerprints with the current disk state. /// A deferred focus return needs the pre-reload fingerprints to compare against, so re-recording @@ -38,10 +65,7 @@ public bool ShouldRecordBaselineOnInitialize(bool isFocused, bool restoredSceneS return !restoredSceneSnapshots || (isFocused && !IsDeferred); } - /// - /// Returns whether a deferred preflight must run now, and clears the deferral so it runs only once. - /// - public bool ConsumeOnEnteredEditMode() + private bool ConsumeDeferral() { bool wasDeferred = IsDeferred; IsDeferred = false; diff --git a/docs/focus-return-asset-handling.md b/docs/focus-return-asset-handling.md index 6759cc016..4dcb14624 100644 --- a/docs/focus-return-asset-handling.md +++ b/docs/focus-return-asset-handling.md @@ -44,9 +44,17 @@ Scene kept the exception firing on every later focus return. On `PlayModeStateChange.EnteredEditMode` the tracker first drops fingerprints for Scenes that are no longer open in the Editor (runtime-loaded Scenes never get a lasting baseline), then -runs the preflight once if a focus return was deferred and the Editor is focused. An unfocused -Editor leaves it to the next focus return instead. The deferral flag lives in `SessionState` -because leaving Play Mode can trigger a domain reload. The decision is isolated in +runs the preflight once. It does so after every Play session, not only when a focus return +was deferred, and whether or not the Editor is focused: `ExitingEditMode` schedules it. A +Scene file changed during Play Mode, or while the Editor stayed focused, is followed by no +focus return. With Auto Refresh enabled, Unity imports it right after Play Mode ends and +raises the "modified externally" dialog, which also blocks `control-play-mode --action Stop`. +The preflight's import-then-reload keeps the dialog away. This was confirmed on Unity +2022.3.62f3 with domain reload on Play both disabled and enabled (#3047). + +The deferral flag lives in `SessionState` because entering and leaving Play Mode can trigger a +domain reload. While it is set, `Initialize` keeps the restored fingerprints instead of +recording the current disk state, so a change made before or during Play is still detected. The decision is isolated in `ExternalSceneFocusReturnDeferral` and `ExternalSceneSnapshotPruner` (`Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs`).