Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<FileVersion>2.0.0.0</FileVersion>
<!-- Please see https://github.com/OutSystems/reactview?tab=readme-ov-file#versioning for versioning rules -->
<Version>5.120.6</Version>
<Version>5.120.7</Version>
<Authors>OutSystems</Authors>
<Product>ReactView</Product>
<Copyright>Copyright © OutSystems 2023</Copyright>
Expand All @@ -18,7 +18,7 @@

<PropertyGroup>
<AvaloniaVersion>11.0.10</AvaloniaVersion>
<WebViewVersion>3.120.10</WebViewVersion>
<WebViewVersion>3.120.12</WebViewVersion>
</PropertyGroup>

<PropertyGroup Condition="'$(Platform)' == '' or '$(Platform)' == 'x64'">
Expand Down
2 changes: 2 additions & 0 deletions ReactViewControl.Avalonia/ReactViewControl.Avalonia.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
<Compile Include="..\ReactViewControl\IFrame.cs" Link="IFrame.cs" />
<Compile Include="..\ReactViewControl\IViewModule.cs" Link="IViewModule.cs" />
<Compile Include="..\ReactViewControl\LoadStatus.cs" Link="LoadStatus.cs" />
<Compile Include="..\ReactViewControl\Properties\AssemblyInfo.cs" Link="Properties\AssemblyInfo.cs" />
<Compile Include="..\ReactViewControl\ReactView.cs" Link="ReactView.cs" />
<Compile Include="..\ReactViewControl\ReactViewDiagnostics.cs" Link="ReactViewDiagnostics.cs" />
<Compile Include="..\ReactViewControl\ReactViewFactory.cs" Link="ReactViewFactory.cs" />
<Compile Include="..\ReactViewControl\ReactViewRender.cs" Link="ReactViewRender.cs" />
<Compile Include="..\ReactViewControl\ReactViewRender.LoaderModule.cs" Link="ReactViewRender.LoaderModule.cs" />
Expand Down
5 changes: 5 additions & 0 deletions ReactViewControl/ExecutionEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ public void ExecuteMethod(IViewModule module, string methodCall, params object[]
webView.ExecuteScriptFunctionWithSerializedParams(method, args);
} else {
PendingExecutions.Enqueue(Tuple.Create(module, methodCall, args));
ReactViewDiagnostics.Log($"Call '{methodCall}' to module '{module.Name}' was buffered: execution engine not started (queued: {PendingExecutions.Count})");
}
}

public T EvaluateMethod<T>(IViewModule module, string methodCall, params object[] args) => EvaluateMethodAsync<T>(module, methodCall, args).Result;

public Task<T> EvaluateMethodAsync<T>(IViewModule module, string methodCall, params object[] args) {
if (webView == null) {
ReactViewDiagnostics.Log($"Evaluate '{methodCall}' on module '{module.Name}' answered with a default value: execution engine not started");
return Task.FromResult<T>(default);
}
module.Host?.HandledBeforeExecuteMethod();
Expand All @@ -42,6 +44,9 @@ public Task<T> EvaluateMethodAsync<T>(IViewModule module, string methodCall, par
}

public void Start(ExtendedWebView webView, string frameName, string id) {
if (!PendingExecutions.IsEmpty) {
ReactViewDiagnostics.Log($"Execution engine started for '{frameName}': flushing {PendingExecutions.Count} buffered call(s)");
}
this.id = id;
this.frameName = frameName;
this.webView = webView;
Expand Down
7 changes: 7 additions & 0 deletions ReactViewControl/ReactView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ public T WithPlugin<T>() {
/// </summary>
public bool IsReady => View.IsReady;

/// <summary>
/// Unloads a child view, releasing its renderer-side resources even if the react tree that owns
/// its frame never re-renders.
/// </summary>
/// <param name="frameName"></param>
public void UnloadChildView(string frameName) => View.UnloadChildView(frameName);

/// <summary>
/// Gets or sets the control zoom percentage (1 = 100%)
/// </summary>
Expand Down
15 changes: 15 additions & 0 deletions ReactViewControl/ReactViewDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace ReactViewControl {

/// <summary>
/// Surfaces view-lifecycle situations that would otherwise fail silently. The library stays
/// logger-free: hosts subscribe and route the messages into their own logging or telemetry.
/// </summary>
public static class ReactViewDiagnostics {

public static event Action<string> Message;

internal static void Log(string message) => Message?.Invoke(message);
}
}
11 changes: 10 additions & 1 deletion ReactViewControl/ReactViewRender.LoaderModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ public void LoadPlugins(IViewModule[] plugins, string frameName) {
ExecuteLoaderFunction("loadPlugins", loadArgs);
}

/// <summary>
/// Unloads the specified child view without waiting for the react tree that owns its
/// frame to re-render.
/// </summary>
/// <param name="frameName"></param>
public void UnloadView(string frameName) {
ExecuteLoaderFunction("unloadView", JavascriptSerializer.Serialize(frameName));
}

/// <summary>
/// Shows an resource load error message for the spcified url.
/// </summary>
Expand Down Expand Up @@ -135,7 +144,7 @@ public void EnableMouseInteractions() {
private void ExecuteLoaderFunction(string functionName, params string[] args) {
// using setimeout we make sure the function is already defined
var loaderUrl = new ResourceUrl(ResourcesAssembly, ReactViewResources.Resources.LoaderUrl);
ViewRender.WebView.ExecuteScript($"import('{loaderUrl}').then(m => m.default.{LoaderModuleName}).then({LoaderModuleName} => {LoaderModuleName}.{functionName}({string.Join(",", args)}))");
ViewRender.WebView.ExecuteScript($"import('{loaderUrl}').then(m => m.default.{LoaderModuleName}).then({LoaderModuleName} => {LoaderModuleName}.{functionName}({string.Join(",", args)})).catch(e => console.error('Loader.{functionName} failed: ' + (e && (e.stack || e.message) || e)))");
}

private static string SerializeComponent(IViewModule component) {
Expand Down
6 changes: 6 additions & 0 deletions ReactViewControl/ReactViewRender.NativeAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public static void Initialize(ReactViewRender viewRender) {
/// </summary>
public void NotifyViewInitialized(string frameName) {
lock (ViewRender.SyncRoot) {
if (frameName == FrameInfo.MainViewFrameName) {
// a new main view proves the released context was really replaced; children register after it
ViewRender.RunPendingContextLossCleanup();
}

var frame = ViewRender.GetOrCreateFrame(frameName);
frame.LoadStatus = LoadStatus.ViewInitialized;

Expand Down Expand Up @@ -71,6 +76,7 @@ public void NotifyViewLoaded(string frameName, string id) {
/// </summary>
public void NotifyViewDestroyed(string frameName) {
lock (ViewRender.SyncRoot) {
ReactViewDiagnostics.Log($"View '{frameName}' destroyed");
if (ViewRender.Frames.TryGetValue(frameName, out var frame)) {
IEnumerable<IViewModule> modules = frame.Plugins;
if (frame.Component != null) {
Expand Down
47 changes: 46 additions & 1 deletion ReactViewControl/ReactViewRender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal partial class ReactViewRender : IChildViewHost, IDisposable {

private Dictionary<string, FrameInfo> Frames { get; } = new Dictionary<string, FrameInfo>();
private Dictionary<string, WeakReference<FrameInfo>> RecoverableFrames { get; } = new Dictionary<string, WeakReference<FrameInfo>>();
private bool hasPendingContextLossCleanup;
private Dictionary<string, WeakReference<IViewModule>> ChildViewModules { get; } = new Dictionary<string, WeakReference<IViewModule>>();

private ExtendedWebView WebView { get; }
Expand Down Expand Up @@ -189,7 +190,9 @@ public event ResourceLoadFailedEventHandler ResourceLoadFailed {
internal EditCommands EditCommands { get; }

/// <summary>
/// Javascript context was destroyed, cleanup everything.
/// Javascript context was released. A release can arrive out of order with its replacement's
/// creation, so the cleanup waits for the new main view to initialize instead of running here,
/// which would strand the current document's live views.
/// </summary>
/// <param name="frameName"></param>
private void OnWebViewJavascriptContextReleased(string frameName) {
Expand All @@ -199,6 +202,22 @@ private void OnWebViewJavascriptContextReleased(string frameName) {
}

lock (SyncRoot) {
hasPendingContextLossCleanup = true;
}
ReactViewDiagnostics.Log("Main javascript context released: stale frames will be cleaned when a new main view initializes");
}

/// <summary>
/// Discards the frames of a document whose main javascript context was lost. Runs before the
/// replacement's child views register, so same-name views get fresh frames.
/// </summary>
private void RunPendingContextLossCleanup() {
lock (SyncRoot) {
if (!hasPendingContextLossCleanup) {
return;
}
hasPendingContextLossCleanup = false;

var mainFrame = Frames[FrameInfo.MainViewFrameName];

Frames.Remove(mainFrame.Name);
Expand All @@ -208,6 +227,8 @@ private void OnWebViewJavascriptContextReleased(string frameName) {
UnregisterNativeObject(keyValuePair.Value.Component, keyValuePair.Value);
}

ReactViewDiagnostics.Log($"Cleaned {Frames.Count} stale frame(s) after a main javascript context loss");

Frames.Clear();
Frames.Add(mainFrame.Name, mainFrame);
ChildViewModules.Clear();
Expand All @@ -217,6 +238,30 @@ private void OnWebViewJavascriptContextReleased(string frameName) {
}
}

/// <summary>
/// Unloads a child view without waiting for the react tree that owns its frame to re-render.
/// Both sides are idempotent with the regular ViewFrame unmount teardown.
/// </summary>
/// <param name="frameName"></param>
public void UnloadChildView(string frameName) {
Loader.UnloadView(frameName);

lock (SyncRoot) {
if (Frames.TryGetValue(frameName, out var frame) && !frame.IsMain) {
IEnumerable<IViewModule> modules = frame.Plugins;
if (frame.Component != null) {
modules = modules.Concat(new[] { frame.Component });
}
foreach (var module in modules) {
UnregisterNativeObject(module, frame);
}
Frames.Remove(frameName);
ReactViewDiagnostics.Log($"View '{frameName}' unloaded on host initiative");
}
ChildViewModules.Remove(frameName);
}
}

public void Dispose() {
WebView.Dispose();
}
Expand Down
7 changes: 7 additions & 0 deletions ReactViewResources/Loader/Internal/ObservableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export abstract class ObservableCollection<T> {
this.listeners.push(listener);
}

public removeChangedListener(listener: CollectionChangedListener<T>) {
const index = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}

private triggerCollectionChangedListeners(item: T, operation: Operation) {
this.listeners.forEach(l => l(item, operation));
}
Expand Down
12 changes: 12 additions & 0 deletions ReactViewResources/Loader/Loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ import { setEnsureViewPluginsAreDisposedFlag, setLoadScriptsOncePerDocumentFlag
export { disableMouseInteractions, enableMouseInteractions } from "./Internal/InputManager";
export { showErrorMessage } from "./Internal/MessagesProvider";

/**
* Unloads a child view without waiting for its owner ViewFrame to re-render. Removing the view from
* its parent's collection drives the regular portal teardown.
*/
export function unloadView(viewName: string): void {
const view = tryGetView(viewName);
if (!view || view.isMain || !view.parentView) {
return;
}
view.parentView.childViews.remove(view);
}

const bootstrapTask = new Task();
const defaultStylesheetLoadTask = new Task();

Expand Down
16 changes: 15 additions & 1 deletion ReactViewResources/Loader/Public/ViewFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,16 @@ class InternalViewFrame<T> extends React.Component<IInternalViewFrameProps<T>, {
return this.parentView.childViews.items.find(c => c.name === fullName);
}

private onChildViewsChanged = () => {
if (!this.getView()) {
// view unloaded from outside (Loader.unloadView): drop the portal so it tears down
this.forceUpdate();
}
};

public componentDidMount() {
this.parentView.childViews.addChangedListener(this.onChildViewsChanged);

const existingView = this.getView();
if (existingView) {
// update the existing view generation
Expand All @@ -114,6 +123,9 @@ class InternalViewFrame<T> extends React.Component<IInternalViewFrameProps<T>, {
}

public componentWillUnmount() {
// stop listening before the removal below re-enters this frame
this.parentView.childViews.removeChangedListener(this.onChildViewsChanged);

if (this.replacement) {
// put back the original container, otherwise react will complain
this.replacement.parentElement!.replaceChild(this.placeholder, this.replacement);
Expand All @@ -128,8 +140,10 @@ class InternalViewFrame<T> extends React.Component<IInternalViewFrameProps<T>, {
}

public render() {
// the view can be unloaded (Loader.unloadView) before this frame re-renders
const view = this.getView();
return <div ref={this.setPlaceholder} className={this.props.className}>
{this.shadowRoot && <ViewPortal view={this.getView()!} shadowRoot={this.shadowRoot} />}
{this.shadowRoot && view && <ViewPortal view={view} shadowRoot={this.shadowRoot} />}
</div>;
}
}
Expand Down
56 changes: 56 additions & 0 deletions Tests.ReactView/ExecutionEngineDiagnosticsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string>();
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<string>();
using (CaptureDiagnostics(messages)) {
var engine = new ExecutionEngine();
IViewModule module = new FakeModule();

var result = await engine.EvaluateMethodAsync<int>(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");
}
}
}
}
Loading
Loading