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) {
