[SIP-219] A uniform model for built-in extensions: registration, resolution, and loading
- Dominant language
- Python
- Stars
- 74.8k
- Forks
- 18.3k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 685
Description
*Please make sure you are familiar with the SIP process documented*
[here](https://github.com/apache/superset/issues/5602). The SIP will be numbered by a committer upon acceptance.
## [SIP-219] A uniform model for built-in extensions: registration, resolution, and loading
### Motivation
Superset is steadily extracting core functionality into extensions. [SIP-151](https://github.com/apache/superset/issues/31932) gave us the extensions architecture and the contribution points that let extensions replace built-in functionality: the SQL Lab editor can be swapped for a Monaco-based one, and with [#41703](https://github.com/apache/superset/pull/41703) the dashboard renderer can be swapped too. That is a direction of travel, not a one-off. As more of the core is pulled out into its own packages, the host becomes a thin shell and more of Superset ships as extensions that consume the same public contribution-point API a third-party author would use. The 20th extraction should look like the first.
This SIP is about how *built-in* extensions fit into that world: how they register, how the host resolves which implementation is active, and how they load. Built-in and third-party extensions should share one model and differ in only two ways: how they load (a built-in is statically part of the app and always present, a third-party extension is fetched at runtime behind a flag) and what tier they occupy (a built-in is the default, a third-party extension overrides or augments it).
Today that is not how built-ins work. The guarantee that core surfaces keep rendering when `ENABLE_EXTENSIONS` is off (it is off by default) comes from hardcoded fallbacks inside host components. `EditorHost` checks the editor registry and, when no extension has registered a provider for the language, falls back to a hardcoded `AceEditorProvider`. It works, but it has structural drawbacks:
1. The built-in bypasses the contribution point it anchors. The built-in path and the extension path are two different code paths in the host, so Superset never exercises its own contract. Regressions only surface downstream, in third-party extensions.
2. Introspection lies. `editors.getEditor('sql')` returns `undefined` even though an editor is clearly rendering. The API cannot tell you what is actually on screen.
3. Extensions can only replace, never augment. There is no way to retrieve the built-in and wrap it (add a toolbar to the default editor, chrome around the default renderer), because the built-in is not reachable through the API.
4. Fallback logic is duplicated per host, and each host invents its own variant, instead of the registry owning resolution.
We want built-ins to be first-class citizens of the extension system, without ever making core UX depend on the extensions feature flag or the extension loading machinery.
### Proposed Change
Five parts: a shared registration and resolution base, two flavors of contribution point (replaceable and augmentable), a boundary between resolution and lifecycle, a loading model that keeps core UX off the runtime loader, and one package per built-in.
**1. Shared registration and resolution: `DefaultableRegistry`.**
Contribution points that wrap built-in functionality share one base, `DefaultableRegistry`, with two tiers:
- Default tier (host): the built-in registers *through the contribution point itself* as the default provider, under a reserved `superset.` id (`superset.dashboard-renderer`, `superset.ace-editor`, ...). Registration is a side-effect module import wherever the surface renders, independent of `ENABLE_EXTENSIONS`, the extensions startup, and the loader. The component is loaded via `React.lazy`, so registering the default does not pull it into the startup bundle.
- Override tier (extensions): extensions register through the existing public API (`registerDashboardRenderer`, `registerEditor`, ...).
Resolution lives in the registry, not in a host code branch. `getActive()` returns `override ?? default`, disposing an override falls back to the default through the registry, and introspection is truthful: `getDefault()`, `getOverride()`, and `getActive()` each answer what they say. The feature flag gates overrides, never defaults, so with `ENABLE_EXTENSIONS` off the default renders unconditionally and a broken or absent extension system can never take down a core surface.
The three registries that exist today (`DashboardRendererProviders`, `EditorProviders`, `ChatProvider`) each hand-roll the same singleton, subscribe, and resolve shape. This folds that duplication into `DefaultableRegistry`, with each contribution point extending it. To @michael-s-molina's question about a single resolution mechanism vs. per-type registries: it is one shared model, many typed registries. Provider shapes differ per contribution point, so the storage stays typed and per-point, but the default/override/resolve/dispose semantics come from one base.
**2. Two flavors: replaceable vs. augmentable.**
Whether registering a second provider *replaces* the built-in or *adds an option alongside* it is a property of the contribution point, declared once, not something an individual extension decides.
- Replaceable (single slot): at most one override occupies the slot, the most recent registration wins and displaces the previous override, never the default. `getActive()` is `override ?? default`. The dashboard renderer is replaceable.
- Augmentable (multiple providers coexist): the default and any number of added providers are all valid at once, and the *user* chooses which is active. A `SelectableRegistry` extends `DefaultableRegistry` with `getAll()` and a user selection, and the contribution point declares a pointer to where a selection control mounts. The host renders a standard picker at that anchor, populated from `getAll()` (providers can supply a label and icon), and active is `selection ?? default`. SQL Lab editors are the motivating case: you do not have to replace Ace, you can add a second editor and let the user pick which one to use, per tab.
Extensions contribute providers, the contribution point owns the chooser, so two extensions cannot ship competing selectors fighting over the same surface.
**3. Resolution vs. lifecycle.**
This is @EnxDev's point, and it is worth stating as a firm boundary. The SIP standardizes how a provider is *resolved* (the tiers, `override ?? default` or user selection, disposal, introspection). It deliberately does not standardize *instantiation*: how many instances exist and when they are created is up to each contribution point. Chat is a singleton per app, a dashboard builder is a singleton for the active dashboard, editors have one instance per tab, chart contributions are scoped per chart. Selection state for an augmentable point (whose choice it is, where it is stored, at what scope) is part of that lifecycle and belongs to the surface, not to this SIP. One resolution model, many lifecycles. Surface-specific SIPs define their own lifecycle on top of the shared provider model.
**4. Loading: keep core UX off the runtime loader.**
How a built-in *loads* is separate from how it *registers*, and it is a per-extension choice on a criticality spectrum, not one global decision.
`ExtensionsLoader` today fetches a list from an API and, for each extension, injects a `` and initializes webpack module federation, all behind `ENABLE_EXTENSIONS`. That is exactly the machinery a core surface must not depend on: a dashboard cannot wait on an API call, a remote script fetch, and share-scope init, and it cannot be gated by a flag that is off by default.
So:
- Critical-path built-ins (dashboard renderer, SQL editor) are statically bundled and register their default eagerly. They never route through the runtime loader or the flag, which preserves the guarantee that core renders even if the extension system is entirely disabled or broken.
- Non-critical built-ins (future extractions that are optional enhancements) may load through the same federation runtime as third-party extensions, when independent runtime deployment is worth it.
- Registration is loader-agnostic. A provider lands in its registry identically whether it got there by a static side-effect import or a federated remote, so we never have to revisit resolution to change loading.
Publishing a built-in package to NPM is a distribution and versioning question, separate from loading. The common case is a built-in that is published to NPM *and* statically bundled (an ordinary workspace dependency compiled into the app): independent package, independent versioning, no runtime-loader dependency.
**5. Code organization: one package per built-in.**
Each built-in extension is its own package in the monorepo (`superset-frontend/packages/*`), depending on `@apache-superset/core` and nothing internal. The package boundary is the guarantee: a built-in that can only reach the public contribution-point API cannot quietly couple to host internals, which is the same discipline we ask of third-party authors, enforced by construction rather than convention. This answers @michael-s-molina's code-organization question: built-ins are not mixed into non-extension code, they become packages, and obeying the same API as external extensions is what the package boundary enforces. As core is extracted over time, each extracted piece becomes one of these packages, so the packages directory grows as the core shrinks.
### New or Changed Public Interfaces
- A shared `DefaultableRegistry` base: `setDefaultProvider` (host-internal, idempotent by id, deliberately not exposed on `window.superset`), `registerProvider` (public, returns a `Disposable`), `getDefault()`, `getOverride()`, `getActive()`.
- A `SelectableRegistry` extending it for augmentable points: `getAll()`, a selection accessor and setter, and a declared selection-UI anchor the host renders a picker into.
- Truthful introspection on existing points: `dashboards.getDashboardRenderer()` and `editors.getEditor(language)` return the *active* provider (override or default), where they previously returned `undefined` when no extension had registered. `dashboards.getDefaultDashboardRenderer()` ships with #41703.
- Reserved `superset.` prefixed ids for built-ins (`superset.dashboard-renderer`, `superset.ace-editor`, ...).
- No REST endpoints, models, CLI, or deployment changes.
### New dependencies
None. Built-in extensions are workspace packages, statically bundled.
### Migration Plan and Compatibility
No database migrations. The pattern is behavior-compatible: with no extensions registered, the default renders exactly what the hardcoded fallback rendered before. Concrete follow-ups once this passes:
- `editors`: register `AceEditorProvider` as the default per language, remove the hardcoded fallback from `EditorHost`, and make SQL Lab editors the first augmentable point (a second editor addable alongside Ace with a user picker).
- `chat`: adopt the registry shape with an empty default tier. If a built-in assistant ever ships, it lands as the default with no API change.
- SQL Lab South Pane: the built-in Results and Query History tabs are hardcoded in `SouthPane` while extension views are appended from the `sqllab.panels` registry. Registering the built-ins as defaults in that same registry opens up reordering, replacing, and augmenting them.
- Docs: promote the pattern from the dashboards extension-point page to the extensions architecture doc as the standard for replace-the-default and augment-the-default contribution points.
The dashboard renderer (#41703) is the reference implementation and is on `hold:sip!` pending this vote. Dashboard-specific concerns (entry points, the renderer's own migration to the contract) stay in @EnxDev's Dashboard Extensions SIP. This proposal covers only the generic infrastructure.
The one observable change is introspection becoming truthful, which is a strict improvement but worth a note in the extensions changelog: an extension that used `getEditor() === undefined` as a "no custom editor" check should switch to the override accessor or a provider-id check.
### Rejected Alternatives
1. **Hardcoded fallback in the host component** (today's SQL editor approach). Simple, and nothing breaks if a given contribution point stays on it for a while, so this SIP treats it as the migration starting point rather than an error. Rejected as the long-term pattern for the reasons in Motivation: the contract is not dogfooded, introspection returns `undefined` while a built-in renders, augmentation is impossible, and every host duplicates its own fallback.
2. **Loading all built-ins through module federation**, like third-party extensions. Maximally uniform, and tempting as the core shrinks into extensions. Rejected as a blanket rule because the federation loader is a runtime remote fetch gated by a flag and an API call, and core surfaces cannot depend on that. Refined instead into the per-criticality loading model above: critical-path built-ins are statically bundled and never touch the loader, non-critical ones may federate. A narrower variant, federation with eager, build-time-resolved, same-origin remotes (no API fetch, no flag), was considered. It buys uniformity but deliberately switches off the runtime-swap behavior that makes federation worth its complexity, so for a statically-available built-in it is cost without payoff.
3. **Enabling `ENABLE_EXTENSIONS` by default** so registry-based built-ins always load. Rejected: it conflates two decisions (whether operators opt into third-party extensions vs. whether built-ins render), widens the default security surface, and still leaves built-ins dependent on loader machinery.
Contributor guide
Assessment
This issue has not been assessed yet.