Compare commits

...

5 Commits

Author SHA1 Message Date
Enzo Martellucci
793ffb3d80 feat(extensions): show toast on load failure and export ChatbotView type 2026-05-25 16:43:18 +02:00
Enzo Martellucci
f575fdae3a 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
2026-05-25 16:20:02 +02:00
Enzo Martellucci
7b418becc7 feat(extensions): add superset.chatbot contribution point (SIP P1.1)
- Add `app` scope and `AppLocation` type to `ViewContributions` manifest schema
- Add host-internal `getViewProvider` and `getRegisteredViewIds` accessors to the views registry
- Add `getActiveChatbot` resolver with first-to-register fallback policy
- Mount `ChatbotMount` in the app shell (fixed bottom-right, persists across routes
2026-05-25 15:04:36 +02:00
Enzo Martellucci
ba7db15f02 Merge branch 'master' into chat-prototype 2026-05-22 16:31:56 +02:00
Michael S. Molina
c85661f4fd feat: Chat prototype 2026-05-15 15:15:42 -03:00
13 changed files with 637 additions and 76 deletions

View File

View File

@@ -17,23 +17,12 @@
* under the License.
*/
/**
* @fileoverview Manifest schema for Superset extension contributions.
*
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
*/
import { Command } from '../commands';
import { View } from '../views';
import type { ChatbotView } from '../views';
import { Menu } from '../menus';
import { Editor } from '../editors';
/**
* Valid locations within SQL Lab.
*/
export type { ChatbotView };
export type SqlLabLocation =
| 'leftSidebar'
| 'rightSidebar'
@@ -43,43 +32,14 @@ export type SqlLabLocation =
| 'results'
| 'queryHistory';
/**
* Nested structure for view contributions by scope and location.
* @example
* {
* sqllab: {
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
* }
* }
*/
/** Valid locations within the app shell (persist across all routes). */
export type AppLocation = 'chatbot';
export interface ViewContributions {
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
app?: Partial<Record<AppLocation, View[]>>;
}
/**
* Nested structure for menu contributions by scope and location.
* @example
* {
* sqllab: {
* editor: { primary: [...], secondary: [...] }
* }
* }
*/
export interface MenuContributions {
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
commands: Command[];
/** Nested mapping of menu contributions by scope and location. */
menus: MenuContributions;
/** Nested mapping of view contributions by scope and location. */
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
}

View File

@@ -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,
@@ -76,6 +81,21 @@ export declare function registerView(
provider: () => ReactElement,
): Disposable;
/**
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
*
* Extension authors should use this type when calling `registerView` for the
* chatbot area. It is identical to {@link View} but makes the registration
* intent explicit and allows future narrowing (e.g. required `icon`).
*
* @example
* ```typescript
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
* ```
*/
export type ChatbotView = View;
/**
* Retrieves all views registered at a specific location.
*

View File

@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { views } from 'src/core';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import ChatbotMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('renders nothing when no chatbot extension is registered', () => {
render(<ChatbotMount />);
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
});
test('renders the registered chatbot inside the fixed mount slot', () => {
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
render(<ChatbotMount />);
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
});
test('renders only the first-to-register chatbot when several are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First Bubble');
const secondProvider = () =>
React.createElement('div', null, 'Second Bubble');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
render(<ChatbotMount />);
expect(screen.getByText('First Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
});
test('isolates a failing chatbot so it does not crash the host', () => {
const FailingChatbot = () => {
throw new Error('chatbot blew up');
};
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => React.createElement(FailingChatbot),
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
});

View File

@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host mount point for the singleton `superset.chatbot`
* contribution area.
*
* The host owns the slot: a fixed bottom-right anchor that persists across all
* routes, with a managed z-index. The extension owns everything rendered
* inside it — the collapsed bubble, the expanded panel, all open/close state,
* animations, and behavior (SIP §3.2 "Component contract").
*
* Singleton resolution (which of possibly several registered chatbots renders)
* is delegated to `getActiveChatbot`. If no chatbot extension is registered,
* 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';
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 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, setActiveChatbot] = useState(getActiveChatbot);
useEffect(
() =>
subscribeToLocation(CHATBOT_LOCATION, () =>
setActiveChatbot(getActiveChatbot()),
),
[],
);
if (!activeChatbot) {
return null;
}
return (
<div
data-test="chatbot-mount"
css={css`
position: fixed;
right: ${CHATBOT_EDGE_MARGIN}px;
bottom: ${CHATBOT_EDGE_MARGIN}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
</div>
);
};
export default ChatbotMount;

View File

@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { views } from 'src/core/views';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getActiveChatbot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot resolves the single registered chatbot', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
const active = getActiveChatbot();
expect(active).toEqual({ id: 'superset.chatbot', provider });
});
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
const active = getActiveChatbot();
expect(active?.id).toBe('first.chatbot');
expect(active?.provider).toBe(firstProvider);
});
test('getActiveChatbot ignores views registered at other locations', () => {
const provider = () => React.createElement('div', null, 'Panel');
disposables.push(
views.registerView(
{ id: 'some.panel', name: 'Some Panel' },
'sqllab.panels',
provider,
),
);
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
const disposable = views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
);
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
disposable.dispose();
expect(getActiveChatbot()).toBeUndefined();
});

View File

@@ -0,0 +1,77 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
* contribution area.
*
* `superset.chatbot` is a singleton contribution area: multiple chatbot
* extensions may register a view there, but the host renders exactly one.
* This module owns the host-side selection policy.
*
* This is host-internal infrastructure — it is NOT part of the public
* `@apache-superset/core` API. Extensions register via the public
* `views.registerView()`; only the host resolves which one is active.
*/
import { ReactElement } from 'react';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
/**
* The resolved active chatbot: a view id paired with its renderable provider.
*/
export interface ActiveChatbot {
/** The registered view id of the selected chatbot. */
id: string;
/** The provider that renders the chatbot's React element. */
provider: () => ReactElement;
}
/**
* Resolves which single chatbot extension is currently active.
*
* Selection policy (P1):
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
* - If one or more chatbots are registered, the first one to register wins.
*
* `Set` preserves insertion order, so "first to register" is deterministic.
*
* This is the P1 fallback policy. P2 introduces an admin "Default chatbot"
* setting (SIP §4 option (c)); when that lands, the admin-selected id takes
* precedence here and this first-to-register behavior remains only as the
* fallback used when no admin setting is configured.
*
* @returns The active chatbot's id and provider, or `undefined` if none.
*/
export const getActiveChatbot = (): ActiveChatbot | undefined => {
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
if (registeredIds.length === 0) {
return undefined;
}
// Deterministic first-to-register fallback. P2 will consult the admin
// "Default chatbot" setting before this point.
const [selectedId] = registeredIds;
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
if (!provider) {
return undefined;
}
return { id: selectedId, provider };
};

View File

@@ -17,7 +17,12 @@
* under the License.
*/
import React from 'react';
import { views, resolveView } from './index';
import {
views,
resolveView,
getViewProvider,
getRegisteredViewIds,
} from './index';
const disposables: Array<{ dispose: () => void }> = [];
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
expect(views.getViews('sqllab.panels')).toBeUndefined();
});
test('getViewProvider returns the registered provider for a matching location', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'superset.chatbot',
provider,
),
);
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
});
test('getViewProvider returns undefined when the location does not match', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'sqllab.panels',
provider,
),
);
// Registered, but at a different location.
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
});
test('getViewProvider returns undefined for an unknown id', () => {
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
});
test('getRegisteredViewIds returns ids in registration order', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First' },
'superset.chatbot',
provider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second' },
'superset.chatbot',
provider,
),
);
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
'first.chatbot',
'second.chatbot',
]);
});
test('getRegisteredViewIds returns an empty array for an unused location', () => {
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
});

View File

@@ -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);
});
};
@@ -77,6 +101,53 @@ const getViews: typeof viewsApi.getViews = (
.filter((c): c is View => !!c);
};
/**
* Host-internal accessor that returns the registered `provider` for a view id
* at a given location.
*
* This is deliberately NOT part of the public `@apache-superset/core` `views`
* API. The public `getViews` returns descriptors only (`id`/`name`/...), so an
* extension can discover what is registered but cannot obtain — and therefore
* cannot render — another extension's view outside the host's mount point,
* lifecycle, and fault-isolation boundary.
*
* The host uses this accessor to render exclusive (singleton) contribution
* areas such as `superset.chatbot`, where it must enumerate the candidates and
* then render exactly one. See `getActiveChatbot` in `src/core/chatbot`.
*
* @param location The contribution location (e.g. `superset.chatbot`).
* @param id The registered view id.
* @returns The provider function, or undefined if no matching view is
* registered at that location.
*/
export const getViewProvider = (
location: string,
id: string,
): (() => ReactElement) | undefined => {
const entry = viewRegistry.get(id);
if (entry?.location !== location) {
return undefined;
}
return entry.provider;
};
/**
* Host-internal accessor that returns the ordered list of view ids registered
* at a location, in registration order.
*
* Registration order is meaningful for exclusive locations: the host's
* deterministic fallback policy ("first to register wins") relies on it.
* Like {@link getViewProvider}, this is host-internal and not part of the
* public API.
*
* @param location The contribution location.
* @returns View ids in registration order, or an empty array if none.
*/
export const getRegisteredViewIds = (location: string): string[] => {
const ids = locationIndex.get(location);
return ids ? Array.from(ids) : [];
};
export const views: typeof viewsApi = {
registerView,
getViews,

View File

@@ -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,15 +103,31 @@ 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);
}
/**
* 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 +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;
}
/**

View File

@@ -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);
setup();
// 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);
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [initialized, userId]);
if (!initialized) {

View File

@@ -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>

View File

@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* View locations for app-shell extension integration.
*
* These define locations that persist across all routes, mirroring the `app`
* scope of the `ViewContributions` manifest schema.
*/
export const AppViewLocations = {
app: {
chatbot: 'superset.chatbot',
},
} as const;
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;