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

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

commit f575fdae3a1f8f9d74df3e94089abc7541efa84b
Author: Enzo Martellucci <[email protected]>
AuthorDate: Mon May 25 16:20:02 2026 +0200

    feat(extensions): complete SIP P1 — chatbot mount point & registration
    
    - Add `icon` field to `View` descriptor (static, set at registerView time)
    - Track per-extension Disposables in ExtensionsLoader; add 
deactivateExtension()
    - Subscribe ChatbotMount to registry changes so it reacts to 
activate/deactivate
    - Add host-level unhandledrejection isolation to ExtensionsStartup
    - Make extension loading non-blocking (host renders immediately, chatbot 
appears reactively)
    - Document superset.chatbot location and chatbot registerView example in 
public API
---
 .../packages/superset-core/src/views/index.ts      | 41 ++++++++++--------
 .../src/components/ChatbotMount/index.tsx          | 33 +++++++-------
 superset-frontend/src/core/views/index.ts          | 24 +++++++++++
 .../src/extensions/ExtensionsLoader.ts             | 50 ++++++++++++++++++++--
 .../src/extensions/ExtensionsStartup.tsx           | 28 +++++++++---
 5 files changed, 132 insertions(+), 44 deletions(-)

diff --git a/superset-frontend/packages/superset-core/src/views/index.ts 
b/superset-frontend/packages/superset-core/src/views/index.ts
index 99c8ad09eb2..ceb9e7fba25 100644
--- a/superset-frontend/packages/superset-core/src/views/index.ts
+++ b/superset-frontend/packages/superset-core/src/views/index.ts
@@ -20,19 +20,12 @@
 /**
  * @fileoverview Views registration API for Superset extensions.
  *
- * This module provides functions for registering custom React views
- * at specific locations in the Superset UI. Views are registered as
- * module-level side effects at import time.
+ * Extensions register React views at named locations using `registerView`.
+ * Registrations happen as module-level side effects at import time.
  *
- * @example
- * ```typescript
- * import { views } from '@apache-superset/core';
- *
- * views.registerView(
- *   { id: 'my_ext.result_stats', name: 'Result Stats', location: 
'sqllab.panels' },
- *   () => <ResultStatsPanel />,
- * );
- * ```
+ * Built-in locations:
+ *  - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
+ *  - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders 
one)
  */
 
 import { ReactElement } from 'react';
@@ -48,20 +41,23 @@ export interface View {
   name: string;
   /** Optional description of the view, for display in contribution manifests. 
*/
   description?: string;
+  /**
+   * Optional icon identifier for the view, used in admin pickers and manifest
+   * listings. Static — set once at registerView() time.
+   * Dynamic icon states (e.g. notification badge) are the extension's concern.
+   */
+  icon?: string;
 }
 
 /**
  * Registers a custom view at a specific UI location.
  *
- * The view provider function is called when the UI renders the location,
- * and should return a React element to display.
- *
- * @param view The view descriptor (id and name).
- * @param location The location where this view should appear (e.g. 
"sqllab.panels").
+ * @param view The view descriptor (id, name, and optional icon/description).
+ * @param location The location where this view should appear.
  * @param provider A function that returns the React element to render.
  * @returns A Disposable that unregisters the view when disposed.
  *
- * @example
+ * @example SQL Lab panel
  * ```typescript
  * views.registerView(
  *   { id: 'my_ext.result_stats', name: 'Result Stats' },
@@ -69,6 +65,15 @@ export interface View {
  *   () => <ResultStatsPanel />,
  * );
  * ```
+ *
+ * @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
+ * ```typescript
+ * views.registerView(
+ *   { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
+ *   'superset.chatbot',
+ *   () => <ChatbotApp />,
+ * );
+ * ```
  */
 export declare function registerView(
   view: View,
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx 
b/superset-frontend/src/components/ChatbotMount/index.tsx
index 17d16c9e9f3..4efb9aa47e9 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -30,30 +30,34 @@
  * this component renders nothing and the corner stays empty.
  */
 
+import { useState, useEffect } from 'react';
 import { css, useTheme } from '@apache-superset/core/theme';
 import { ErrorBoundary } from 'src/components/ErrorBoundary';
 import { getActiveChatbot } from 'src/core/chatbot';
+import { subscribeToLocation } from 'src/core/views';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
 
-/**
- * Margin from the viewport edges for the chatbot anchor, per SIP §3.2.
- */
 const CHATBOT_EDGE_MARGIN = 24;
 
 /**
  * Renders the active chatbot extension into a fixed bottom-right slot.
  *
- * Mounted once at the app root so the bubble is available consistently across
- * routes. Renders `null` when no chatbot extension is registered.
- *
- * Resolution happens once at mount: chatbot extensions are registered
- * synchronously during extension loading, before this component renders (it
- * sits inside `ExtensionsStartup`). Reacting to chatbots that register or
- * unregister at runtime — deactivate / uninstall / replace — is the extension
- * lifecycle work tracked separately under SIP §8 phase P1.
+ * Mounted once at the app root so the bubble persists across routes.
+ * Re-resolves when the chatbot registry changes (extension activated or
+ * deactivated at runtime via the P1.A lifecycle contract).
+ * Renders null when no chatbot extension is registered.
  */
 const ChatbotMount = () => {
   const theme = useTheme();
-  const activeChatbot = getActiveChatbot();
+  const [activeChatbot, setActiveChatbot] = useState(getActiveChatbot);
+
+  useEffect(
+    () =>
+      subscribeToLocation(CHATBOT_LOCATION, () =>
+        setActiveChatbot(getActiveChatbot()),
+      ),
+    [],
+  );
 
   if (!activeChatbot) {
     return null;
@@ -66,10 +70,7 @@ const ChatbotMount = () => {
         position: fixed;
         right: ${CHATBOT_EDGE_MARGIN}px;
         bottom: ${CHATBOT_EDGE_MARGIN}px;
-        /*
-         * Above dashboard content and the toast layer
-         * (toasts sit at zIndexPopupBase + 1), below modal dialogs.
-         */
+        /* Above dashboard content and the toast layer, below modal dialogs. */
         z-index: ${theme.zIndexPopupBase + 2};
       `}
     >
diff --git a/superset-frontend/src/core/views/index.ts 
b/superset-frontend/src/core/views/index.ts
index 6f29143d453..7925ef3d2d4 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -39,6 +39,27 @@ const viewRegistry: Map<
 
 const locationIndex: Map<string, Set<string>> = new Map();
 
+/** Listeners notified whenever a view is registered or unregistered at a 
location. */
+const locationListeners: Map<string, Set<() => void>> = new Map();
+
+const notifyListeners = (location: string) => {
+  locationListeners.get(location)?.forEach(fn => fn());
+};
+
+/**
+ * Subscribe to registration changes at a specific location.
+ * Returns an unsubscribe function.
+ */
+export const subscribeToLocation = (
+  location: string,
+  listener: () => void,
+): (() => void) => {
+  const listeners = locationListeners.get(location) ?? new Set();
+  listeners.add(listener);
+  locationListeners.set(location, listeners);
+  return () => listeners.delete(listener);
+};
+
 const registerView: typeof viewsApi.registerView = (
   view: View,
   location: string,
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
   ids.add(id);
   locationIndex.set(location, ids);
 
+  notifyListeners(location);
+
   return new Disposable(() => {
     viewRegistry.delete(id);
     locationIndex.get(location)?.delete(id);
+    notifyListeners(location);
   });
 };
 
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts 
b/superset-frontend/src/extensions/ExtensionsLoader.ts
index 0b74ef9be86..7b1f1564424 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.ts
@@ -36,6 +36,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 +91,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) {
@@ -99,12 +103,25 @@ class ExtensionsLoader {
     }
   }
 
+  /**
+   * 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);
+  }
+
   /**
    * Loads a single extension module via webpack module federation.
    * 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 +166,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..5d523be8a4a 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -19,6 +19,7 @@
 import { useEffect, useState } from 'react';
 // 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,
@@ -80,14 +81,29 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
       views,
     };
 
-    const setup = async () => {
-      if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
-        await ExtensionsLoader.getInstance().initializeExtensions();
-      }
-      setInitialized(true);
+    // Isolate unhandled rejections that originate from extension code so they
+    // cannot crash the host application. Extensions load via Module Federation
+    // and their async failures (e.g. failed API calls, unhandled promise
+    // chains) would otherwise surface as uncaught rejections in the host.
+    const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+      // Always log so extension authors can diagnose failures.
+      logging.error('[extensions] Unhandled rejection from extension:', 
event.reason);
+      event.preventDefault();
     };
+    window.addEventListener('unhandledrejection', handleUnhandledRejection);
+
+    // 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();
+    }
+
+    return () => {
+      window.removeEventListener('unhandledrejection', 
handleUnhandledRejection);
+    };
   }, [initialized, userId]);
 
   if (!initialized) {

Reply via email to