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)!;