From c95ec9e5c35bd0531b5a4cf54ed06a23add29947 Mon Sep 17 00:00:00 2001
From: Benjamin Franzke <ben@bnf.dev>
Date: Tue, 5 Sep 2023 22:12:07 +0200
Subject: [PATCH] [BUGFIX] Fix race condition in module router

The module components (like the iframe wrapper) are
created asynchronously after their implementing module
(e.g. <typo3-iframe-module>) is loaded.

The router had a preparatory check that verified
that existing module elements are reused, but missed
to verifiy whether the element has been created in
parallel while the module components javascript module
was loaded. This happens when the module changes
while the module component is loaded for the first time.

That leads to a situation that the module componenent
was created twice, which rendered two module frames
into the backend.

Also resolve a long overdue todo while at it.

Resolves: #101851
Releases: main, 12.4, 11.5
Change-Id: I069f9e924d38b3abeb70c6d4e805d9c43a4f05e9
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/80893
Tested-by: Benjamin Franzke <ben@bnf.dev>
Reviewed-by: Benjamin Franzke <ben@bnf.dev>
Tested-by: core-ci <typo3@b13.com>
---
 Build/Sources/TypeScript/backend/module/router.ts        | 9 ++++++++-
 .../backend/Resources/Public/JavaScript/module/router.js | 2 +-
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/Build/Sources/TypeScript/backend/module/router.ts b/Build/Sources/TypeScript/backend/module/router.ts
index b38746a82a89..bfcea526cc61 100644
--- a/Build/Sources/TypeScript/backend/module/router.ts
+++ b/Build/Sources/TypeScript/backend/module/router.ts
@@ -158,7 +158,14 @@ export class ModuleRouter extends LitElement {
 
     try {
       const module = await import(moduleName + '.js');
-      // @todo: Check if .componentName exists
+      element = this.querySelector(`*[slot="${moduleName}"]`);
+      if (element !== null) {
+        // The element has been created parallelly during the asynchronous module load; use that instance
+        return element;
+      }
+      if (!('componentName' in module)) {
+        throw new Error(`module ${moduleName} is missing the "componentName" export`);
+      }
       element = document.createElement(module.componentName);
     } catch (e) {
       console.error({ msg: `Error importing ${moduleName} as backend module`, err: e });
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/module/router.js b/typo3/sysext/backend/Resources/Public/JavaScript/module/router.js
index 319e33843072..023b9aeddec9 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/module/router.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/module/router.js
@@ -10,7 +10,7 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-var __decorate=function(t,e,o,r){var i,l=arguments.length,n=l<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,o,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(n=(l<3?i(n):l>3?i(e,o,n):i(e,o))||n);return l>3&&n&&Object.defineProperty(e,o,n),n};import{html,css,LitElement}from"lit";import{customElement,property,query}from"lit/decorators.js";import{ModuleUtility}from"@typo3/backend/module.js";const IFRAME_COMPONENT="@typo3/backend/module/iframe",alwaysUpdate=()=>!0;let ModuleRouter=class extends LitElement{constructor(){super(),this.module="",this.endpoint="",this.addEventListener("typo3-module-load",(({target:t,detail:e})=>{const o=t.getAttribute("slot");this.pushState({slotName:o,detail:e})})),this.addEventListener("typo3-module-loaded",(({detail:t})=>{this.updateBrowserState(t)})),this.addEventListener("typo3-iframe-load",(({detail:t})=>{let e={slotName:IFRAME_COMPONENT,detail:t};if(e.detail.url.includes(this.stateTrackerUrl+"?state=")){const t=e.detail.url.split("?state=");e=JSON.parse(decodeURIComponent(t[1]||"{}"))}this.slotElement.getAttribute("name")!==e.slotName&&this.slotElement.setAttribute("name",e.slotName),this.markActive(e.slotName,this.slotElement.getAttribute("name")===IFRAME_COMPONENT?null:e.detail.url,!1),this.updateBrowserState(e.detail),this.parentElement.dispatchEvent(new CustomEvent("typo3-module-load",{bubbles:!0,composed:!0,detail:e.detail}))})),this.addEventListener("typo3-iframe-loaded",(({detail:t})=>{this.updateBrowserState(t),this.parentElement.dispatchEvent(new CustomEvent("typo3-module-loaded",{bubbles:!0,composed:!0,detail:t}))}))}render(){const t=ModuleUtility.getFromName(this.module).component||IFRAME_COMPONENT;return html`<slot name="${t}"></slot>`}updated(){const t=ModuleUtility.getFromName(this.module).component||IFRAME_COMPONENT;this.markActive(t,this.endpoint)}async markActive(t,e,o=!0){const r=await this.getModuleElement(t);e&&(o||r.getAttribute("endpoint")!==e)&&r.setAttribute("endpoint",e),r.hasAttribute("active")||r.setAttribute("active","");for(let t=r.previousElementSibling;null!==t;t=t.previousElementSibling)t.removeAttribute("active");for(let t=r.nextElementSibling;null!==t;t=t.nextElementSibling)t.removeAttribute("active")}async getModuleElement(t){let e=this.querySelector(`*[slot="${t}"]`);if(null!==e)return e;try{const o=await import(t+".js");e=document.createElement(o.componentName)}catch(e){throw console.error({msg:`Error importing ${t} as backend module`,err:e}),e}return e.setAttribute("slot",t),this.appendChild(e),e}async pushState(t){const e=this.stateTrackerUrl+"?state="+encodeURIComponent(JSON.stringify(t));(await this.getModuleElement(IFRAME_COMPONENT)).setAttribute("endpoint",e)}updateBrowserState(t){const e=new URL(t.url||"",window.location.origin),o=new URLSearchParams(e.search),r="title"in t?t.title:"";if(null!==r){const t=[this.sitename];""!==r&&t.unshift(r),this.sitenameFirst&&t.reverse(),document.title=t.join(" · ")}if(!o.has("token")){if(!o.has("install[controller]"))return;{const t=o.get("install[controller]");o.delete("install[controller]"),o.delete("install[context]"),e.pathname=e.pathname.replace("/typo3/install.php","/typo3/module/tools/"+t)}}o.delete("token"),e.search=o.toString();const i=e.toString();window.history.replaceState(t,"",i)}};ModuleRouter.styles=css`
+var __decorate=function(t,e,o,r){var i,l=arguments.length,n=l<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,o,r);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(n=(l<3?i(n):l>3?i(e,o,n):i(e,o))||n);return l>3&&n&&Object.defineProperty(e,o,n),n};import{html,css,LitElement}from"lit";import{customElement,property,query}from"lit/decorators.js";import{ModuleUtility}from"@typo3/backend/module.js";const IFRAME_COMPONENT="@typo3/backend/module/iframe",alwaysUpdate=()=>!0;let ModuleRouter=class extends LitElement{constructor(){super(),this.module="",this.endpoint="",this.addEventListener("typo3-module-load",(({target:t,detail:e})=>{const o=t.getAttribute("slot");this.pushState({slotName:o,detail:e})})),this.addEventListener("typo3-module-loaded",(({detail:t})=>{this.updateBrowserState(t)})),this.addEventListener("typo3-iframe-load",(({detail:t})=>{let e={slotName:IFRAME_COMPONENT,detail:t};if(e.detail.url.includes(this.stateTrackerUrl+"?state=")){const t=e.detail.url.split("?state=");e=JSON.parse(decodeURIComponent(t[1]||"{}"))}this.slotElement.getAttribute("name")!==e.slotName&&this.slotElement.setAttribute("name",e.slotName),this.markActive(e.slotName,this.slotElement.getAttribute("name")===IFRAME_COMPONENT?null:e.detail.url,!1),this.updateBrowserState(e.detail),this.parentElement.dispatchEvent(new CustomEvent("typo3-module-load",{bubbles:!0,composed:!0,detail:e.detail}))})),this.addEventListener("typo3-iframe-loaded",(({detail:t})=>{this.updateBrowserState(t),this.parentElement.dispatchEvent(new CustomEvent("typo3-module-loaded",{bubbles:!0,composed:!0,detail:t}))}))}render(){const t=ModuleUtility.getFromName(this.module).component||IFRAME_COMPONENT;return html`<slot name="${t}"></slot>`}updated(){const t=ModuleUtility.getFromName(this.module).component||IFRAME_COMPONENT;this.markActive(t,this.endpoint)}async markActive(t,e,o=!0){const r=await this.getModuleElement(t);e&&(o||r.getAttribute("endpoint")!==e)&&r.setAttribute("endpoint",e),r.hasAttribute("active")||r.setAttribute("active","");for(let t=r.previousElementSibling;null!==t;t=t.previousElementSibling)t.removeAttribute("active");for(let t=r.nextElementSibling;null!==t;t=t.nextElementSibling)t.removeAttribute("active")}async getModuleElement(t){let e=this.querySelector(`*[slot="${t}"]`);if(null!==e)return e;try{const o=await import(t+".js");if(e=this.querySelector(`*[slot="${t}"]`),null!==e)return e;if(!("componentName"in o))throw new Error(`module ${t} is missing the "componentName" export`);e=document.createElement(o.componentName)}catch(e){throw console.error({msg:`Error importing ${t} as backend module`,err:e}),e}return e.setAttribute("slot",t),this.appendChild(e),e}async pushState(t){const e=this.stateTrackerUrl+"?state="+encodeURIComponent(JSON.stringify(t));(await this.getModuleElement(IFRAME_COMPONENT)).setAttribute("endpoint",e)}updateBrowserState(t){const e=new URL(t.url||"",window.location.origin),o=new URLSearchParams(e.search),r="title"in t?t.title:"";if(null!==r){const t=[this.sitename];""!==r&&t.unshift(r),this.sitenameFirst&&t.reverse(),document.title=t.join(" · ")}if(!o.has("token")){if(!o.has("install[controller]"))return;{const t=o.get("install[controller]");o.delete("install[controller]"),o.delete("install[context]"),e.pathname=e.pathname.replace("/typo3/install.php","/typo3/module/tools/"+t)}}o.delete("token"),e.search=o.toString();const i=e.toString();window.history.replaceState(t,"",i)}};ModuleRouter.styles=css`
     :host {
       width: 100%;
       min-height: 100%;
-- 
GitLab