Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions lib/experimental/connectors/default-connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,6 @@ function _gutenberg_get_connector_script_module_data( array $data ): array {
return $data;
}

$registry = \WordPress\AiClient\AiClient::defaultRegistry();

// Build a slug-to-file map for plugin installation status.
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
Expand All @@ -448,11 +446,6 @@ function _gutenberg_get_connector_script_module_data( array $data ): array {
$auth_out['settingName'] = $auth['setting_name'] ?? '';
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
$auth_out['keySource'] = _gutenberg_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' );
try {
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$auth_out['isConnected'] = false;
}
}

$connector_out = array(
Expand Down Expand Up @@ -485,3 +478,59 @@ function _gutenberg_get_connector_script_module_data( array $data ): array {
}
remove_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
add_filter( 'script_module_data_options-connectors-wp-admin', '_gutenberg_get_connector_script_module_data' );

/**
* Streams connector connection-status checks to the browser after the page
* has been flushed, so the initial render is not blocked by slow HTTP
* validation requests.
*
* Each resolved status is delivered as an inline <script> that writes to
* window.__connectorStatuses and dispatches a CustomEvent so the React UI
* can update progressively.
*
* @access private
*/
function _gutenberg_stream_connector_statuses(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}

// Flush all output buffers so the browser receives the full page
// (including React bootstrap scripts) immediately.
while ( ob_get_level() ) {
ob_end_flush();
}
flush();
Comment on lines +498 to +503

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could conflict with the output buffer used for client-side media processing, which is now slated for 7.1.

Specifically, see WordPress/wordpress-develop#11324 and how wp_start_cross_origin_isolation_output_buffer() starts an output buffer via wp_set_up_cross_origin_isolation(). That said, it currently only added currently on these screens:

add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );

So while it doesn't seem it will be a problem in 7.0, and maybe it won't even be a problem in 7.1, I'm concerned it could cause compatibility problems elsewhere for perhaps other extensions that are output buffering the admin screens.

One possibility is to run this at the shutdown action. There is already the wp_ob_end_flush_all() function which runs at shutdown priority 1. So you could run the streaming of these scripts at shutdown priority 2 (or higher).


$registry = \WordPress\AiClient\AiClient::defaultRegistry();

foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'api_key' !== $auth['method'] ) {
continue;
}

try {
sleep( 3 ); // Simulate slow validation for demo purposes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just for demo to simulate a slow check.

$is_connected = $registry->hasProvider( $connector_id )
&& $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$is_connected = false;
}

printf(
'<script>'
. '(window.__connectorStatuses=window.__connectorStatuses||{})[%s]=%s;'
. 'document.dispatchEvent(new CustomEvent("connector-status",{detail:{id:%s,connected:%s}}));'
. '</script>',
wp_json_encode( $connector_id ),
$is_connected ? 'true' : 'false',
wp_json_encode( $connector_id ),
$is_connected ? 'true' : 'false'
);
Comment on lines +521 to +530

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple important suggestions here:

  1. This needs to use wp_print_inline_script_tag() so that CSP nonce attributes can be added.
  2. Additional flags should be provide to ensure safe JSON encoding.
  3. Encoded params can be once instead of multiple times.
  4. The JS shouldn't be minified. We can use a NOWDOc to format this to improve maintainability.
  5. A sourceURL comment should be added.
Suggested change
printf(
'<script>'
. '(window.__connectorStatuses=window.__connectorStatuses||{})[%s]=%s;'
. 'document.dispatchEvent(new CustomEvent("connector-status",{detail:{id:%s,connected:%s}}));'
. '</script>',
wp_json_encode( $connector_id ),
$is_connected ? 'true' : 'false',
wp_json_encode( $connector_id ),
$is_connected ? 'true' : 'false'
);
$function = <<<'JS'
( id, connected ) => {
window.__connectorStatuses = window.__connectorStatuses || {};
window.__connectorStatuses[ id ] = connected;
const event = new CustomEvent( "connector-status", { detail: { id, connected } } );
document.dispatchEvent( event );
}
JS;
wp_print_inline_script_tag(
sprintf( '( %s )( %s, %s );',
$function,
wp_json_encode( $connector_id, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $is_connected, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
) . "\n//# sourceURL=" . rawurlencode( __FUNCTION__ )
);

For reference for where this was recently done in core similarly:

https://github.com/WordPress/wordpress-develop/blob/5d3de27e868ccc9f4e93a2cb7e15f76147ab0443/src/wp-includes/class-wp-script-modules.php#L387-L420

https://github.com/WordPress/wordpress-develop/blob/5d3de27e868ccc9f4e93a2cb7e15f76147ab0443/src/wp-includes/admin-bar.php#L965-L985

In PhpStorm:

Before:

Image

After:

Image


// Flush each result so the browser processes it immediately.
flush();
}
}
add_action( 'admin_footer-settings_page_options-connectors-wp-admin', '_gutenberg_stream_connector_statuses' );
4 changes: 2 additions & 2 deletions routes/connectors-home/ai-plugin-callout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ export function AiPluginCallout() {
const [ isBusy, setIsBusy ] = useState( false );
const [ justActivated, setJustActivated ] = useState( false );

// Server-side initial state — true if any provider was already connected at page load.
// Server-side initial state — true if any provider has a configured key at page load.
const initialHasConnectedProvider = useRef(
connectorDataValues.some(
( c ) =>
c.type === 'ai_provider' &&
c.authentication.method === 'api_key' &&
c.authentication.isConnected
c.authentication.keySource !== 'none'
)
).current;

Expand Down
16 changes: 10 additions & 6 deletions routes/connectors-home/default-connectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ type ConnectorAuthentication =
settingName: string;
credentialsUrl: string | null;
keySource?: ApiKeySource;
isConnected?: boolean;
}
| { method: 'none' };

Expand Down Expand Up @@ -102,27 +101,27 @@ const ConnectedBadge = () => (
const UnavailableActionBadge = () => <Badge>{ __( 'Not available' ) }</Badge>;

interface ApiKeyConnectorConfig {
connectorId: string;
pluginSlug?: string;
settingName: string;
helpUrl?: string;
icon?: React.ReactNode;
isInstalled?: boolean;
isActivated?: boolean;
keySource?: ApiKeySource;
initialIsConnected?: boolean;
}

function ApiKeyConnector( {
label,
description,
connectorId,
pluginSlug,
settingName,
helpUrl,
icon,
isInstalled,
isActivated,
keySource: initialKeySource,
initialIsConnected,
}: ConnectorRenderProps & ApiKeyConnectorConfig ) {
let helpLabel: string | undefined;
try {
Expand All @@ -141,19 +140,20 @@ function ApiKeyConnector( {
setIsExpanded,
isBusy,
isConnected,
isCheckingConnection,
currentApiKey,
keySource,
handleButtonClick,
getButtonLabel,
saveApiKey,
removeApiKey,
} = useConnectorPlugin( {
connectorId,
pluginSlug,
settingName,
isInstalled,
isActivated,
keySource: initialKeySource,
initialIsConnected,
} );
const isExternallyConfigured =
keySource === 'env' || keySource === 'constant';
Expand Down Expand Up @@ -187,7 +187,11 @@ function ApiKeyConnector( {
: 'compact'
}
onClick={ handleButtonClick }
disabled={ pluginStatus === 'checking' || isBusy }
disabled={
pluginStatus === 'checking' ||
isCheckingConnection ||
isBusy
}
isBusy={ isBusy }
aria-expanded={ isExpanded }
>
Expand Down Expand Up @@ -247,6 +251,7 @@ export function registerDefaultConnectors() {
render: ( props ) => (
<ApiKeyConnector
{ ...props }
connectorId={ connectorId }
pluginSlug={ data.plugin?.slug }
settingName={ authentication.settingName }
helpUrl={ authentication.credentialsUrl ?? undefined }
Expand All @@ -258,7 +263,6 @@ export function registerDefaultConnectors() {
isInstalled={ data.plugin?.isInstalled }
isActivated={ data.plugin?.isActivated }
keySource={ authentication.keySource }
initialIsConnected={ authentication.isConnected }
/>
),
} );
Expand Down
56 changes: 50 additions & 6 deletions routes/connectors-home/use-connector-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@
*/
import { store as coreStore } from '@wordpress/core-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

import type { __experimentalApiKeySource as ApiKeySource } from '@wordpress/connectors';

export type PluginStatus = 'checking' | 'not-installed' | 'inactive' | 'active';

interface UseConnectorPluginOptions {
connectorId: string;
pluginSlug?: string;
settingName: string;
isInstalled?: boolean;
isActivated?: boolean;
keySource?: ApiKeySource;
initialIsConnected?: boolean;
}

interface UseConnectorPluginReturn {
Expand All @@ -27,6 +27,7 @@ interface UseConnectorPluginReturn {
setIsExpanded: ( expanded: boolean ) => void;
isBusy: boolean;
isConnected: boolean;
isCheckingConnection: boolean;
currentApiKey: string;
keySource: ApiKeySource;
handleButtonClick: () => void;
Expand All @@ -36,17 +37,53 @@ interface UseConnectorPluginReturn {
}

export function useConnectorPlugin( {
connectorId,
pluginSlug,
settingName,
isInstalled,
isActivated,
keySource = 'none',
initialIsConnected = false,
}: UseConnectorPluginOptions ): UseConnectorPluginReturn {
const [ isExpanded, setIsExpanded ] = useState( false );
const [ isBusy, setIsBusy ] = useState( false );
const [ connectedState, setConnectedState ] =
useState( initialIsConnected );
const [ connectedState, setConnectedState ] = useState< boolean | null >(
() => {
// Check if streaming already delivered the result before React mounted.
const streamed = (
window as unknown as {
__connectorStatuses?: Record< string, boolean >;
}
).__connectorStatuses?.[ connectorId ];
return streamed !== undefined ? streamed : null;
}
);

// Listen for streamed connector-status events from the server.
useEffect( () => {
if ( connectedState !== null ) {
return;
}
const handler = ( e: Event ) => {
const detail = ( e as CustomEvent ).detail;
if ( detail.id === connectorId ) {
setConnectedState( detail.connected );
}
};
document.addEventListener( 'connector-status', handler );

// Check again in case the event fired between initial state and effect registration.
const streamed = (
window as unknown as {
__connectorStatuses?: Record< string, boolean >;
}
).__connectorStatuses?.[ connectorId ];
if ( streamed !== undefined ) {
setConnectedState( streamed );
}

return () =>
document.removeEventListener( 'connector-status', handler );
}, [ connectorId, connectedState ] );
// Local override for immediate UI feedback after install/activate.
const [ pluginStatusOverride, setPluginStatusOverride ] =
useState< PluginStatus | null >( null );
Expand Down Expand Up @@ -144,8 +181,11 @@ export function useConnectorPlugin( {
// Use canManagePlugins (from plugin entity resolution) for activation capability.
const canActivatePlugins = canManagePlugins;

const isCheckingConnection =
pluginStatus === 'active' && connectedState === null;

const isConnected =
( pluginStatus === 'active' && connectedState ) ||
( pluginStatus === 'active' && connectedState === true ) ||
// After install/activate, if settings re-fetch reveals an existing key,
// update connected state (mirrors what the server would report on page load).
( pluginStatusOverride === 'active' && !! currentApiKey );
Expand Down Expand Up @@ -226,6 +266,9 @@ export function useConnectorPlugin( {
if ( isConnected ) {
return __( 'Edit' );
}
if ( isCheckingConnection ) {
return __( 'Checking…' );
}
switch ( pluginStatus ) {
case 'checking':
return __( 'Checking…' );
Expand Down Expand Up @@ -298,6 +341,7 @@ export function useConnectorPlugin( {
setIsExpanded,
isBusy,
isConnected,
isCheckingConnection,
currentApiKey,
keySource,
handleButtonClick,
Expand Down
Loading