From 0722bbf3f9d6adeccd8a0a04c0fc460ccffacf6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lder=20Gon=C3=A7alves?= Date: Thu, 6 Aug 2026 15:51:28 +0100 Subject: [PATCH 1/5] RDEV-0000 - Release resource load listeners and pending timeout waitForLoad left its load listener attached to the script or link element after the resource had loaded, and had no error handler at all, so a resource that failed to load kept both the listener and the pending timeout alive for the lifetime of the document. Both listeners are now removed and the timeout cleared as soon as the outcome is known. The timeout still only warns and tears nothing down, so a resource arriving after the warning resolves as before, and a failed resource is still not reported back, since no caller handles one today. Co-authored-by: Cursor --- .../Loader/Internal/ResourcesLoader.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ReactViewResources/Loader/Internal/ResourcesLoader.ts b/ReactViewResources/Loader/Internal/ResourcesLoader.ts index a97e4de1..334d1278 100644 --- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts +++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts @@ -63,9 +63,25 @@ 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(); + } + + element.addEventListener("load", onLoad); + element.addEventListener("error", onError); }); } \ No newline at end of file From 8b42202557c511b650fb078737900afe764140f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lder=20Gon=C3=A7alves?= Date: Thu, 6 Aug 2026 16:01:50 +0100 Subject: [PATCH 2/5] RDEV-0000 - Load each script once per document Inner views are shadow roots rather than frames, and shadow dom encapsulates styles but not scripts, so a script appended for one view has executed for the whole document. loadScript nevertheless tracked loads in a per-view map, with a fallback to the main frame's map. That fallback never worked. `a || !b ? c : null` parses as `(a || !b) ? c : null`, so the view's own entry was only ever used as a truthiness test and the value taken was always the main frame's task. For an inner view `!isMain` is true, making the condition constant, so the lookup only ever consulted the main frame and the entry written for the view itself was unreachable. Inner views therefore re-loaded, and re-executed, scripts another inner view had already loaded. Replaced with a single document-wide map, which is what the lifetime of a script actually matches, and dropped the now unused per-view scriptsLoadTasks. A script that fails is removed so a later view can attempt it again; one that times out is kept, since it may still arrive. The head check now runs before the task is registered. It previously ran after, leaving behind an entry nothing could resolve, and did so inside an async promise executor, where the throw became an unhandled rejection and the caller hung instead. Every call site is async, so it now surfaces as a rejection. Co-authored-by: Cursor --- .../Loader/Internal/ResourcesLoader.ts | 59 ++++++++++--------- .../Loader/Internal/ViewMetadata.ts | 2 - 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/ReactViewResources/Loader/Internal/ResourcesLoader.ts b/ReactViewResources/Loader/Internal/ResourcesLoader.ts index 334d1278..85e7740b 100644 --- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts +++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts @@ -1,39 +1,39 @@ -import { defaultLoadResourcesTimeout, isDebugModeEnabled, mainFrameName } from "./Environment"; +import { defaultLoadResourcesTimeout, isDebugModeEnabled } from "./Environment"; import { showWarningMessage } from "./MessagesProvider"; import { Task } from "./Task"; import { ViewMetadata } from "./ViewMetadata"; -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 { - return new Promise(async (resolve) => { - const frameScripts = view.scriptsLoadTasks; - - // check if script was already added, fallback to main frame - const scriptLoadTask = frameScripts.get(scriptSrc) || !view.isMain ? getView(mainFrameName).scriptsLoadTasks.get(scriptSrc) : null; - if (scriptLoadTask) { - // wait for script to be loaded - await scriptLoadTask.promise; - resolve(); - return; - } + const pendingLoad = scriptLoadTasks.get(scriptSrc); + if (pendingLoad) { + return pendingLoad.promise; + } - const loadTask = new Task(); - frameScripts.set(scriptSrc, loadTask); + // checked before the task is registered, so that a view without a head does not leave behind a load + // that nothing can ever resolve + if (!view.head) { + throw new Error(`View ${view.name} head is not set`); + } - const script = document.createElement("script"); - script.src = scriptSrc; + const loadTask = new Task(); + scriptLoadTasks.set(scriptSrc, loadTask); - waitForLoad(script, scriptSrc, defaultLoadResourcesTimeout) - .then(() => { - loadTask.setResult(); - resolve(); - }); + const script = document.createElement("script"); + script.src = scriptSrc; - if (!view.head) { - throw new Error(`View ${view.name} head is not set`); - } - view.head.appendChild(script); - }); + // 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()); + + view.head.appendChild(script); + + return loadTask.promise; } export function loadStyleSheet(stylesheet: string, containerElement: Element, markAsSticky: boolean): Promise { @@ -53,7 +53,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( () => { @@ -79,6 +79,9 @@ function waitForLoad(element: T, url: string, timeout: nu // a failed resource is not reported back, since no caller handles one today function onError(): void { cleanup(); + if (onFailed) { + onFailed(); + } } element.addEventListener("load", onLoad); diff --git a/ReactViewResources/Loader/Internal/ViewMetadata.ts b/ReactViewResources/Loader/Internal/ViewMetadata.ts index 53426d56..68c37596 100644 --- a/ReactViewResources/Loader/Internal/ViewMetadata.ts +++ b/ReactViewResources/Loader/Internal/ViewMetadata.ts @@ -9,7 +9,6 @@ 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 pluginsLoadTask: Task; // plugins load task viewLoadTask: Task; // resolved when view is loaded modules: Map; // maps module name to module instance @@ -33,7 +32,6 @@ export function newView(id: number, name: string, isMain: boolean, placeholder: nativeObjectNames: [], pluginsLoadTask: new Task(), viewLoadTask: new Task(), - scriptsLoadTasks: new Map>(), childViews: new ObservableListCollection(), context: null, parentView: null! From ccaff302f463c5c3c2d979b35bc1293c66a66b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lder=20Gon=C3=A7alves?= Date: Thu, 6 Aug 2026 16:08:34 +0100 Subject: [PATCH 3/5] RDEV-0000 - Bump patch version to 5.120.5 Both changes on this branch are bug fixes, which the versioning rules in the README take as a patch increment. Co-authored-by: Cursor --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f92485357bd83140512d953db3e45ca8a43b69aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lder=20Gon=C3=A7alves?= Date: Fri, 7 Aug 2026 13:18:24 +0100 Subject: [PATCH 4/5] RDEV-10097 - Append loaded scripts to the document head Scripts execute document wide regardless of which shadow root they are appended to, so the requesting view's head was never the right insertion point - and it may already be detached by the time the load runs. A view can be unmounted, or have its placeholder moved between frames, during the awaits in loadComponent, and view.head is never cleared on unmount, so the !view.head guard passed on a dead element. A script appended to a detached tree never runs: no fetch, no load, no error. With the per document task map that entry would stay pending forever and every later view asking for the same source would await it. Previously each view kept its own map, so the damage died with the view. Appending to document.head removes the case entirely, and with it the view argument and the head check. --- ReactViewResources/Loader/Bootstrap.ts | 12 ++++++------ .../Loader/Internal/ResourcesLoader.ts | 13 ++++--------- ReactViewResources/Loader/Loader.ts | 8 ++++---- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/ReactViewResources/Loader/Bootstrap.ts b/ReactViewResources/Loader/Bootstrap.ts index d68c50d9..f9fbf661 100644 --- a/ReactViewResources/Loader/Bootstrap.ts +++ b/ReactViewResources/Loader/Bootstrap.ts @@ -1,7 +1,7 @@ import { waitForDOMReady } from "./Internal/Common"; import { libsPath, mainFrameName, webViewRootId } from "./Internal/Environment"; import { loadScript } from "./Internal/ResourcesLoader"; -import { newView, ViewMetadata } from "./Internal/ViewMetadata"; +import { newView } from "./Internal/ViewMetadata"; declare function define(name: string, dependencies: string[], definition: Function); @@ -17,20 +17,20 @@ async function bootstrap() { mainView.head = document.head; mainView.root = rootElement; - await loadFramework(mainView); + await loadFramework(); const loader = await import("./Loader"); loader.initialize(mainView); } -async function loadFramework(view: ViewMetadata): Promise { +async function loadFramework(): Promise { const reactLib: string = "React"; const reactDOMLib: string = "ReactDOM"; const externalLibsPath = libsPath + "node_modules/"; - await loadScript(externalLibsPath + "prop-types/prop-types.min.js", view); /* Prop-Types */ - await loadScript(externalLibsPath + "react/umd/react.production.min.js", view); /* React */ - await loadScript(externalLibsPath + "react-dom/umd/react-dom.production.min.js", view); /* ReactDOM */ + await loadScript(externalLibsPath + "prop-types/prop-types.min.js"); /* Prop-Types */ + await loadScript(externalLibsPath + "react/umd/react.production.min.js"); /* React */ + await loadScript(externalLibsPath + "react-dom/umd/react-dom.production.min.js"); /* ReactDOM */ define("react", [], () => window[reactLib]); define("react-dom", [], () => window[reactDOMLib]); diff --git a/ReactViewResources/Loader/Internal/ResourcesLoader.ts b/ReactViewResources/Loader/Internal/ResourcesLoader.ts index 85e7740b..bf31ed51 100644 --- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts +++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts @@ -1,25 +1,18 @@ import { defaultLoadResourcesTimeout, isDebugModeEnabled } from "./Environment"; import { showWarningMessage } from "./MessagesProvider"; import { Task } from "./Task"; -import { ViewMetadata } from "./ViewMetadata"; // 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 { +export function loadScript(scriptSrc: string): Promise { const pendingLoad = scriptLoadTasks.get(scriptSrc); if (pendingLoad) { return pendingLoad.promise; } - // checked before the task is registered, so that a view without a head does not leave behind a load - // that nothing can ever resolve - if (!view.head) { - throw new Error(`View ${view.name} head is not set`); - } - const loadTask = new Task(); scriptLoadTasks.set(scriptSrc, loadTask); @@ -31,7 +24,9 @@ export function loadScript(scriptSrc: string, view: ViewMetadata): Promise waitForLoad(script, scriptSrc, defaultLoadResourcesTimeout, () => scriptLoadTasks.delete(scriptSrc)) .then(() => loadTask.setResult()); - view.head.appendChild(script); + // 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; } diff --git a/ReactViewResources/Loader/Loader.ts b/ReactViewResources/Loader/Loader.ts index 81c8c3cf..3a7de4c2 100644 --- a/ReactViewResources/Loader/Loader.ts +++ b/ReactViewResources/Loader/Loader.ts @@ -84,11 +84,11 @@ export function loadPlugins(plugins: any[][], frameName: string): void { if (view.isMain) { // only load plugins sources once (in the main frame) // load plugin dependency js sources - const dependencySourcesPromises = dependencySources.map(s => loadScript(s, view)); + const dependencySourcesPromises = dependencySources.map(s => loadScript(s)); await Promise.all(dependencySourcesPromises); // plugin main js source - await loadScript(mainJsSource, view); + await loadScript(mainJsSource); } const module = getPluginModule(moduleName) || getViewModule(moduleName); @@ -163,12 +163,12 @@ export function loadComponent( await Promise.all(promisesToWaitFor); // load component dependencies js sources and css sources - const dependencyLoadPromises = dependencySources.map(s => loadScript(s, view) as Promise) + const dependencyLoadPromises = dependencySources.map(s => loadScript(s) as Promise) .concat(cssSources.map(s => loadStyleSheet(s, head, false))); await Promise.all(dependencyLoadPromises); // main component script should be the last to be loaded, otherwise errors might occur - await loadScript(componentSource, view); + await loadScript(componentSource); const renderFinishedTask = cacheEntry ? view.viewLoadTask : null; // create proxy for properties obj to delay its methods execution until native object is ready From 4ad5345826155ffd1966fa5864c2890557ac2ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lder=20Gon=C3=A7alves?= Date: Fri, 7 Aug 2026 14:55:29 +0100 Subject: [PATCH 5/5] RDEV-10097 - Feature protect loading each script once per document Adds LoadScriptsOncePerDocument to ReactViewFactory, defaulting to true, threaded through ReactViewRender and loadComponent the same way as EnsureInnerViewsAreDisposed. The main view always loads first, so the flag is set before any inner view loads a script. When off, loadScriptPerView restores the pre 5.120.5 behaviour exactly, quirks included, so the switch really does return to what shipped. The document wide map and the document head insertion point move together because they are one unit: without the dedupe, appending to the document head would leave one script tag per inner view load instead of one that dies with the shadow root. The flag accessor lives in its own module rather than in ViewMetadataContext, which depends on react. Bootstrap reads the flag through ResourcesLoader before react is defined, and the AMD loader awaits every dependency of a module before running its factory, so that edge would have deadlocked the loader on startup. --- ReactViewControl/ReactView.cs | 2 +- ReactViewControl/ReactViewFactory.cs | 7 +++ .../ReactViewRender.LoaderModule.cs | 5 +- ReactViewControl/ReactViewRender.cs | 6 ++- ReactViewResources/Loader/Bootstrap.ts | 12 ++--- ReactViewResources/Loader/Internal/Flags.ts | 11 ++++ .../Loader/Internal/ResourcesLoader.ts | 50 ++++++++++++++++++- .../Loader/Internal/ViewMetadata.ts | 2 + ReactViewResources/Loader/Loader.ts | 14 ++++-- 9 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 ReactViewResources/Loader/Internal/Flags.ts 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/Bootstrap.ts b/ReactViewResources/Loader/Bootstrap.ts index f9fbf661..d68c50d9 100644 --- a/ReactViewResources/Loader/Bootstrap.ts +++ b/ReactViewResources/Loader/Bootstrap.ts @@ -1,7 +1,7 @@ import { waitForDOMReady } from "./Internal/Common"; import { libsPath, mainFrameName, webViewRootId } from "./Internal/Environment"; import { loadScript } from "./Internal/ResourcesLoader"; -import { newView } from "./Internal/ViewMetadata"; +import { newView, ViewMetadata } from "./Internal/ViewMetadata"; declare function define(name: string, dependencies: string[], definition: Function); @@ -17,20 +17,20 @@ async function bootstrap() { mainView.head = document.head; mainView.root = rootElement; - await loadFramework(); + await loadFramework(mainView); const loader = await import("./Loader"); loader.initialize(mainView); } -async function loadFramework(): Promise { +async function loadFramework(view: ViewMetadata): Promise { const reactLib: string = "React"; const reactDOMLib: string = "ReactDOM"; const externalLibsPath = libsPath + "node_modules/"; - await loadScript(externalLibsPath + "prop-types/prop-types.min.js"); /* Prop-Types */ - await loadScript(externalLibsPath + "react/umd/react.production.min.js"); /* React */ - await loadScript(externalLibsPath + "react-dom/umd/react-dom.production.min.js"); /* ReactDOM */ + await loadScript(externalLibsPath + "prop-types/prop-types.min.js", view); /* Prop-Types */ + await loadScript(externalLibsPath + "react/umd/react.production.min.js", view); /* React */ + await loadScript(externalLibsPath + "react-dom/umd/react-dom.production.min.js", view); /* ReactDOM */ define("react", [], () => window[reactLib]); define("react-dom", [], () => window[reactDOMLib]); 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 bf31ed51..135eb7dd 100644 --- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts +++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts @@ -1,13 +1,22 @@ -import { defaultLoadResourcesTimeout, isDebugModeEnabled } from "./Environment"; +import { defaultLoadResourcesTimeout, isDebugModeEnabled, mainFrameName } from "./Environment"; 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): Promise { +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; @@ -31,6 +40,43 @@ export function loadScript(scriptSrc: string): Promise { 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; + + // check if script was already added, fallback to main frame + const scriptLoadTask = frameScripts.get(scriptSrc) || !view.isMain ? getView(mainFrameName).scriptsLoadTasks.get(scriptSrc) : null; + if (scriptLoadTask) { + // wait for script to be loaded + await scriptLoadTask.promise; + resolve(); + return; + } + + const loadTask = new Task(); + frameScripts.set(scriptSrc, loadTask); + + const script = document.createElement("script"); + script.src = scriptSrc; + + waitForLoad(script, scriptSrc, defaultLoadResourcesTimeout) + .then(() => { + loadTask.setResult(); + resolve(); + }); + + if (!view.head) { + throw new Error(`View ${view.name} head is not set`); + } + view.head.appendChild(script); + }); +} + export function loadStyleSheet(stylesheet: string, containerElement: Element, markAsSticky: boolean): Promise { return new Promise((resolve) => { const link = document.createElement("link"); diff --git a/ReactViewResources/Loader/Internal/ViewMetadata.ts b/ReactViewResources/Loader/Internal/ViewMetadata.ts index 68c37596..a4cf5d66 100644 --- a/ReactViewResources/Loader/Internal/ViewMetadata.ts +++ b/ReactViewResources/Loader/Internal/ViewMetadata.ts @@ -9,6 +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 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 @@ -29,6 +30,7 @@ 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(), diff --git a/ReactViewResources/Loader/Loader.ts b/ReactViewResources/Loader/Loader.ts index 3a7de4c2..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"; @@ -84,11 +85,11 @@ export function loadPlugins(plugins: any[][], frameName: string): void { if (view.isMain) { // only load plugins sources once (in the main frame) // load plugin dependency js sources - const dependencySourcesPromises = dependencySources.map(s => loadScript(s)); + const dependencySourcesPromises = dependencySources.map(s => loadScript(s, view)); await Promise.all(dependencySourcesPromises); // plugin main js source - await loadScript(mainJsSource); + await loadScript(mainJsSource, view); } const module = getPluginModule(moduleName) || getViewModule(moduleName); @@ -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)!; @@ -163,12 +167,12 @@ export function loadComponent( await Promise.all(promisesToWaitFor); // load component dependencies js sources and css sources - const dependencyLoadPromises = dependencySources.map(s => loadScript(s) as Promise) + const dependencyLoadPromises = dependencySources.map(s => loadScript(s, view) as Promise) .concat(cssSources.map(s => loadStyleSheet(s, head, false))); await Promise.all(dependencyLoadPromises); // main component script should be the last to be loaded, otherwise errors might occur - await loadScript(componentSource); + await loadScript(componentSource, view); const renderFinishedTask = cacheEntry ? view.viewLoadTask : null; // create proxy for properties obj to delay its methods execution until native object is ready