You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by am...@apache.org on 2026/10/20 15:23:00 UTC

(superset) 02/08: feat(extensions): a pages API, so a contributed route renders something

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

amaannawab923 pushed a commit to branch feat/extension-pages
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 84f8d1b82a059c242a7f2202367d640e7867f40a
Author: amaannawab923 <am...@gmail.com>
AuthorDate: Tue Oct 20 16:42:05 2026 +0530

    feat(extensions): a pages API, so a contributed route renders something
    
    The namespaced route this sits on serves the application shell for any
    path under an extension's id, but nothing on the frontend knew that route
    existed: `RouteSwitch` maps only the static route table, and its single
    fallback is `exact`, so the URL rendered the navbar above an empty body.
    
    An extension now registers a page by slug:
    
        pages.registerPage({ slug: 'queue', name: 'Review queue' }, Queue);
    
    and the host serves it at `/extensions/<publisher>.<name>/queue`.
    
    The slug is the whole declaration. The host derives both the id and the
    URL from the extension's own identity, the way the backend already derives
    an extension's REST namespace from an ambient context rather than from an
    argument. `ExtensionsLoader` binds `registerPage` per extension before
    handing the namespace to extension code, and module federation resolves
    `@apache-superset/core` once per container, so every import inside an
    extension receives that bound instance. An extension states which page it
    is contributing, never whose, and so cannot claim a URL belonging to the
    host or to another extension.
    
    `ExtensionPage` resolves the current URL against the registry. Deciding
    that a path is genuinely unclaimed is only possible once extensions have
    settled, so it waits on the loader's promise rather than guessing, and
    renders a real "page not found" afterwards rather than leaving a spinner.
    That matters because the server answers 200 for every path under the
    namespace -- it cannot know which pages exist, which is what lets one rule
    serve all of them -- so the client is the only place the truth is known.
    
    `@superset-ui/core` joins the share scope. Two reasons: the registries in
    it hold state in module-level closures, so a second copy is a second,
    unreachable registry, and `SupersetClient` lives there. Without it an
    extension hand-rolls `fetch` and attaches a CSRF token to every mutation
    itself. Verified from an extension: `SupersetClient.post` creates and
    deletes a dashboard with no manual CSRF.
---
 .../packages/superset-core/src/index.ts            |   1 +
 .../packages/superset-core/src/pages/index.ts      | 100 ++++++++++++
 superset-frontend/src/core/index.ts                |   1 +
 superset-frontend/src/core/pages/index.test.ts     | 150 ++++++++++++++++++
 superset-frontend/src/core/pages/index.ts          | 169 +++++++++++++++++++++
 .../src/extensions/ExtensionsLoader.ts             |  14 ++
 .../src/extensions/ExtensionsStartup.tsx           |   2 +
 superset-frontend/src/extensions/Namespaces.ts     |   2 +
 .../src/pages/ExtensionPage/index.tsx              |  86 +++++++++++
 superset-frontend/src/views/routes.tsx             |  16 ++
 superset-frontend/webpack.config.js                |  18 +++
 11 files changed, 559 insertions(+)

diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts
index 6d6a0aa4653..f1b6839920c 100644
--- a/superset-frontend/packages/superset-core/src/index.ts
+++ b/superset-frontend/packages/superset-core/src/index.ts
@@ -24,6 +24,7 @@ export * as editors from './editors';
 export * as extensions from './extensions';
 export * as menus from './menus';
 export * as navigation from './navigation';
+export * as pages from './pages';
 export * as sqlLab from './sqlLab';
 export * as views from './views';
 export * as contributions from './contributions';
diff --git a/superset-frontend/packages/superset-core/src/pages/index.ts b/superset-frontend/packages/superset-core/src/pages/index.ts
new file mode 100644
index 00000000000..e0bf48b5eff
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/pages/index.ts
@@ -0,0 +1,100 @@
+/**
+ * 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 Pages API for Superset extensions.
+ *
+ * A page is a screen of the extension's own, at its own URL, as opposed to a
+ * view that occupies a slot inside a screen the host already ships.
+ *
+ * An extension names a page by a bare `slug`; the host derives the URL from
+ * the extension's identity, exactly as it does for an extension's REST APIs.
+ * An extension cannot choose, prefix or spoof its own URL space.
+ *
+ * @example
+ * ```typescript
+ * import { pages } from '@apache-superset/core';
+ *
+ * pages.registerPage({ slug: 'queue', name: 'Review queue' }, ReviewQueue);
+ * // served at /extensions/<publisher>.<name>/queue
+ * ```
+ */
+
+import { ComponentType } from 'react';
+import { Disposable, Event } from '../common';
+
+/**
+ * A page contributed by an extension.
+ */
+export interface Page {
+  /**
+   * Names the page within its extension. Lowercase letters, digits and
+   * hyphens.
+   *
+   * This is a slug, not a path: the host prefixes it with the extension's own
+   * namespace. A slug of `queue` becomes `/extensions/<publisher>.<name>/queue`.
+   * Declaring a full path would let one extension claim another's URL, or the
+   * host's.
+   */
+  slug: string;
+  /** The display name of the page. */
+  name: string;
+  /** Optional description, for display in contribution manifests. */
+  description?: string;
+}
+
+/**
+ * A page together with the URL the host derived for it.
+ */
+export interface ResolvedPage extends Page {
+  /** The extension that contributed the page, as `publisher.name`. */
+  extensionId: string;
+  /** The application-root-relative path the page is served at. */
+  path: string;
+}
+
+/**
+ * Registers a page, mounted by the host as a client-side route.
+ *
+ * The component renders inside the host's own chrome, under an error
+ * boundary, and may use the router and store hooks as a host page does.
+ *
+ * Registering the same slug twice within one extension replaces the first
+ * registration and logs a warning; slugs cannot collide across extensions,
+ * because each extension's pages live under its own namespace.
+ *
+ * @param page The page descriptor.
+ * @param component The React component to render at that route.
+ * @returns A Disposable that unmounts the page when disposed.
+ */
+export declare function registerPage(
+  page: Page,
+  component: ComponentType,
+): Disposable;
+
+/**
+ * Retrieves every registered page, in registration order.
+ */
+export declare function getPages(): ResolvedPage[];
+
+/** Event fired when a page is registered. */
+export declare const onDidRegisterPage: Event<ResolvedPage>;
+
+/** Event fired when a page is unregistered. */
+export declare const onDidUnregisterPage: Event<ResolvedPage>;
diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts
index ca8440c68e2..2af8d202659 100644
--- a/superset-frontend/src/core/index.ts
+++ b/superset-frontend/src/core/index.ts
@@ -34,6 +34,7 @@ export * from './extensions';
 export * from './menus';
 export * from './models';
 export * from './navigation';
+export * from './pages';
 export * from './sqlLab';
 export * from './storage';
 export * from './utils';
diff --git a/superset-frontend/src/core/pages/index.test.ts b/superset-frontend/src/core/pages/index.test.ts
new file mode 100644
index 00000000000..a5b49a17093
--- /dev/null
+++ b/superset-frontend/src/core/pages/index.test.ts
@@ -0,0 +1,150 @@
+/**
+ * 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 { logging } from '@apache-superset/core/utils';
+import {
+  findPageByPath,
+  getPages,
+  pagePath,
+  registerPageFor,
+  resolvePage,
+} from './index';
+
+const disposables: Array<{ dispose: () => void }> = [];
+const Component = () => React.createElement('div', null, 'page');
+
+afterEach(() => {
+  disposables.forEach(d => d.dispose());
+  disposables.length = 0;
+});
+
+test('the host derives the URL from the extension, not the extension', () => {
+  // The extension supplies only a slug. If it could supply a path it could
+  // claim the host's URLs, or another extension's.
+  disposables.push(
+    registerPageFor('acme.tools', { slug: 'queue', name: 'Queue' }, Component),
+  );
+
+  expect(getPages()).toEqual([
+    {
+      slug: 'queue',
+      name: 'Queue',
+      extensionId: 'acme.tools',
+      path: '/extensions/acme.tools/queue',
+    },
+  ]);
+});
+
+test('two extensions may use the same slug without colliding', () => {
+  // Namespacing is what makes this safe; without it one would replace the
+  // other and the loser would simply stop rendering.
+  disposables.push(
+    registerPageFor('acme.tools', { slug: 'queue', name: 'A' }, Component),
+    registerPageFor('other.ext', { slug: 'queue', name: 'B' }, Component),
+  );
+
+  expect(getPages().map(p => p.path)).toEqual([
+    '/extensions/acme.tools/queue',
+    '/extensions/other.ext/queue',
+  ]);
+});
+
+test.each([
+  ['a path rather than a slug', '/queue'],
+  ['a nested path', 'queue/42'],
+  ['an attempt to escape the namespace', '../../login'],
+  ['uppercase', 'Queue'],
+  ['empty', ''],
+  ['a leading hyphen', '-queue'],
+])('a slug that is %s is refused', (_label, slug) => {
+  expect(() =>
+    registerPageFor('acme.tools', { slug, name: 'X' }, Component),
+  ).toThrow(/must be lowercase letters/);
+  expect(getPages()).toEqual([]);
+});
+
+test('re-registering a slug within one extension replaces it, and says so', () => {
+  const warn = jest.spyOn(logging, 'warn').mockImplementation(() => {});
+  try {
+    disposables.push(
+      registerPageFor('acme.tools', { slug: 'queue', name: 'First' }, Component),
+      registerPageFor('acme.tools', { slug: 'queue', name: 'Second' }, Component),
+    );
+
+    expect(getPages().map(p => p.name)).toEqual(['Second']);
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining('acme.tools.queue'),
+    );
+  } finally {
+    warn.mockRestore();
+  }
+});
+
+test('disposing a replaced registration leaves the replacement alone', () => {
+  const warn = jest.spyOn(logging, 'warn').mockImplementation(() => {});
+  try {
+    const first = registerPageFor(
+      'acme.tools',
+      { slug: 'queue', name: 'First' },
+      Component,
+    );
+    disposables.push(
+      registerPageFor('acme.tools', { slug: 'queue', name: 'Second' }, Component),
+    );
+
+    first.dispose();
+
+    expect(getPages().map(p => p.name)).toEqual(['Second']);
+  } finally {
+    warn.mockRestore();
+  }
+});
+
+test('a path is matched by prefix, so deep links reach their page', () => {
+  // The server serves `<path:rest>` under the page, so the client has to
+  // resolve the same way or a deep link would render "not found".
+  disposables.push(
+    registerPageFor('acme.tools', { slug: 'queue', name: 'Queue' }, Component),
+  );
+
+  expect(findPageByPath('/extensions/acme.tools/queue')?.slug).toBe('queue');
+  expect(findPageByPath('/extensions/acme.tools/queue/42/edit')?.slug).toBe(
+    'queue',
+  );
+});
+
+test('a path no extension claims matches nothing', () => {
+  disposables.push(
+    registerPageFor('acme.tools', { slug: 'queue', name: 'Queue' }, Component),
+  );
+
+  expect(findPageByPath('/extensions/acme.tools/nonexistent')).toBeUndefined();
+  expect(findPageByPath('/extensions/nobody.nothing/x')).toBeUndefined();
+  // A sibling slug sharing a prefix must not be swallowed.
+  expect(findPageByPath('/extensions/acme.tools/queue-archive')).toBeUndefined();
+});
+
+test('resolvePage falls back to a placeholder for an unknown id', () => {
+  expect(resolvePage('acme.tools.missing')).toBeTruthy();
+});
+
+test('pagePath is the single definition of the namespace', () => {
+  expect(pagePath('acme.tools', 'queue')).toBe('/extensions/acme.tools/queue');
+});
diff --git a/superset-frontend/src/core/pages/index.ts b/superset-frontend/src/core/pages/index.ts
new file mode 100644
index 00000000000..753a144e2d8
--- /dev/null
+++ b/superset-frontend/src/core/pages/index.ts
@@ -0,0 +1,169 @@
+/**
+ * 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-side registry for extension-contributed pages.
+ *
+ * Mirrors `src/core/views`: module-level state, a `useSyncExternalStore`
+ * subscription so a registration arriving after first render still lands, and
+ * an `ExtensionPlaceholder` for a page whose component has not arrived.
+ *
+ * The one thing this adds is namespacing. An extension registers a bare slug;
+ * the host prefixes it with the extension's identity to produce both the id
+ * and the URL. The extension never supplies a path, so it cannot claim a URL
+ * belonging to the host or to another extension.
+ */
+
+import React, { ComponentType, useSyncExternalStore } from 'react';
+import type { pages as pagesApi } from '@apache-superset/core';
+import { logging } from '@apache-superset/core/utils';
+import { ErrorBoundary } from 'src/components/ErrorBoundary';
+import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
+import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
+
+type Page = pagesApi.Page;
+type ResolvedPage = pagesApi.ResolvedPage;
+
+/** Slugs are a single path segment, in the same shape as an extension name. */
+const SLUG_PATTERN = /^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$/;
+
+/** The namespace every contributed page is served under. */
+export const pagePath = (extensionId: string, slug: string): string =>
+  `/extensions/${extensionId}/${slug}`;
+
+const pageRegistry: Map<string, { page: ResolvedPage; component: ComponentType }> =
+  new Map();
+
+const syncListeners = new Set<() => void>();
+const subscribe = (listener: () => void) => {
+  syncListeners.add(listener);
+  return () => syncListeners.delete(listener);
+};
+
+const registerEmitter = createEventEmitter<ResolvedPage>();
+const unregisterEmitter = createEventEmitter<ResolvedPage>();
+
+// `useSyncExternalStore` requires a referentially stable snapshot between
+// notifications, so the array is rebuilt only when the registry changes.
+let snapshot: ResolvedPage[] = [];
+const refresh = () => {
+  snapshot = Array.from(pageRegistry.values(), entry => entry.page);
+  syncListeners.forEach(listener => listener());
+};
+
+/**
+ * Registers a page on behalf of a specific extension.
+ *
+ * Not part of the public API: the loader binds `extensionId` per extension
+ * before handing the namespace to extension code, so an extension cannot
+ * name another extension's id. See `ExtensionsLoader.loadModule`.
+ */
+export const registerPageFor = (
+  extensionId: string,
+  page: Page,
+  component: ComponentType,
+): Disposable => {
+  if (!page?.slug || !SLUG_PATTERN.test(page.slug)) {
+    throw new Error(
+      `Page slug "${page?.slug ?? '<missing>'}" must be lowercase letters, ` +
+        `digits and single hyphens. A slug names a page within an extension; ` +
+        `the host supplies the rest of the URL.`,
+    );
+  }
+
+  const id = `${extensionId}.${page.slug}`;
+  const resolved: ResolvedPage = {
+    ...page,
+    extensionId,
+    path: pagePath(extensionId, page.slug),
+  };
+
+  const replaced = pageRegistry.get(id);
+  if (replaced) {
+    // Within one extension only -- ids are namespaced, so this can never be
+    // another extension's page. Silent replacement would simply make the
+    // first registration stop rendering with nothing said about it.
+    logging.warn(
+      `Page "${id}" is already registered; it has been replaced and the ` +
+        `earlier component no longer renders.`,
+    );
+  }
+
+  const entry = { page: resolved, component };
+  pageRegistry.set(id, entry);
+  refresh();
+  registerEmitter.fire(resolved);
+
+  return new Disposable(() => {
+    // Only while this registration still holds the id: disposing one that has
+    // since been replaced would remove the replacement instead.
+    if (pageRegistry.get(id) !== entry) {
+      return;
+    }
+    pageRegistry.delete(id);
+    refresh();
+    unregisterEmitter.fire(resolved);
+  });
+};
+
+export const getPages = (): ResolvedPage[] => snapshot;
+
+export const usePages = (): ResolvedPage[] =>
+  useSyncExternalStore(subscribe, getPages, getPages);
+
+/** The page registered for a path, or undefined when nothing claims it. */
+export const findPageByPath = (path: string): ResolvedPage | undefined =>
+  snapshot.find(page => page.path === path || path.startsWith(`${page.path}/`));
+
+export const resolvePage = (id: string): React.ReactElement => {
+  const entry = pageRegistry.get(id);
+  if (!entry) {
+    return React.createElement(ExtensionPlaceholder, { id });
+  }
+  return React.createElement(
+    ErrorBoundary,
+    null,
+    React.createElement(entry.component),
+  );
+};
+
+export const onDidRegisterPage = (listener: (e: ResolvedPage) => void) =>
+  registerEmitter.subscribe(listener);
+
+export const onDidUnregisterPage = (listener: (e: ResolvedPage) => void) =>
+  unregisterEmitter.subscribe(listener);
+
+/**
+ * The namespace handed to extension code, with `registerPage` unbound.
+ *
+ * The loader replaces `registerPage` per extension; this shape exists so the
+ * host's own `window.superset.pages` is complete and type-compatible.
+ */
+export const pages = {
+  registerPage: (() => {
+    throw new Error(
+      'pages.registerPage is bound per extension by the loader and cannot ' +
+        'be called on the host namespace directly.',
+    );
+  }) as unknown as typeof pagesApi.registerPage,
+  getPages,
+  onDidRegisterPage,
+  onDidUnregisterPage,
+} as unknown as typeof pagesApi;
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts
index 80b396fa484..529ac0e9261 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.ts
@@ -19,6 +19,9 @@
 import { SupersetClient } from '@superset-ui/core';
 import { logging } from '@apache-superset/core/utils';
 import type { common as core } from '@apache-superset/core';
+import type { ComponentType } from 'react';
+import type { pages as pagesApi } from '@apache-superset/core';
+import { registerPageFor } from 'src/core/pages';
 import { makeUrl } from 'src/utils/navigationUtils';
 import 'src/extensions/Namespaces';
 import { createExtensionContext } from './ExtensionContext';
@@ -217,6 +220,17 @@ class ExtensionsLoader {
         ...window.superset.extensions,
         getContext: () => context,
       },
+      // `registerPage` is bound to this extension's id here, for the same
+      // reason the backend derives an extension's URL namespace from an
+      // ambient context rather than an argument: an extension states which
+      // page it is contributing, never whose. Because module federation
+      // resolves @apache-superset/core once per container, every import
+      // inside this extension receives this bound instance.
+      pages: {
+        ...window.superset.pages,
+        registerPage: (page: pagesApi.Page, component: ComponentType) =>
+          registerPageFor(extension.id, page, component),
+      },
     };
     // Reuse the version key webpack already resolved for the host's own
     // @apache-superset/core shared module, rather than recomputing it, so
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index dd639ec1b18..b018d7c5f08 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -29,6 +29,7 @@ import {
   extensions,
   menus,
   navigation,
+  pages,
   useNavigationTracker,
   sqlLab,
   views,
@@ -63,6 +64,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
       extensions,
       menus,
       navigation,
+      pages,
       sqlLab,
       views,
     };
diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts
index 0e7f6a3063b..8abe38d0a44 100644
--- a/superset-frontend/src/extensions/Namespaces.ts
+++ b/superset-frontend/src/extensions/Namespaces.ts
@@ -35,6 +35,7 @@ import type {
   extensions,
   menus,
   navigation,
+  pages,
   sqlLab,
   views,
 } from 'src/core';
@@ -49,6 +50,7 @@ export interface Namespaces {
   extensions: typeof extensions;
   menus: typeof menus;
   navigation: typeof navigation;
+  pages: typeof pages;
   sqlLab: typeof sqlLab;
   views: typeof views;
 }
diff --git a/superset-frontend/src/pages/ExtensionPage/index.tsx b/superset-frontend/src/pages/ExtensionPage/index.tsx
new file mode 100644
index 00000000000..13e45e0e813
--- /dev/null
+++ b/superset-frontend/src/pages/ExtensionPage/index.tsx
@@ -0,0 +1,86 @@
+/**
+ * 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 { useEffect, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import { t } from '@apache-superset/core/translation';
+import { Loading } from '@superset-ui/core/components';
+import { findPageByPath, resolvePage, usePages } from 'src/core/pages';
+import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
+import { stripAppRoot } from 'src/utils/navigationUtils';
+import { ErrorAlert } from 'src/components/ErrorMessage/ErrorAlert';
+
+/**
+ * Renders whichever extension page claims the current URL.
+ *
+ * The server serves the application shell for every path under the extension
+ * namespace without knowing which pages exist -- that is what lets one route
+ * cover every extension. The consequence is that deciding a URL is genuinely
+ * unclaimed happens here, and it can only be decided once extensions have
+ * finished loading. Until then this must not say "not found", because the
+ * page may be moments away.
+ */
+export default function ExtensionPage() {
+  const { pathname } = useLocation();
+  // Subscribed rather than read once: an extension registering after this
+  // first render is the normal case, not the exception.
+  usePages();
+  const [extensionsSettled, setExtensionsSettled] = useState(false);
+
+  useEffect(() => {
+    let cancelled = false;
+    // Resolves whether or not individual extensions failed; a failure to load
+    // one extension still settles the question for every other path.
+    ExtensionsLoader.getInstance()
+      .initializeExtensions()
+      .catch(() => undefined)
+      .finally(() => {
+        if (!cancelled) {
+          setExtensionsSettled(true);
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const page = findPageByPath(stripAppRoot(pathname));
+
+  if (page) {
+    return resolvePage(`${page.extensionId}.${page.slug}`);
+  }
+
+  if (!extensionsSettled) {
+    return <Loading />;
+  }
+
+  // Everything that can claim this path has had its chance.
+  return (
+    <ErrorAlert
+      errorType={t('Page not found')}
+      message={t('No extension provides a page at this address.')}
+      description={t(
+        'The extension may not be installed, or may have failed to load. ' +
+          'Check the browser console and the Extensions screen.',
+      )}
+      type="warning"
+      closable={false}
+    />
+  );
+}
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 6b6b87b0115..92b2b858198 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -134,6 +134,11 @@ const Tags = lazy(
   () => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'),
 );
 
+const ExtensionPage = lazy(
+  () =>
+    import(/* webpackChunkName: "ExtensionPage" */ 'src/pages/ExtensionPage'),
+);
+
 const Extensions = lazy(
   () => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'),
 );
@@ -312,6 +317,17 @@ if (isAdmin) {
   }
 }
 
+if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
+  // One route for every page every extension contributes, mirroring the
+  // single server-side rule. `:extensionId` is matched loosely here and
+  // validated by the server; what actually decides whether a path is served
+  // is whether an extension registered a page for it.
+  routes.push({
+    path: '/extensions/:extensionId/:slug',
+    Component: ExtensionPage,
+  });
+}
+
 if (authRegistrationEnabled) {
   routes.push({ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations });
 }
diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js
index a7b938a5a6d..657ed77a76a 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -211,6 +211,24 @@ const plugins = [
         singleton: true,
         eager: true,
       },
+      // Shared for two reasons. First, the registries in here hold their
+      // state in module-level closures, so a second copy of the module is a
+      // second, unreachable registry: an extension calling
+      // `getExtensionsRegistry().set(...)` would write somewhere the host
+      // never reads, and the call would look like it worked.
+      //
+      // Second, this is where `SupersetClient` lives. Without it an extension
+      // that talks to Superset's API has to hand-roll `fetch`, including
+      // fetching and attaching a CSRF token for every mutation -- which is
+      // most of what `SupersetClient` exists to do.
+      //
+      // The host loads this module on every page already, so providing it
+      // eagerly costs no extra bytes and keeps extensions off an async
+      // boundary to reach it.
+      '@superset-ui/core': {
+        singleton: true,
+        eager: true,
+      },
     },
   }),
 ];