This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/chat-prototype
in repository https://gitbox.apache.org/repos/asf/superset.git

commit d195371ef7337ce09c76a6e3355b215a6eb8226b
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 26 16:10:16 2026 +0200

    feat(extensions): eager-load extensions at app-shell startup
    
    ExtensionsStartup initializes extensions behind the EnableExtensions
    feature flag immediately after the user session is confirmed, wires
    window.superset, and isolates unhandled rejections from extension code.
    ChatbotMount is mounted at the app root via App.tsx.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 .../src/extensions/ExtensionsLoader.ts             | 56 ++++++++++++++++++--
 .../src/extensions/ExtensionsStartup.tsx           | 60 +++++++++++++++++++---
 superset-frontend/src/views/App.tsx                |  8 +++
 3 files changed, 112 insertions(+), 12 deletions(-)

diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts 
b/superset-frontend/src/extensions/ExtensionsLoader.ts
index 0b74ef9be86..cd99a27dce2 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.ts
@@ -17,8 +17,11 @@
  * under the License.
  */
 import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
 import { logging } from '@apache-superset/core/utils';
 import type { common as core } from '@apache-superset/core';
+import { addDangerToast } from 'src/components/MessageToasts/actions';
+import { store } from 'src/views/store';
 
 type Extension = core.Extension;
 
@@ -36,6 +39,9 @@ class ExtensionsLoader {
 
   private initializationPromise: Promise<void> | null = null;
 
+  /** Disposables returned by contribution registrations, keyed by extension 
id. */
+  private extensionDisposables: Map<string, (() => void)[]> = new Map();
+
   // eslint-disable-next-line no-useless-constructor
   private constructor() {
     // Private constructor for singleton pattern
@@ -88,7 +94,8 @@ class ExtensionsLoader {
   public async initializeExtension(extension: Extension) {
     try {
       if (extension.remoteEntry) {
-        await this.loadModule(extension);
+        const disposables = await this.loadModule(extension);
+        this.extensionDisposables.set(extension.id, disposables);
       }
       this.extensionIndex.set(extension.id, extension);
     } catch (error) {
@@ -96,7 +103,23 @@ class ExtensionsLoader {
         `Failed to initialize extension ${extension.name}\n`,
         error,
       );
+      store.dispatch(
+        addDangerToast(t('Extension "%s" failed to load.', extension.name)),
+      );
+    }
+  }
+
+  /**
+   * Deactivates an extension by disposing all of its registered contributions
+   * and removing it from the index.
+   */
+  public deactivateExtension(id: string): void {
+    const disposables = this.extensionDisposables.get(id);
+    if (disposables) {
+      disposables.forEach(dispose => dispose());
+      this.extensionDisposables.delete(id);
     }
+    this.extensionIndex.delete(id);
   }
 
   /**
@@ -104,7 +127,7 @@ class ExtensionsLoader {
    * The module's top-level side effects fire contribution registrations.
    * @param extension The extension to load.
    */
-  private async loadModule(extension: Extension): Promise<void> {
+  private async loadModule(extension: Extension): Promise<(() => void)[]> {
     const { remoteEntry, id } = extension;
 
     // Load the remote entry script
@@ -149,8 +172,33 @@ class ExtensionsLoader {
     await container.init(__webpack_share_scopes__.default);
 
     const factory = await container.get('./index');
-    // Execute the module factory - side effects fire registrations
-    factory();
+
+    // Intercept contribution registrations during module activation so we can
+    // collect the Disposables and drive cleanup on deactivation.
+    const collected: (() => void)[] = [];
+    const originalSuperset = window.superset;
+    window.superset = {
+      ...originalSuperset,
+      views: {
+        ...originalSuperset.views,
+        registerView: (
+          ...args: Parameters<typeof originalSuperset.views.registerView>
+        ) => {
+          const disposable = originalSuperset.views.registerView(...args);
+          collected.push(() => disposable.dispose());
+          return disposable;
+        },
+      },
+    };
+
+    try {
+      // Execute the module factory โ€” side effects fire contribution 
registrations
+      factory();
+    } finally {
+      window.superset = originalSuperset;
+    }
+
+    return collected;
   }
 
   /**
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx 
b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index beb5590220c..ddce4cca897 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -16,20 +16,27 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { useLocation } from 'react-router-dom';
 // eslint-disable-next-line no-restricted-syntax
 import * as supersetCore from '@apache-superset/core';
+import { logging } from '@apache-superset/core/utils';
 import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
 import {
   authentication,
   core,
   commands,
+  dashboard,
+  dataset,
   editors,
+  explore,
   extensions,
   menus,
+  navigation,
   sqlLab,
   views,
 } from 'src/core';
+import { notifyPageChange } from 'src/core/navigation';
 import { useSelector } from 'react-redux';
 import { RootState } from 'src/views/store';
 import ExtensionsLoader from './ExtensionsLoader';
@@ -40,9 +47,13 @@ declare global {
       authentication: typeof authentication;
       core: typeof core;
       commands: typeof commands;
+      dashboard: typeof dashboard;
+      dataset: typeof dataset;
       editors: typeof editors;
+      explore: typeof explore;
       extensions: typeof extensions;
       menus: typeof menus;
+      navigation: typeof navigation;
       sqlLab: typeof sqlLab;
       views: typeof views;
     };
@@ -53,11 +64,40 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
   children,
 }) => {
   const [initialized, setInitialized] = useState(false);
+  const location = useLocation();
+  const prevPathname = useRef<string | null>(null);
 
   const userId = useSelector<RootState, number | undefined>(
     ({ user }) => user.userId,
   );
 
+  // Notify the navigation namespace on every route change.
+  useEffect(() => {
+    if (prevPathname.current !== location.pathname) {
+      prevPathname.current = location.pathname;
+      notifyPageChange(location.pathname);
+    }
+  }, [location.pathname]);
+
+  // Isolate unhandled rejections from extension code for the lifetime of the
+  // app โ€” registered once, never removed.
+  useEffect(() => {
+    const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+      logging.error(
+        '[extensions] Unhandled rejection from extension:',
+        event.reason,
+      );
+      event.preventDefault();
+    };
+    window.addEventListener('unhandledrejection', handleUnhandledRejection);
+    return () => {
+      window.removeEventListener(
+        'unhandledrejection',
+        handleUnhandledRejection,
+      );
+    };
+  }, []);
+
   useEffect(() => {
     if (initialized) return;
 
@@ -73,21 +113,25 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
       authentication,
       core,
       commands,
+      dashboard,
+      dataset,
       editors,
+      explore,
       extensions,
       menus,
+      navigation,
       sqlLab,
       views,
     };
 
-    const setup = async () => {
-      if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
-        await ExtensionsLoader.getInstance().initializeExtensions();
-      }
-      setInitialized(true);
-    };
+    // Render the host immediately; extension bundles load in the background.
+    // ChatbotMount re-resolves reactively once the chatbot extension registers
+    // (via subscribeToLocation), so the bubble appears without blocking the 
UI.
+    setInitialized(true);
 
-    setup();
+    if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
+      ExtensionsLoader.getInstance().initializeExtensions();
+    }
   }, [initialized, userId]);
 
   if (!initialized) {
diff --git a/superset-frontend/src/views/App.tsx 
b/superset-frontend/src/views/App.tsx
index 4f30a552e94..d84b111ca84 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -39,6 +39,7 @@ import setupCodeOverrides from 'src/setup/setupCodeOverrides';
 import { logEvent } from 'src/logger/actions';
 import { store } from 'src/views/store';
 import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
+import ChatbotMount from 'src/components/ChatbotMount';
 import { RootContextProviders } from './RootContextProviders';
 import { ScrollToTop } from './ScrollToTop';
 
@@ -112,6 +113,13 @@ const App = () => (
             </Route>
           ))}
         </Switch>
+        {/*
+          The singleton chatbot bubble. Rendered as a sibling of the route
+          Switch โ€” inside ExtensionsStartup so chatbot extensions have been
+          loaded and registered, but outside the Switch so the bubble persists
+          across route changes (SIP ยง3.2).
+        */}
+        <ChatbotMount />
       </ExtensionsStartup>
       <ToastContainer />
     </RootContextProviders>

Reply via email to