;
diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts
index be41ac88a19..3bff58a46c1 100644
--- a/superset-frontend/packages/superset-core/src/index.ts
+++ b/superset-frontend/packages/superset-core/src/index.ts
@@ -20,6 +20,7 @@ export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
+export * as dashboards from './dashboards';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
diff --git a/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx b/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx
new file mode 100644
index 00000000000..1591b33f3a6
--- /dev/null
+++ b/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx
@@ -0,0 +1,182 @@
+/**
+ * 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 { act, render, screen, cleanup } from 'spec/helpers/testing-library';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import type { dashboards } from '@apache-superset/core';
+import DashboardRendererProviders from './DashboardRendererProviders';
+import DashboardRendererHost from './DashboardRendererHost';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ isFeatureEnabled: jest.fn(),
+}));
+const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
+
+// Stub the built-in renderer to avoid mounting the full dashboard stack
+jest.mock(
+ 'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer',
+ () => ({
+ __esModule: true,
+ default: ({ dashboard }: dashboards.DashboardRendererProps) => (
+
+ {dashboard.id}
+
+ ),
+ }),
+);
+
+const rendererProps: dashboards.DashboardRendererProps = {
+ dashboard: {
+ id: 42,
+ title: 'Test dashboard',
+ metadata: {},
+ layout: {},
+ },
+ charts: [{ id: 7, slice_name: 'Test chart' }],
+ datasets: [{ id: 3, table_name: 'test_table' }],
+ initialDataMask: {
+ 'NATIVE_FILTER-abc': {
+ id: 'NATIVE_FILTER-abc',
+ filterState: { value: ['foo'] },
+ },
+ },
+};
+
+beforeEach(() => {
+ DashboardRendererProviders.getInstance().reset();
+ mockIsFeatureEnabled.mockImplementation(
+ flag => flag === FeatureFlag.EnableExtensions,
+ );
+});
+
+afterEach(() => {
+ mockIsFeatureEnabled.mockReset();
+ cleanup();
+});
+
+test('renders the built-in renderer when no custom renderer is registered', () => {
+ render();
+
+ expect(screen.getByTestId('default-dashboard-renderer')).toBeInTheDocument();
+ expect(screen.getByTestId('default-renderer-dashboard-id')).toHaveTextContent(
+ '42',
+ );
+});
+
+test('renders a registered custom renderer with the contract props', () => {
+ const customRenderer = jest.fn(
+ ({ dashboard, initialDataMask }: dashboards.DashboardRendererProps) => (
+
+ {dashboard.title}
+ {Object.keys(initialDataMask).join(',')}
+
+ ),
+ );
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.custom', name: 'Custom Renderer' },
+ customRenderer,
+ );
+
+ render();
+
+ expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
+ 'Test dashboard',
+ );
+ expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
+ 'NATIVE_FILTER-abc',
+ );
+ expect(customRenderer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({ id: 42 }),
+ charts: rendererProps.charts,
+ datasets: rendererProps.datasets,
+ initialDataMask: rendererProps.initialDataMask,
+ }),
+ expect.anything(),
+ );
+ expect(
+ screen.queryByTestId('default-dashboard-renderer'),
+ ).not.toBeInTheDocument();
+});
+
+test('renders the built-in renderer in edit mode even with a custom renderer registered', () => {
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.custom', name: 'Custom Renderer' },
+ () => ,
+ );
+
+ render();
+
+ expect(screen.getByTestId('default-dashboard-renderer')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('custom-dashboard-renderer'),
+ ).not.toBeInTheDocument();
+});
+
+test('renders the built-in renderer when the extensions feature flag is off', () => {
+ mockIsFeatureEnabled.mockReturnValue(false);
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.custom', name: 'Custom Renderer' },
+ () => ,
+ );
+
+ render();
+
+ expect(screen.getByTestId('default-dashboard-renderer')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('custom-dashboard-renderer'),
+ ).not.toBeInTheDocument();
+});
+
+test('shows an error boundary when the custom renderer throws', () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.broken', name: 'Broken Renderer' },
+ () => {
+ throw new Error('renderer exploded');
+ },
+ );
+
+ render();
+
+ expect(screen.getByText('Unexpected error')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('default-dashboard-renderer'),
+ ).not.toBeInTheDocument();
+
+ consoleErrorSpy.mockRestore();
+});
+
+test('swaps to a custom renderer registered after mount', () => {
+ render();
+
+ expect(screen.getByTestId('default-dashboard-renderer')).toBeInTheDocument();
+
+ act(() => {
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.late', name: 'Late Renderer' },
+ () => ,
+ );
+ });
+
+ expect(screen.getByTestId('custom-dashboard-renderer')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('default-dashboard-renderer'),
+ ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx b/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx
new file mode 100644
index 00000000000..9b26a6afddb
--- /dev/null
+++ b/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx
@@ -0,0 +1,88 @@
+/**
+ * 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 DashboardRendererHost component for dynamic dashboard
+ * renderer resolution.
+ *
+ * This component resolves and renders the appropriate dashboard renderer
+ * implementation. If an extension has registered a custom renderer (and the
+ * dashboard is not in edit mode), it uses that; otherwise, it falls back to
+ * the built-in dashboard renderer.
+ */
+
+import { useSyncExternalStore } from 'react';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import type { dashboards } from '@apache-superset/core';
+import { ErrorBoundary } from 'src/components/ErrorBoundary';
+import DefaultDashboardRenderer from 'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer';
+import DashboardRendererProviders from './DashboardRendererProviders';
+
+export interface DashboardRendererHostProps
+ extends dashboards.DashboardRendererProps {
+ /**
+ * Host-only flag, not part of the renderer contract: when true, the
+ * built-in renderer is always used. Custom renderers handle view mode
+ * only — the built-in editor stack owns editing.
+ */
+ editMode?: boolean;
+}
+
+/**
+ * DashboardRendererHost dynamically resolves and renders the appropriate
+ * dashboard renderer.
+ *
+ * It checks whether an extension has registered a custom dashboard renderer
+ * and uses that if available; otherwise, it falls back to the built-in
+ * renderer. The built-in renderer is also used whenever the dashboard is in
+ * edit mode, regardless of registration.
+ */
+const DashboardRendererHost = ({
+ editMode,
+ ...rendererProps
+}: DashboardRendererHostProps) => {
+ const manager = DashboardRendererProviders.getInstance();
+ const provider = useSyncExternalStore(
+ manager.subscribe,
+ () => manager.getProvider(),
+ () => undefined,
+ );
+
+ // Extensions can only register while the feature flag is on (the loader is
+ // gated), but the dashboard blast radius warrants the extra check.
+ const useCustomRenderer =
+ !editMode &&
+ provider !== undefined &&
+ isFeatureEnabled(FeatureFlag.EnableExtensions);
+
+ if (useCustomRenderer) {
+ const RendererComponent = provider.component;
+ return (
+
+
+
+ );
+ }
+
+ return ;
+};
+
+export default DashboardRendererHost;
+
+export { DashboardRendererHost };
diff --git a/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts b/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts
new file mode 100644
index 00000000000..d809cb91ee8
--- /dev/null
+++ b/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts
@@ -0,0 +1,224 @@
+/**
+ * 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 type { dashboards } from '@apache-superset/core';
+import DashboardRendererProviders from './DashboardRendererProviders';
+
+type DashboardRenderer = dashboards.DashboardRenderer;
+type DashboardRendererComponent = dashboards.DashboardRendererComponent;
+
+/**
+ * Creates a mock renderer descriptor for testing.
+ */
+function createMockRenderer(
+ overrides: Partial = {},
+): DashboardRenderer {
+ return {
+ id: 'test.mock-renderer',
+ name: 'Mock Renderer',
+ description: 'A mock dashboard renderer for testing',
+ ...overrides,
+ };
+}
+
+/**
+ * Creates a mock renderer component for testing.
+ */
+function createMockRendererComponent(): DashboardRendererComponent {
+ return jest.fn(() => null) as unknown as DashboardRendererComponent;
+}
+
+beforeEach(() => {
+ // Reset the singleton instance before each test
+ const manager = DashboardRendererProviders.getInstance();
+ manager.reset();
+});
+
+test('creates singleton instance', () => {
+ const manager1 = DashboardRendererProviders.getInstance();
+ const manager2 = DashboardRendererProviders.getInstance();
+
+ expect(manager1).toBe(manager2);
+ expect(manager1).toBeInstanceOf(DashboardRendererProviders);
+});
+
+test('registers and retrieves a provider', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const renderer = createMockRenderer();
+ const component = createMockRendererComponent();
+
+ manager.registerProvider(renderer, component);
+
+ const provider = manager.getProvider();
+ expect(provider).toBeDefined();
+ expect(provider?.renderer).toEqual(renderer);
+ expect(provider?.component).toBe(component);
+});
+
+test('returns undefined when no provider is registered', () => {
+ const manager = DashboardRendererProviders.getInstance();
+
+ expect(manager.getProvider()).toBeUndefined();
+});
+
+test('unregisters provider when disposable is disposed', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const renderer = createMockRenderer();
+
+ const disposable = manager.registerProvider(
+ renderer,
+ createMockRendererComponent(),
+ );
+
+ expect(manager.getProvider()).toBeDefined();
+
+ disposable.dispose();
+
+ expect(manager.getProvider()).toBeUndefined();
+});
+
+test('most recent registration wins and displaces the previous provider', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const unregisterListener = jest.fn();
+ manager.onDidUnregister(unregisterListener);
+
+ const renderer1 = createMockRenderer({ id: 'renderer-1' });
+ const renderer2 = createMockRenderer({ id: 'renderer-2' });
+
+ manager.registerProvider(renderer1, createMockRendererComponent());
+ manager.registerProvider(renderer2, createMockRendererComponent());
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Multiple dashboard renderers registered. Using "renderer-2"; ' +
+ 'discarding "renderer-1".',
+ );
+ expect(unregisterListener).toHaveBeenCalledWith({ renderer: renderer1 });
+ expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
+
+ consoleWarnSpy.mockRestore();
+});
+
+test('disposing a displaced provider is a no-op', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ const renderer1 = createMockRenderer({ id: 'renderer-1' });
+ const renderer2 = createMockRenderer({ id: 'renderer-2' });
+
+ const disposable1 = manager.registerProvider(
+ renderer1,
+ createMockRendererComponent(),
+ );
+ manager.registerProvider(renderer2, createMockRendererComponent());
+
+ // Disposing the displaced provider must not clear the active one
+ disposable1.dispose();
+
+ expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
+
+ consoleWarnSpy.mockRestore();
+});
+
+test('fires onDidRegister event when provider is registered', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const listener = jest.fn();
+
+ manager.onDidRegister(listener);
+
+ const renderer = createMockRenderer();
+ manager.registerProvider(renderer, createMockRendererComponent());
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ renderer });
+});
+
+test('fires onDidUnregister event when provider is unregistered', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const listener = jest.fn();
+
+ manager.onDidUnregister(listener);
+
+ const renderer = createMockRenderer();
+ const disposable = manager.registerProvider(
+ renderer,
+ createMockRendererComponent(),
+ );
+
+ disposable.dispose();
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ renderer });
+});
+
+test('event listeners can be disposed', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const listener = jest.fn();
+
+ const listenerDisposable = manager.onDidRegister(listener);
+
+ manager.registerProvider(
+ createMockRenderer({ id: 'renderer-1' }),
+ createMockRendererComponent(),
+ );
+
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ listenerDisposable.dispose();
+
+ manager.registerProvider(
+ createMockRenderer({ id: 'renderer-2' }),
+ createMockRendererComponent(),
+ );
+
+ expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
+
+ consoleWarnSpy.mockRestore();
+});
+
+test('notifies subscribe listeners on register and unregister', () => {
+ const manager = DashboardRendererProviders.getInstance();
+ const listener = jest.fn();
+
+ const unsubscribe = manager.subscribe(listener);
+
+ const disposable = manager.registerProvider(
+ createMockRenderer(),
+ createMockRendererComponent(),
+ );
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ disposable.dispose();
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ unsubscribe();
+ manager.registerProvider(createMockRenderer(), createMockRendererComponent());
+ expect(listener).toHaveBeenCalledTimes(2); // No further calls
+});
+
+test('reset clears the provider slot', () => {
+ const manager = DashboardRendererProviders.getInstance();
+
+ manager.registerProvider(createMockRenderer(), createMockRendererComponent());
+ expect(manager.getProvider()).toBeDefined();
+
+ manager.reset();
+
+ expect(manager.getProvider()).toBeUndefined();
+});
diff --git a/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts b/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts
new file mode 100644
index 00000000000..c5562e754f5
--- /dev/null
+++ b/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts
@@ -0,0 +1,170 @@
+/**
+ * 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 type { dashboards } from '@apache-superset/core';
+import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
+
+type DashboardRenderer = dashboards.DashboardRenderer;
+type DashboardRendererComponent = dashboards.DashboardRendererComponent;
+type DashboardRendererProvider = dashboards.DashboardRendererProvider;
+type DashboardRendererRegisteredEvent =
+ dashboards.DashboardRendererRegisteredEvent;
+type DashboardRendererUnregisteredEvent =
+ dashboards.DashboardRendererUnregisteredEvent;
+
+type Listener = (e: T) => void;
+
+/**
+ * Singleton manager for the dashboard renderer slot.
+ *
+ * The dashboard renderer is a single-slot contribution point: at most one
+ * provider is active at a time. Registering while the slot is occupied
+ * displaces (and unregisters) the previous provider with a console warning —
+ * the most recent registration wins.
+ */
+class DashboardRendererProviders {
+ private static instance: DashboardRendererProviders;
+
+ /**
+ * The single active provider slot.
+ */
+ private provider: DashboardRendererProvider | undefined;
+
+ private registerEmitter =
+ createEventEmitter();
+
+ private unregisterEmitter =
+ createEventEmitter();
+
+ private syncListeners: Set<() => void> = new Set();
+
+ /**
+ * Stable-reference subscribe function for useSyncExternalStore.
+ * Defined as an arrow property so the reference is bound to this instance at construction.
+ */
+ public subscribe = (listener: () => void): (() => void) => {
+ this.syncListeners.add(listener);
+ return () => this.syncListeners.delete(listener);
+ };
+
+ // eslint-disable-next-line no-useless-constructor
+ private constructor() {
+ // Private constructor for singleton pattern
+ }
+
+ /**
+ * Get the singleton instance of DashboardRendererProviders.
+ * @returns The singleton instance.
+ */
+ public static getInstance(): DashboardRendererProviders {
+ if (!DashboardRendererProviders.instance) {
+ DashboardRendererProviders.instance = new DashboardRendererProviders();
+ }
+ return DashboardRendererProviders.instance;
+ }
+
+ /**
+ * Register a dashboard renderer provider.
+ * The most recently registered provider occupies the slot; a previously
+ * registered provider is displaced and unregistered with a warning.
+ *
+ * @param renderer The renderer descriptor.
+ * @param component The React component implementing the renderer.
+ * @returns A Disposable to unregister the provider. Disposing after the
+ * provider has been displaced by a newer registration is a no-op.
+ */
+ public registerProvider(
+ renderer: DashboardRenderer,
+ component: DashboardRendererComponent,
+ ): Disposable {
+ const displaced = this.provider;
+ if (displaced) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Multiple dashboard renderers registered. Using "${renderer.id}"; ` +
+ `discarding "${displaced.renderer.id}".`,
+ );
+ this.unregisterEmitter.fire({ renderer: displaced.renderer });
+ }
+
+ this.provider = { renderer, component };
+
+ // Fire registration event
+ this.registerEmitter.fire({ renderer });
+ this.syncListeners.forEach(l => l());
+
+ // Return disposable for cleanup
+ return new Disposable(() => {
+ // No-op when this provider is no longer the active one (displaced).
+ if (this.provider?.renderer !== renderer) {
+ return;
+ }
+ this.provider = undefined;
+ this.unregisterEmitter.fire({ renderer });
+ this.syncListeners.forEach(l => l());
+ });
+ }
+
+ /**
+ * Get the active dashboard renderer provider.
+ * @returns The provider or undefined if none is registered.
+ */
+ public getProvider(): DashboardRendererProvider | undefined {
+ return this.provider;
+ }
+
+ /**
+ * Subscribe to provider registration events.
+ * @param listener The listener function.
+ * @returns A Disposable to unsubscribe.
+ */
+ public onDidRegister(
+ listener: Listener,
+ thisArgs?: unknown,
+ ): Disposable {
+ return this.registerEmitter.subscribe(listener, thisArgs);
+ }
+
+ /**
+ * Subscribe to provider unregistration events.
+ * @param listener The listener function.
+ * @returns A Disposable to unsubscribe.
+ */
+ public onDidUnregister(
+ listener: Listener,
+ thisArgs?: unknown,
+ ): Disposable {
+ return this.unregisterEmitter.subscribe(listener, thisArgs);
+ }
+
+ /**
+ * Reset the manager state (for testing purposes).
+ */
+ public reset(): void {
+ this.provider = undefined;
+ this.syncListeners.clear();
+ this.registerEmitter =
+ createEventEmitter();
+ this.unregisterEmitter =
+ createEventEmitter();
+ }
+}
+
+export default DashboardRendererProviders;
diff --git a/superset-frontend/src/core/dashboards/index.ts b/superset-frontend/src/core/dashboards/index.ts
new file mode 100644
index 00000000000..cf6d1369d92
--- /dev/null
+++ b/superset-frontend/src/core/dashboards/index.ts
@@ -0,0 +1,54 @@
+/**
+ * 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 implementation of the `dashboards` contribution type.
+ *
+ * Extensions register via the public `dashboards.registerDashboardRenderer()`
+ * and the host resolves the active provider, falling back to the built-in
+ * dashboard renderer when no extension is registered.
+ *
+ * The public namespace (`dashboards`) is exposed to extensions on
+ * `window.superset`. `DashboardRendererHost` is the host-internal component
+ * for rendering dashboards and is NOT part of the public
+ * `@apache-superset/core` API.
+ */
+
+import { useSyncExternalStore } from 'react';
+import { dashboards as dashboardsApi } from '@apache-superset/core';
+import DashboardRendererProviders from './DashboardRendererProviders';
+
+export type { DashboardRendererHostProps } from './DashboardRendererHost';
+export { default as DashboardRendererHost } from './DashboardRendererHost';
+
+const provider = DashboardRendererProviders.getInstance();
+
+export const useDashboardRenderer = () =>
+ useSyncExternalStore(
+ provider.subscribe,
+ () => provider.getProvider(),
+ () => undefined,
+ );
+
+export const dashboards: typeof dashboardsApi = {
+ registerDashboardRenderer: provider.registerProvider.bind(provider),
+ getDashboardRenderer: provider.getProvider.bind(provider),
+ onDidRegisterDashboardRenderer: provider.onDidRegister.bind(provider),
+ onDidUnregisterDashboardRenderer: provider.onDidUnregister.bind(provider),
+};
diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts
index ce5b7f4d505..0eaa7368760 100644
--- a/superset-frontend/src/core/index.ts
+++ b/superset-frontend/src/core/index.ts
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
export * from './authentication';
export * from './chat';
export * from './commands';
+export * from './dashboards';
export * from './editors';
export * from './extensions';
export * from './menus';
diff --git a/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx b/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx
new file mode 100644
index 00000000000..5d78665823b
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx
@@ -0,0 +1,99 @@
+/**
+ * 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 The built-in dashboard renderer.
+ *
+ * This component conforms to the `DashboardRendererProps` contract from
+ * `@apache-superset/core/dashboards` but intentionally ignores those props:
+ * it reads everything from the Redux store the host hydrates. The contract
+ * props exist so custom renderers contributed by extensions can be
+ * Redux-free; migrating this built-in renderer to consume the props instead
+ * of the store is incremental follow-up work behind the stable contract.
+ */
+
+import { FC, lazy, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from '@reduxjs/toolkit';
+import type { dashboards } from '@apache-superset/core';
+import DashboardContainer from 'src/dashboard/containers/Dashboard';
+import {
+ getAllActiveFilters,
+ getRelevantDataMask,
+} from 'src/dashboard/util/activeAllDashboardFilters';
+import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
+import { AutoRefreshProvider } from 'src/dashboard/contexts/AutoRefreshContext';
+import type { ActiveFilters, RootState } from 'src/dashboard/types';
+
+const DashboardBuilder = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "DashboardContainer" */
+ /* webpackPreload: true */
+ 'src/dashboard/components/DashboardBuilder/DashboardBuilder'
+ ),
+);
+
+const selectRelevantDatamask = createSelector(
+ (state: RootState) => state.dataMask, // the first argument accesses relevant data from global state
+ dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
+);
+
+const selectChartConfiguration = (state: RootState) =>
+ state.dashboardInfo.metadata?.chart_configuration;
+const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
+const selectDataMask = (state: RootState) => state.dataMask;
+const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
+const selectActiveFilters = createSelector(
+ [
+ selectChartConfiguration,
+ selectNativeFilters,
+ selectDataMask,
+ selectAllSliceIds,
+ ],
+ (chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
+ ...getActiveFilters(),
+ ...getAllActiveFilters({
+ // eslint-disable-next-line camelcase
+ chartConfiguration,
+ nativeFilters,
+ dataMask,
+ allSliceIds,
+ }),
+ }),
+);
+
+const DefaultDashboardRenderer: FC = () => {
+ const relevantDataMask = useSelector(selectRelevantDatamask);
+ const activeFilters = useSelector(selectActiveFilters);
+ const dashboardBuilderComponent = useMemo(() => , []);
+
+ return (
+
+
+ {dashboardBuilderComponent}
+
+
+ );
+};
+
+export default DefaultDashboardRenderer;
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx
index e66d271d0a2..7b0e3103478 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx
@@ -31,8 +31,14 @@ import {
useDashboardCharts,
useDashboardDatasets,
} from 'src/hooks/apiResources';
-import { SupersetApiError, SupersetClient } from '@superset-ui/core';
+import {
+ FeatureFlag,
+ SupersetApiError,
+ SupersetClient,
+} from '@superset-ui/core';
+import type { dashboards } from '@apache-superset/core';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
+import DashboardRendererProviders from 'src/core/dashboards/DashboardRendererProviders';
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import {
clearDashboardHistory,
@@ -158,6 +164,8 @@ const MockCrudThemeProvider = CrudThemeProvider as unknown as jest.Mock;
afterEach(() => {
jest.restoreAllMocks();
+ DashboardRendererProviders.getInstance().reset();
+ window.featureFlags = {};
});
beforeEach(() => {
@@ -602,6 +610,89 @@ test('renders a not-found state instead of throwing when the dashboard 404s', as
expect(window.location.pathname).toBe('/dashboard/list/');
});
+test('renders the built-in dashboard renderer when no custom renderer is registered', async () => {
+ render(
+
+
+ ,
+ {
+ useRedux: true,
+ useRouter: true,
+ initialState: {
+ dashboardInfo: { id: 1, metadata: {} },
+ dashboardState: { sliceIds: [] },
+ nativeFilters: { filters: {} },
+ dataMask: {},
+ },
+ },
+ );
+
+ expect(await screen.findByText('DashboardBuilder')).toBeInTheDocument();
+});
+
+test('renders a registered custom dashboard renderer with the contract props', async () => {
+ window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
+ mockUseDashboardCharts.mockReturnValue({
+ result: [{ id: 7, slice_name: 'Test chart' }],
+ error: null,
+ });
+ mockUseDashboardDatasets.mockReturnValue({
+ result: [{ id: 3, table_name: 'test_table' }],
+ error: null,
+ status: 'complete',
+ });
+
+ const customRenderer = jest.fn(
+ ({ dashboard }: dashboards.DashboardRendererProps) => (
+ {dashboard.title}
+ ),
+ );
+ DashboardRendererProviders.getInstance().registerProvider(
+ { id: 'test.custom', name: 'Custom Renderer' },
+ customRenderer,
+ );
+
+ render(
+
+
+ ,
+ {
+ useRedux: true,
+ useRouter: true,
+ initialState: {
+ dashboardInfo: { id: 1, metadata: {} },
+ dashboardState: { sliceIds: [] },
+ nativeFilters: { filters: {} },
+ dataMask: {},
+ },
+ },
+ );
+
+ expect(
+ await screen.findByTestId('custom-dashboard-renderer'),
+ ).toHaveTextContent('Test Dashboard');
+ expect(screen.queryByText('DashboardBuilder')).not.toBeInTheDocument();
+
+ // The custom renderer receives the full contract props
+ expect(customRenderer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ id: 1,
+ title: 'Test Dashboard',
+ isPublished: true,
+ }),
+ charts: [expect.objectContaining({ id: 7 })],
+ datasets: [expect.objectContaining({ id: 3 })],
+ initialDataMask: expect.any(Object),
+ }),
+ expect.anything(),
+ );
+
+ // Host behavior stays identical: the store is still hydrated so
+ // permalinks, SyncDashboardState, and the embedded path keep working.
+ expect(hydrateDashboard).toHaveBeenCalled();
+});
+
test('clears undo history after hydrating the dashboard', async () => {
render(
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index 516b04ba786..e4663318a28 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -16,13 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { createContext, lazy, FC, useEffect, useMemo, useRef } from 'react';
+import { createContext, FC, useEffect, useMemo, useRef, useState } from 'react';
import { Global } from '@emotion/react';
import { useHistory } from 'react-router-dom';
+import type { dashboards } from '@apache-superset/core';
import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
-import { createSelector } from '@reduxjs/toolkit';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { EmptyState, Loading } from '@superset-ui/core/components';
import {
@@ -34,11 +34,6 @@ import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
import { setDatasources } from 'src/dashboard/actions/datasources';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
-import {
- getAllActiveFilters,
- getRelevantDataMask,
-} from 'src/dashboard/util/activeAllDashboardFilters';
-import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
@@ -49,12 +44,12 @@ import {
getFilterValue,
getPermalinkValue,
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
-import DashboardContainer from 'src/dashboard/containers/Dashboard';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
+import { useUiConfig } from 'src/components/UiConfigContext';
+import DashboardRendererHost from 'src/core/dashboards/DashboardRendererHost';
import type { DashboardChartStates } from 'src/dashboard/types/chartState';
import { nanoid } from 'nanoid';
-import type { ActiveFilters } from '../types';
import { RootState } from '../types';
import {
chartContextMenuStyles,
@@ -66,7 +61,6 @@ import {
import SyncDashboardState, {
getDashboardContextLocalStorage,
} from '../components/SyncDashboardState';
-import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core';
import { RoutePaths } from 'src/views/routePaths';
import {
@@ -83,49 +77,19 @@ type NativeFilterConfigEntry = Partial & { id: string };
export const DashboardPageIdContext = createContext('');
-const DashboardBuilder = lazy(
- () =>
- import(
- /* webpackChunkName: "DashboardContainer" */
- /* webpackPreload: true */
- 'src/dashboard/components/DashboardBuilder/DashboardBuilder'
- ),
-);
-
type PageProps = {
idOrSlug: string;
};
-// TODO: move to Dashboard.jsx when it's refactored to functional component
-const selectRelevantDatamask = createSelector(
- (state: RootState) => state.dataMask, // the first argument accesses relevant data from global state
- dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
-);
-
-const selectChartConfiguration = (state: RootState) =>
- state.dashboardInfo.metadata?.chart_configuration;
-const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
-const selectDataMask = (state: RootState) => state.dataMask;
-const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
-// TODO: move to Dashboard.jsx when it's refactored to functional component
-const selectActiveFilters = createSelector(
- [
- selectChartConfiguration,
- selectNativeFilters,
- selectDataMask,
- selectAllSliceIds,
- ],
- (chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
- ...getActiveFilters(),
- ...getAllActiveFilters({
- // eslint-disable-next-line camelcase
- chartConfiguration,
- nativeFilters,
- dataMask,
- allSliceIds,
- }),
- }),
-);
+/**
+ * Initial dashboard state resolved from the URL (permalink, filter key, or
+ * legacy rison params) before any renderer mounts.
+ */
+type InitialRendererState = {
+ dataMask: dashboards.DashboardDataMask;
+ activeTabs?: string[];
+ anchor?: string;
+};
export const DashboardPage: FC = ({ idOrSlug }: PageProps) => {
const theme = useTheme();
@@ -150,6 +114,12 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => {
status,
} = useDashboardDatasets(idOrSlug);
const isDashboardHydrated = useRef(false);
+ const uiConfig = useUiConfig();
+ const editMode = useSelector(
+ state => !!state.dashboardState?.editMode,
+ );
+ const [initialRendererState, setInitialRendererState] =
+ useState();
const error = dashboardApiError || chartsApiError;
// Only 404 gets a graceful not-found state; a 403 (access denied) still
@@ -318,6 +288,11 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => {
} as unknown as Parameters[0]),
);
dispatch(clearDashboardHistory());
+ setInitialRendererState({
+ dataMask: dataMask as dashboards.DashboardDataMask,
+ activeTabs: activeTabs ?? undefined,
+ anchor,
+ });
// Scroll to anchor element if specified in permalink state
if (anchor) {
@@ -380,8 +355,38 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => {
}
}, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
- const relevantDataMask = useSelector(selectRelevantDatamask);
- const activeFilters = useSelector(selectActiveFilters);
+ const rendererProps = useMemo<
+ dashboards.DashboardRendererProps | undefined
+ >(() => {
+ if (!dashboard || !charts || !initialRendererState) return undefined;
+ return {
+ dashboard: {
+ id: dashboard.id,
+ uuid: dashboard.uuid,
+ slug: dashboard.slug,
+ title: dashboard.dashboard_title,
+ css: dashboard.css,
+ metadata: dashboard.metadata ?? {},
+ layout: dashboard.position_data ?? {},
+ isPublished: dashboard.published,
+ isManagedExternally: dashboard.is_managed_externally,
+ },
+ // The API payloads satisfy the contract shapes structurally, but the
+ // host types (Chart, Datasource) lack the contract's index signatures,
+ // so the conversion has to go through `unknown`.
+ charts: charts as unknown as dashboards.DashboardChart[],
+ datasets: (datasets ?? []) as unknown as dashboards.DashboardDataset[],
+ initialDataMask: initialRendererState.dataMask,
+ initialActiveTabs: initialRendererState.activeTabs,
+ initialAnchor: initialRendererState.anchor,
+ uiConfig: {
+ hideTitle: uiConfig.hideTitle,
+ hideTab: uiConfig.hideTab,
+ hideChartControls: uiConfig.hideChartControls,
+ emitDataMasks: uiConfig.emitDataMasks,
+ },
+ };
+ }, [dashboard, charts, datasets, initialRendererState, uiConfig]);
if (error && !isNotFoundError) throw error; // caught in error boundary
@@ -398,8 +403,6 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => {
if (error && !isNotFoundError) throw error; // caught in error boundary
- const DashboardBuilderComponent = useMemo(() => , []);
-
if (isNotFoundError) {
return (
= ({ idOrSlug }: PageProps) => {
return (
<>
- {readyToRender && hasDashboardInfoInitiated ? (
+ {readyToRender && hasDashboardInfoInitiated && rendererProps ? (
<>
-
-
- {DashboardBuilderComponent}
-
-
+
>
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index dd639ec1b18..c891d9f47fd 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -25,6 +25,7 @@ import {
chat,
core,
commands,
+ dashboards,
editors,
extensions,
menus,
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
chat,
core,
commands,
+ dashboards,
editors,
extensions,
menus,
diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts
index 0e7f6a3063b..7e43f4a3508 100644
--- a/superset-frontend/src/extensions/Namespaces.ts
+++ b/superset-frontend/src/extensions/Namespaces.ts
@@ -31,6 +31,7 @@ import type {
chat,
commands,
core,
+ dashboards,
editors,
extensions,
menus,
@@ -45,6 +46,7 @@ export interface Namespaces {
core: typeof core;
chat: typeof chat;
commands: typeof commands;
+ dashboards: typeof dashboards;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
diff --git a/superset-frontend/src/types/Dashboard.ts b/superset-frontend/src/types/Dashboard.ts
index 99c16f3fc8e..3ac7de07e57 100644
--- a/superset-frontend/src/types/Dashboard.ts
+++ b/superset-frontend/src/types/Dashboard.ts
@@ -21,6 +21,7 @@ import Role from './Role';
export interface Dashboard {
id: number;
+ uuid?: string;
slug?: string | null;
url: string;
dashboard_title: string;
@@ -32,6 +33,7 @@ export interface Dashboard {
changed_by_name: string;
changed_by: Owner;
changed_on: string;
+ is_managed_externally?: boolean;
charts: string[]; // just chart names, unfortunately...
owners: Owner[];
extra_owners?: Owner[];