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`).