Description
registerDefaultConnectors() in routes/connectors-home/default-connectors.tsx unconditionally re-registers every PHP-discovered AI provider with args.render = ApiKeyConnector (when authentication.method === 'api_key'). The connectors store reducer in packages/connectors/src/store.ts spreads the new config over the existing entry, so whichever caller fires last wins per key.
registerDefaultConnectors() runs inside the routes/connectors-home/content module, which is loaded asynchronously via dynamic import() from the boot system after the page-level inline import("@wordpress/boot").then(mod => mod.initSinglePage(...)) resolves. That happens several microtasks after any third-party script-module entry has executed.
So when a plugin enqueues a connector script module that calls __experimentalRegisterConnector( slug, { render: CustomCard } ), the call lands in the store first. Then the dynamic-import chain settles, registerDefaultConnectors() runs, and overwrites the custom render with ApiKeyConnector.
PR #76722 added JS-extensibility e2e coverage, but only verifies the server-then-client direction (where the test plugin's script module runs after the default register). The client-then-server direction — which is the natural order for any plugin enqueueing on the standard options-connectors-wp-admin_init hook — is unverified and broken.
Reproducer
Repo: https://github.com/Ultimate-Multisite/ultimate-ai-connector-webllm
Minimal pattern any third-party AI provider plugin can hit:
- Register a provider with the PHP AI Client SDK at
init priority 5 (AiClient::defaultRegistry()->registerProvider(MyProvider::class)).
- Hook
options-connectors-wp-admin_init and call wp_register_script_module() + wp_enqueue_script_module() for the connector bundle, declaring [ 'id' => '@wordpress/connectors', 'import' => 'static' ] as a dependency.
- The bundle imports
@wordpress/connectors and calls __experimentalRegisterConnector( 'my-provider', { render: MyCustomCard } ).
Expected: the Connectors page card uses MyCustomCard.
Actual: it uses ApiKeyConnector, prompting for an API key the provider doesn't have.
Also racy with multiple third-party connector plugins: exactly one of them happens to win because of late-loading dependency-graph quirks, making the bug look intermittent and load-order-dependent.
Root cause walkthrough
wp_options_connectors_wp_admin_enqueue_scripts (wp-includes/build/pages/options-connectors/page-wp-admin.php:120) fires do_action( 'options-connectors-wp-admin_init' ).
- Third-party plugins hook this and enqueue their connector script modules.
- After the action returns, the function enqueues the page's
options-connectors-wp-admin entry (loader.js) and adds an inline module script: import("@wordpress/boot").then(mod => mod.initSinglePage({routes: [...]})).
- Browser executes top-level module scripts in document order: third-party bundles run first and call
registerConnector('foo', { render: CustomCard }). Store now has foo: { render: CustomCard, ... }.
- The inline
import("@wordpress/boot") resolves; initSinglePage calls dynamic import() for each route's content module.
routes/connectors-home/content module body runs registerDefaultConnectors(). For every PHP provider id (including foo), it dispatches registerConnector( 'foo', { render: ApiKeyConnector, name, description, logo, authentication, plugin } ).
- Reducer spreads existing entry then new config —
render is overwritten with ApiKeyConnector. Bug.
Note that hook ordering on the PHP side cannot fix this. The clobber happens inside an async dynamic-import chain in the browser, after every top-level script module has finished executing. There is no PHP hook that fires "after the boot system's dynamic imports have settled" — that's a runtime browser event.
Suggested fix
In routes/connectors-home/default-connectors.tsx, skip slugs that already have a custom render registered. Merge the PHP-side metadata (name, description, logo, authentication, plugin) but leave the existing render alone:
import { select } from '@wordpress/data';
import { unlock } from './lock-unlock';
import { store } from '@wordpress/connectors';
export function registerDefaultConnectors() {
const connectors = getConnectorData();
const sanitize = ( s: string ) => s.replace( /[^a-z0-9-_]/gi, '-' );
const existing = unlock( select( store ) ).getConnectors();
for ( const [ connectorId, data ] of Object.entries( connectors ) ) {
if ( connectorId === 'akismet' && ! data.plugin?.isInstalled ) continue;
const connectorName = sanitize( connectorId );
const prior = existing.find( ( c ) => c.slug === connectorName );
const args: Partial< Omit< ConnectorConfig, 'slug' > > = {
name: data.name,
description: data.description,
type: data.type,
logo: getConnectorLogo( connectorId, data.logoUrl ),
authentication: data.authentication,
plugin: data.plugin,
};
// Only set the default render if no plugin has already supplied one.
if ( ! prior?.render && data.authentication.method === 'api_key' ) {
args.render = ApiKeyConnector;
}
registerConnector( connectorName, args );
}
}
Even simpler: skip the dispatch entirely if prior?.render exists. The plugin-supplied entry already has the render, label, and description. The only thing PHP adds that JS doesn't have is the plugin install/activation state and authentication config — and a plugin that registered its own render presumably doesn't need either.
Workaround (in third-party plugin)
Re-call registerConnector() on multiple ticks (sync + microtask + setTimeout 0/50/250/1000ms) so the third-party plugin always ends up last. Effective but clearly a hack — every third-party connector plugin will need the same dance until this is fixed in core.
Live example:
https://github.com/Ultimate-Multisite/ultimate-ai-connector-webllm/blob/main/src/connector.jsx (search for registerOurs)
Test case to add
Extend test/e2e/test-plugins/connectors-js-extensibility/ (added in PR #76722) with a second connector whose script module is enqueued via the standard options-connectors-wp-admin_init action — i.e. exercising the plugin-script-runs-before-default-register direction. Assert its custom render still appears after page boot.
Environment
- WordPress 7.0-RC2-62197
- AI plugin 0.6.0
- Gutenberg trunk (verified
default-connectors.tsx and packages/connectors/src/store.ts in the linked source files)
- Affects every third-party AI provider plugin that wants a custom Connectors page card without an API-key prompt — particularly painful for local/self-hosted providers (Ollama, LM Studio, WebLLM) that don't have an API key concept at all.
cc @ folks from PR #76722
Description
registerDefaultConnectors()inroutes/connectors-home/default-connectors.tsxunconditionally re-registers every PHP-discovered AI provider withargs.render = ApiKeyConnector(whenauthentication.method === 'api_key'). The connectors store reducer inpackages/connectors/src/store.tsspreads the new config over the existing entry, so whichever caller fires last wins per key.registerDefaultConnectors()runs inside theroutes/connectors-home/contentmodule, which is loaded asynchronously via dynamicimport()from the boot system after the page-level inlineimport("@wordpress/boot").then(mod => mod.initSinglePage(...))resolves. That happens several microtasks after any third-party script-module entry has executed.So when a plugin enqueues a connector script module that calls
__experimentalRegisterConnector( slug, { render: CustomCard } ), the call lands in the store first. Then the dynamic-import chain settles,registerDefaultConnectors()runs, and overwrites the custom render withApiKeyConnector.PR #76722 added JS-extensibility e2e coverage, but only verifies the server-then-client direction (where the test plugin's script module runs after the default register). The client-then-server direction — which is the natural order for any plugin enqueueing on the standard
options-connectors-wp-admin_inithook — is unverified and broken.Reproducer
Repo: https://github.com/Ultimate-Multisite/ultimate-ai-connector-webllm
Minimal pattern any third-party AI provider plugin can hit:
initpriority 5 (AiClient::defaultRegistry()->registerProvider(MyProvider::class)).options-connectors-wp-admin_initand callwp_register_script_module()+wp_enqueue_script_module()for the connector bundle, declaring[ 'id' => '@wordpress/connectors', 'import' => 'static' ]as a dependency.@wordpress/connectorsand calls__experimentalRegisterConnector( 'my-provider', { render: MyCustomCard } ).Expected: the Connectors page card uses
MyCustomCard.Actual: it uses
ApiKeyConnector, prompting for an API key the provider doesn't have.Also racy with multiple third-party connector plugins: exactly one of them happens to win because of late-loading dependency-graph quirks, making the bug look intermittent and load-order-dependent.
Root cause walkthrough
wp_options_connectors_wp_admin_enqueue_scripts(wp-includes/build/pages/options-connectors/page-wp-admin.php:120) firesdo_action( 'options-connectors-wp-admin_init' ).options-connectors-wp-adminentry (loader.js) and adds an inline module script:import("@wordpress/boot").then(mod => mod.initSinglePage({routes: [...]})).registerConnector('foo', { render: CustomCard }). Store now hasfoo: { render: CustomCard, ... }.import("@wordpress/boot")resolves;initSinglePagecalls dynamicimport()for each route's content module.routes/connectors-home/contentmodule body runsregisterDefaultConnectors(). For every PHP provider id (includingfoo), it dispatchesregisterConnector( 'foo', { render: ApiKeyConnector, name, description, logo, authentication, plugin } ).renderis overwritten withApiKeyConnector. Bug.Note that hook ordering on the PHP side cannot fix this. The clobber happens inside an async dynamic-import chain in the browser, after every top-level script module has finished executing. There is no PHP hook that fires "after the boot system's dynamic imports have settled" — that's a runtime browser event.
Suggested fix
In
routes/connectors-home/default-connectors.tsx, skip slugs that already have a customrenderregistered. Merge the PHP-side metadata (name,description,logo,authentication,plugin) but leave the existingrenderalone:Even simpler: skip the dispatch entirely if
prior?.renderexists. The plugin-supplied entry already has the render, label, and description. The only thing PHP adds that JS doesn't have is theplugininstall/activation state andauthenticationconfig — and a plugin that registered its own render presumably doesn't need either.Workaround (in third-party plugin)
Re-call
registerConnector()on multiple ticks (sync + microtask + setTimeout 0/50/250/1000ms) so the third-party plugin always ends up last. Effective but clearly a hack — every third-party connector plugin will need the same dance until this is fixed in core.Live example:
https://github.com/Ultimate-Multisite/ultimate-ai-connector-webllm/blob/main/src/connector.jsx (search for
registerOurs)Test case to add
Extend
test/e2e/test-plugins/connectors-js-extensibility/(added in PR #76722) with a second connector whose script module is enqueued via the standardoptions-connectors-wp-admin_initaction — i.e. exercising the plugin-script-runs-before-default-register direction. Assert its custom render still appears after page boot.Environment
default-connectors.tsxandpackages/connectors/src/store.tsin the linked source files)cc @ folks from PR #76722