diff --git a/Directory.Build.props b/Directory.Build.props
index 79c882b9..6d1295e4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,7 +5,7 @@
2.0.0.0
2.0.0.0
- 5.120.4
+ 5.120.5
OutSystems
ReactView
Copyright © OutSystems 2023
diff --git a/ReactViewControl/ReactView.cs b/ReactViewControl/ReactView.cs
index 4c31c226..64b850f1 100644
--- a/ReactViewControl/ReactView.cs
+++ b/ReactViewControl/ReactView.cs
@@ -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();
}
diff --git a/ReactViewControl/ReactViewFactory.cs b/ReactViewControl/ReactViewFactory.cs
index ba695754..e08d8c90 100644
--- a/ReactViewControl/ReactViewFactory.cs
+++ b/ReactViewControl/ReactViewFactory.cs
@@ -32,5 +32,12 @@ public class ReactViewFactory {
public virtual bool EnableViewPreload => true;
public virtual bool EnsureInnerViewsAreDisposed => true;
+
+ ///
+ /// 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.
+ ///
+ public virtual bool LoadScriptsOncePerDocument => true;
}
}
diff --git a/ReactViewControl/ReactViewRender.LoaderModule.cs b/ReactViewControl/ReactViewRender.LoaderModule.cs
index cbcef81b..22419457 100644
--- a/ReactViewControl/ReactViewRender.LoaderModule.cs
+++ b/ReactViewControl/ReactViewRender.LoaderModule.cs
@@ -22,7 +22,7 @@ public LoaderModule(ReactViewRender viewRender) {
///
/// Loads the specified react component into the specified frame
///
- 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();
@@ -43,6 +43,8 @@ public void LoadComponent(IViewModule component, string frameName, bool hasStyle
// componentNativeObject: Dictionary,
// frameName: string
// componentHash: string
+ // ensureDisposeInnerViews: boolean
+ // loadScriptsOncePerDocument: boolean
var loadArgs = new[] {
JavascriptSerializer.Serialize(component.Name),
@@ -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);
diff --git a/ReactViewControl/ReactViewRender.cs b/ReactViewControl/ReactViewRender.cs
index bc713cfd..302a6c2b 100644
--- a/ReactViewControl/ReactViewRender.cs
+++ b/ReactViewControl/ReactViewRender.cs
@@ -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 initializePlugins, bool preloadWebView, bool enableDebugMode, bool ensureInnerViewsAreDisposed) {
+ public ReactViewRender(ResourceUrl defaultStyleSheet, Func 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
@@ -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();
}
diff --git a/ReactViewResources/Loader/Internal/Flags.ts b/ReactViewResources/Loader/Internal/Flags.ts
new file mode 100644
index 00000000..19f9db52
--- /dev/null
+++ b/ReactViewResources/Loader/Internal/Flags.ts
@@ -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;
+}
diff --git a/ReactViewResources/Loader/Internal/ResourcesLoader.ts b/ReactViewResources/Loader/Internal/ResourcesLoader.ts
index a97e4de1..135eb7dd 100644
--- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts
+++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts
@@ -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>();
+
export function loadScript(scriptSrc: string, view: ViewMetadata): Promise {
+ // 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();
+ 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 {
return new Promise(async (resolve) => {
const frameScripts = view.scriptsLoadTasks;
@@ -53,7 +94,7 @@ export function loadStyleSheet(stylesheet: string, containerElement: Element, ma
});
}
-function waitForLoad(element: T, url: string, timeout: number): Promise {
+function waitForLoad(element: T, url: string, timeout: number, onFailed?: () => void): Promise {
return new Promise((resolve) => {
const timeoutHandle = setTimeout(
() => {
@@ -63,9 +104,28 @@ function waitForLoad(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);
});
}
\ No newline at end of file
diff --git a/ReactViewResources/Loader/Internal/ViewMetadata.ts b/ReactViewResources/Loader/Internal/ViewMetadata.ts
index 53426d56..a4cf5d66 100644
--- a/ReactViewResources/Loader/Internal/ViewMetadata.ts
+++ b/ReactViewResources/Loader/Internal/ViewMetadata.ts
@@ -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>; // maps scripts urls to load tasks
+ scriptsLoadTasks: Map>; // maps script source to load task, only used when scripts are tracked per view
pluginsLoadTask: Task; // plugins load task
viewLoadTask: Task; // resolved when view is loaded
modules: Map; // maps module name to module instance
@@ -30,10 +30,10 @@ export function newView(id: number, name: string, isMain: boolean, placeholder:
head: undefined,
root: undefined,
modules: new Map(),
+ scriptsLoadTasks: new Map>(),
nativeObjectNames: [],
pluginsLoadTask: new Task(),
viewLoadTask: new Task(),
- scriptsLoadTasks: new Map>(),
childViews: new ObservableListCollection(),
context: null,
parentView: null!
diff --git a/ReactViewResources/Loader/Loader.ts b/ReactViewResources/Loader/Loader.ts
index 81c8c3cf..abb0063e 100644
--- a/ReactViewResources/Loader/Loader.ts
+++ b/ReactViewResources/Loader/Loader.ts
@@ -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";
@@ -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;
@@ -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)!;