Skip to content

Connectors: registerDefaultConnectors() clobbers third-party custom render via async dynamic-import race #77115

@superdav42

Description

@superdav42

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:

  1. Register a provider with the PHP AI Client SDK at init priority 5 (AiClient::defaultRegistry()->registerProvider(MyProvider::class)).
  2. 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.
  3. 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

  1. 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' ).
  2. Third-party plugins hook this and enqueue their connector script modules.
  3. 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: [...]})).
  4. 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, ... }.
  5. The inline import("@wordpress/boot") resolves; initSinglePage calls dynamic import() for each route's content module.
  6. 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 } ).
  7. 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

Metadata

Metadata

Assignees

Labels

Connectors screenTracks connectors screen related tasks[Status] In ProgressTracking issues with work in progress[Type] BugAn existing feature does not function as intended

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions