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
2 changes: 1 addition & 1 deletion 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.4</Version>
<Version>5.120.5</Version>
<Authors>OutSystems</Authors>
<Product>ReactView</Product>
<Copyright>Copyright © OutSystems 2023</Copyright>
Expand Down
2 changes: 1 addition & 1 deletion ReactViewControl/ReactView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public abstract partial class ReactView : IDisposable {

private static ReactViewRender CreateReactViewInstance(ReactViewFactory factory) {
ReactViewRender InnerCreateView() {
var view = new ReactViewRender(factory.DefaultStyleSheet, () => factory.InitializePlugins(), factory.EnableViewPreload, factory.EnableDebugMode, factory.EnsureInnerViewsAreDisposed);
var view = new ReactViewRender(factory.DefaultStyleSheet, () => factory.InitializePlugins(), factory.EnableViewPreload, factory.EnableDebugMode, factory.EnsureInnerViewsAreDisposed, factory.LoadScriptsOncePerDocument);
if (factory.ShowDeveloperTools) {
view.ShowDeveloperTools();
}
Expand Down
7 changes: 7 additions & 0 deletions ReactViewControl/ReactViewFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,12 @@ public class ReactViewFactory {
public virtual bool EnableViewPreload => true;

public virtual bool EnsureInnerViewsAreDisposed => true;

/// <summary>
/// Each script is loaded once per document, instead of once per view. Inner views are shadow roots,
/// and shadow dom does not encapsulate scripts, so a script appended for one view has already
/// executed for every other one. Set to false to restore the previous per view behaviour.
/// </summary>
public virtual bool LoadScriptsOncePerDocument => true;
}
}
5 changes: 4 additions & 1 deletion ReactViewControl/ReactViewRender.LoaderModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public LoaderModule(ReactViewRender viewRender) {
/// <summary>
/// Loads the specified react component into the specified frame
/// </summary>
public void LoadComponent(IViewModule component, string frameName, bool hasStyleSheet, bool hasPlugins, bool ensureDisposeInnerViews) {
public void LoadComponent(IViewModule component, string frameName, bool hasStyleSheet, bool hasPlugins, bool ensureDisposeInnerViews, bool loadScriptsOncePerDocument) {
var mainSource = ViewRender.ToFullUrl(NormalizeUrl(component.MainJsSource));
var dependencySources = component.DependencyJsSources.Select(s => ViewRender.ToFullUrl(NormalizeUrl(s))).ToArray();
var cssSources = component.CssSources.Select(s => ViewRender.ToFullUrl(NormalizeUrl(s))).ToArray();
Expand All @@ -43,6 +43,8 @@ public void LoadComponent(IViewModule component, string frameName, bool hasStyle
// componentNativeObject: Dictionary<any>,
// frameName: string
// componentHash: string
// ensureDisposeInnerViews: boolean
// loadScriptsOncePerDocument: boolean

var loadArgs = new[] {
JavascriptSerializer.Serialize(component.Name),
Expand All @@ -57,6 +59,7 @@ public void LoadComponent(IViewModule component, string frameName, bool hasStyle
JavascriptSerializer.Serialize(frameName),
JavascriptSerializer.Serialize(componentHash),
JavascriptSerializer.Serialize(ensureDisposeInnerViews),
JavascriptSerializer.Serialize(loadScriptsOncePerDocument),
};

ExecuteLoaderFunction("loadComponent", loadArgs);
Expand Down
6 changes: 4 additions & 2 deletions ReactViewControl/ReactViewRender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ internal partial class ReactViewRender : IChildViewHost, IDisposable {
private ResourceUrl defaultStyleSheet;
private bool isInputDisabled; // used primarly to control the intention to disable input (before the browser is ready)
private readonly bool ensureDisposeInnerViews;
private readonly bool loadScriptsOncePerDocument;

public ReactViewRender(ResourceUrl defaultStyleSheet, Func<IViewModule[]> initializePlugins, bool preloadWebView, bool enableDebugMode, bool ensureInnerViewsAreDisposed) {
public ReactViewRender(ResourceUrl defaultStyleSheet, Func<IViewModule[]> initializePlugins, bool preloadWebView, bool enableDebugMode, bool ensureInnerViewsAreDisposed, bool loadScriptsOncePerDocument = true) {
this.ensureDisposeInnerViews = ensureInnerViewsAreDisposed;
this.loadScriptsOncePerDocument = loadScriptsOncePerDocument;
UserCallingAssembly = GetUserCallingMethod().ReflectedType.Assembly;

// must useSharedDomain for the local storage to be shared
Expand Down Expand Up @@ -274,7 +276,7 @@ private void TryLoadComponent(FrameInfo frame) {

RegisterNativeObject(frame.Component, frame);

Loader.LoadComponent(frame.Component, frame.Name, DefaultStyleSheet != null, frame.Plugins.Length > 0, ensureDisposeInnerViews);
Loader.LoadComponent(frame.Component, frame.Name, DefaultStyleSheet != null, frame.Plugins.Length > 0, ensureDisposeInnerViews, loadScriptsOncePerDocument);
if (isInputDisabled && frame.IsMain) {
Loader.DisableMouseInteractions();
}
Expand Down
11 changes: 11 additions & 0 deletions ReactViewResources/Loader/Internal/Flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// flags set by the host on the main view load, kept here rather than in ViewMetadataContext because that
// module depends on react, and bootstrap reads flags before react has been defined
const LoadScriptsOncePerDocumentFlagKey = "LOAD_SCRIPTS_ONCE_PER_DOCUMENT";

export function getLoadScriptsOncePerDocumentFlag(): boolean {
return !!window[LoadScriptsOncePerDocumentFlagKey];
}

export function setLoadScriptsOncePerDocumentFlag(loadScriptsOncePerDocument: boolean): void {
window[LoadScriptsOncePerDocumentFlagKey] = loadScriptsOncePerDocument;
}
66 changes: 63 additions & 3 deletions ReactViewResources/Loader/Internal/ResourcesLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,50 @@
import { showWarningMessage } from "./MessagesProvider";
import { Task } from "./Task";
import { ViewMetadata } from "./ViewMetadata";
import { getLoadScriptsOncePerDocumentFlag } from "./Flags";
import { getView } from "./ViewsCollection";

// inner views are shadow roots rather than frames, and shadow dom encapsulates styles but not scripts, so
// a script that has been appended for one view has executed for every other one as well. tracking these
// per view re-executes the same bundle once per view.
const scriptLoadTasks = new Map<string, Task<void>>();

export function loadScript(scriptSrc: string, view: ViewMetadata): Promise<void> {
// bootstrap runs before the flag is set, but it only loads scripts for the main view, whose head is the
// document head and whose per view map is the one the legacy path reads, so both paths behave alike there
if (!getLoadScriptsOncePerDocumentFlag()) {
return loadScriptPerView(scriptSrc, view);
}

const pendingLoad = scriptLoadTasks.get(scriptSrc);
if (pendingLoad) {
return pendingLoad.promise;
}

const loadTask = new Task<void>();
scriptLoadTasks.set(scriptSrc, loadTask);

const script = document.createElement("script");
script.src = scriptSrc;

// a script that fails is dropped, so that a later view can attempt it again. one that times out is
// kept, since it may still arrive
waitForLoad(script, scriptSrc, defaultLoadResourcesTimeout, () => scriptLoadTasks.delete(scriptSrc))
.then(() => loadTask.setResult());

// not the requesting view's head: it may already be detached, and a script in a detached tree never
// runs, so the task above would neither resolve nor fail
document.head.appendChild(script);

return loadTask.promise;
}

/**
* Pre 5.120.5 behaviour, kept behind LoadScriptsOncePerDocument so that it can be restored. Reproduced as it
* was, quirks included: the condition below reads as (ownTask || !isMain) ? mainFrameTask : null, so the
* view's own entry is only ever a truthiness test and inner views never reuse what they registered.
*/
function loadScriptPerView(scriptSrc: string, view: ViewMetadata): Promise<void> {
return new Promise(async (resolve) => {
const frameScripts = view.scriptsLoadTasks;

Expand Down Expand Up @@ -53,7 +94,7 @@ export function loadStyleSheet(stylesheet: string, containerElement: Element, ma
});
}

function waitForLoad<T extends HTMLElement>(element: T, url: string, timeout: number): Promise<T> {
function waitForLoad<T extends HTMLElement>(element: T, url: string, timeout: number, onFailed?: () => void): Promise<T> {
return new Promise((resolve) => {
const timeoutHandle = setTimeout(
() => {
Expand All @@ -63,9 +104,28 @@ function waitForLoad<T extends HTMLElement>(element: T, url: string, timeout: nu
},
timeout);

element.addEventListener("load", () => {
// both listeners capture the element, so whichever outcome happens first has to remove them. the
// timeout only warns and never cleans up, so a resource that loads after it still resolves.
function cleanup(): void {
clearTimeout(timeoutHandle);
element.removeEventListener("load", onLoad);
element.removeEventListener("error", onError);
}

function onLoad(): void {
cleanup();
resolve(element);
});
}

// a failed resource is not reported back, since no caller handles one today
function onError(): void {
cleanup();
if (onFailed) {
onFailed();
}
}

element.addEventListener("load", onLoad);
element.addEventListener("error", onError);
});
}
4 changes: 2 additions & 2 deletions ReactViewResources/Loader/Internal/ViewMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type ViewMetadata = {
placeholder: Element; // element were the view is mounted (where the shadow root is mounted in case of child views)
root?: Element; // view root element
head?: Element; // view head element
scriptsLoadTasks: Map<string, Task<void>>; // maps scripts urls to load tasks
scriptsLoadTasks: Map<string, Task<void>>; // maps script source to load task, only used when scripts are tracked per view
pluginsLoadTask: Task<void>; // plugins load task
viewLoadTask: Task<void>; // resolved when view is loaded
modules: Map<string, any>; // maps module name to module instance
Expand All @@ -30,10 +30,10 @@ export function newView(id: number, name: string, isMain: boolean, placeholder:
head: undefined,
root: undefined,
modules: new Map<string, any>(),
scriptsLoadTasks: new Map<string, Task<void>>(),
nativeObjectNames: [],
pluginsLoadTask: new Task(),
viewLoadTask: new Task(),
scriptsLoadTasks: new Map<string, Task<void>>(),
childViews: new ObservableListCollection<ViewMetadata>(),
context: null,
parentView: null!
Expand Down
6 changes: 5 additions & 1 deletion ReactViewResources/Loader/Loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ViewMetadata } from "./Internal/ViewMetadata";
import { createPropertiesProxy } from "./Internal/ViewPropertiesProxy";
import { addView, getView, tryGetView } from "./Internal/ViewsCollection";
import { setEnsureDisposeInnerViewsFlag } from "./Internal/ViewMetadataContext";
import { setLoadScriptsOncePerDocumentFlag } from "./Internal/Flags";

export { disableMouseInteractions, enableMouseInteractions } from "./Internal/InputManager";
export { showErrorMessage } from "./Internal/MessagesProvider";
Expand Down Expand Up @@ -128,7 +129,8 @@ export function loadComponent(
componentNativeObject: any,
frameName: string,
componentHash: string,
ensureDisposeInnerViews: boolean): void {
ensureDisposeInnerViews: boolean,
loadScriptsOncePerDocument: boolean): void {

async function innerLoad() {
let view: ViewMetadata;
Expand All @@ -140,6 +142,8 @@ export function loadComponent(

if (frameName === mainFrameName) {
setEnsureDisposeInnerViewsFlag(ensureDisposeInnerViews);
// the main view always loads first, so the flag is set before any inner view loads a script
setLoadScriptsOncePerDocumentFlag(loadScriptsOncePerDocument);
}

view = tryGetView(frameName)!;
Expand Down