, {
}
public render() {
+ // the view can be unloaded (Loader.unloadView) before this frame re-renders
+ const view = this.getView();
return
- {this.shadowRoot && }
+ {this.shadowRoot && view && }
;
}
}
diff --git a/Tests.ReactView/ExecutionEngineDiagnosticsTests.cs b/Tests.ReactView/ExecutionEngineDiagnosticsTests.cs
new file mode 100644
index 00000000..786ee71c
--- /dev/null
+++ b/Tests.ReactView/ExecutionEngineDiagnosticsTests.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using ReactViewControl;
+
+namespace Tests.ReactView {
+
+ // Plain fixture on purpose: the stopped-engine behavior needs no browser.
+ public class ExecutionEngineDiagnosticsTests {
+
+ private class FakeModule : ViewModuleContainer {
+ protected override string ModuleName => "FakeModule";
+ }
+
+ private class Unsubscriber : IDisposable {
+ private readonly Action dispose;
+ public Unsubscriber(Action dispose) => this.dispose = dispose;
+ public void Dispose() => dispose();
+ }
+
+ private static IDisposable CaptureDiagnostics(List sink) {
+ void OnMessage(string message) => sink.Add(message);
+ ReactViewDiagnostics.Message += OnMessage;
+ return new Unsubscriber(() => ReactViewDiagnostics.Message -= OnMessage);
+ }
+
+ [Test(Description = "A call executed before the engine starts is buffered and reported, not lost silently")]
+ public void CallOnStoppedEngineIsBufferedAndReported() {
+ var messages = new List();
+ using (CaptureDiagnostics(messages)) {
+ var engine = new ExecutionEngine();
+ IViewModule module = new FakeModule();
+
+ engine.ExecuteMethod(module, "refreshInnerPanes");
+
+ Assert.That(messages, Has.Some.Contains("buffered"), "buffering must be surfaced through diagnostics");
+ Assert.That(messages, Has.Some.Contains("refreshInnerPanes"), "the diagnostic must name the buffered call");
+ }
+ }
+
+ [Test(Description = "An evaluation on a stopped engine answers default and is reported, not silent")]
+ public async Task EvaluateOnStoppedEngineAnswersDefaultAndReports() {
+ var messages = new List();
+ using (CaptureDiagnostics(messages)) {
+ var engine = new ExecutionEngine();
+ IViewModule module = new FakeModule();
+
+ var result = await engine.EvaluateMethodAsync(module, "getBottomPaneInfo");
+
+ Assert.AreEqual(0, result, "a stopped engine answers default(T)");
+ Assert.That(messages, Has.Some.Contains("default value"), "the default answer must be surfaced through diagnostics");
+ }
+ }
+ }
+}
diff --git a/Tests.ReactView/MainContextLossTests.cs b/Tests.ReactView/MainContextLossTests.cs
new file mode 100644
index 00000000..81d8e306
--- /dev/null
+++ b/Tests.ReactView/MainContextLossTests.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using ReactViewControl;
+
+namespace Tests.ReactView {
+
+ // Regression coverage for the superseded-main-context defect (RDPIM-4385): a main context release
+ // used to run the document-gone cleanup immediately, stranding every live inner view. The cleanup
+ // is now deferred until a new main view initializes.
+ public class MainContextLossTests : ReactViewTestBase {
+
+ // Fires the real (private) release handler; a stray release cannot be produced on demand otherwise.
+ private static void SimulateMainContextReleased(ReactViewControl.ReactView view) {
+ var render = typeof(ReactViewControl.ReactView)
+ .GetProperty("View", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .GetValue(view)!;
+ render.GetType()
+ .GetMethod("OnWebViewJavascriptContextReleased", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(render, new object[] { FrameInfo.MainViewFrameName });
+ }
+
+ private ConcurrentQueue diagnostics;
+
+ protected override void InitializeView() {
+ diagnostics = new ConcurrentQueue();
+ ReactViewDiagnostics.Message += OnDiagnosticMessage;
+ if (TargetView != null) {
+ TargetView.AutoShowInnerView = true;
+ }
+ base.InitializeView();
+ }
+
+ [TearDown]
+ public void DetachDiagnostics() {
+ ReactViewDiagnostics.Message -= OnDiagnosticMessage;
+ }
+
+ private void OnDiagnosticMessage(string message) => diagnostics.Enqueue(message);
+
+ [Test(Description = "A stray main-context release must not break the inner view: calls still arrive")]
+ public async Task InnerViewSurvivesStrayMainContextRelease() {
+ await Run(async () => {
+ var loaded = new TaskCompletionSource();
+ TargetView.InnerView.Loaded += () => loaded.TrySetResult(true);
+ TargetView.InnerView.Load();
+ await loaded.Task;
+
+ SimulateMainContextReleased(TargetView);
+
+ var methodCalled = new TaskCompletionSource();
+ TargetView.InnerView.MethodCalled += _ => methodCalled.TrySetResult(true);
+ TargetView.InnerView.TestMethod();
+
+ var completed = await Task.WhenAny(methodCalled.Task, Task.Delay(TimeSpan.FromSeconds(10)));
+ Assert.AreSame(methodCalled.Task, completed, "inner view stopped answering after a stray main-context release");
+ Assert.IsFalse(diagnostics.Any(m => m.Contains("buffered")), "no call may be buffered on a stopped engine after a stray release");
+ });
+ }
+
+ [Test(Description = "A reload after a main-context release runs the deferred cleanup and comes back functional")]
+ public async Task ReloadAfterMainContextReleaseCleansStaleFrames() {
+ await Run(async () => {
+ var loaded = new TaskCompletionSource();
+ TargetView.InnerView.Loaded += () => loaded.TrySetResult(true);
+ TargetView.InnerView.Load();
+ await loaded.Task;
+
+ SimulateMainContextReleased(TargetView);
+
+ var reloaded = new TaskCompletionSource();
+ TargetView.InnerView.Loaded += () => reloaded.TrySetResult(true);
+ TargetView.ExecuteMethod("reload");
+ await reloaded.Task;
+
+ Assert.IsTrue(diagnostics.Any(m => m.Contains("stale frame")), "the deferred cleanup must run when the new main view initializes");
+
+ var methodCalled = new TaskCompletionSource();
+ TargetView.InnerView.MethodCalled += _ => methodCalled.TrySetResult(true);
+ TargetView.InnerView.TestMethod();
+ var completed = await Task.WhenAny(methodCalled.Task, Task.Delay(TimeSpan.FromSeconds(10)));
+ Assert.AreSame(methodCalled.Task, completed, "inner view must be functional after the reload");
+ });
+ }
+ }
+}
diff --git a/Tests.ReactView/UnloadChildViewTests.cs b/Tests.ReactView/UnloadChildViewTests.cs
new file mode 100644
index 00000000..253b091d
--- /dev/null
+++ b/Tests.ReactView/UnloadChildViewTests.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using ReactViewControl;
+
+namespace Tests.ReactView {
+
+ public class UnloadChildViewTests : ReactViewTestBase {
+
+ private ConcurrentQueue diagnostics;
+
+ protected override void InitializeView() {
+ diagnostics = new ConcurrentQueue();
+ ReactViewDiagnostics.Message += OnDiagnosticMessage;
+ if (TargetView != null) {
+ TargetView.AutoShowInnerView = true;
+ }
+ base.InitializeView();
+ }
+
+ [TearDown]
+ public void DetachDiagnostics() {
+ ReactViewDiagnostics.Message -= OnDiagnosticMessage;
+ }
+
+ private void OnDiagnosticMessage(string message) => diagnostics.Enqueue(message);
+
+ [Test(Description = "The host can unload a child view directly, without a react re-render of its owner")]
+ public async Task HostInitiatedUnloadReleasesTheChildView() {
+ await Run(async () => {
+ var loaded = new TaskCompletionSource();
+ TargetView.InnerView.Loaded += () => loaded.TrySetResult(true);
+ TargetView.InnerView.Load();
+ await loaded.Task;
+
+ TargetView.UnloadChildView("test");
+
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
+ while (DateTime.UtcNow < deadline && !diagnostics.Any(m => m.Contains("'test' destroyed"))) {
+ await Task.Delay(100);
+ }
+
+ Assert.IsTrue(diagnostics.Any(m => m.Contains("'test' unloaded")), "the native side must be released eagerly");
+ Assert.IsTrue(diagnostics.Any(m => m.Contains("'test' destroyed")), "the JS side must tear the view down");
+ });
+ }
+ }
+}