Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Dashboard: add a React Query Devtools widget (non-production only), and expose two server-side widget-type filters (at registration and on read) so consumers can scope which widget types reach the dashboard.
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,5 @@
"@wordpress/i18n": "^6.9.0",
"@wordpress/url": "4.48.1",
"date-fns": "4.1.0"
},
"devDependencies": {
"@tanstack/react-query-devtools": "5.90.2"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* External dependencies
*/
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ReactNode, lazy, Suspense } from 'react';
import { ReactNode } from 'react';
/**
* Internal dependencies
*/
Expand All @@ -11,19 +11,6 @@ import { globalErrorManager } from './global-error-manager';
const DEFAULT_STALE_TIME = 5 * 60 * 1000;
const DEFAULT_GC_TIME = 10 * 60 * 1000;

// Upstream gates devtools behind an admin-toolkit experiment flag; that system
// isn't available here, so we show them in dev builds only. Gate the `lazy()`
// creation on NODE_ENV (not just the render) so the dynamic import sits in a
// dead branch that production builds tree-shake out — no orphaned chunk.
const ReactQueryDevtools =
process.env.NODE_ENV !== 'production'
? lazy( () =>
import( '@tanstack/react-query-devtools' ).then( d => ( {
default: d.ReactQueryDevtools,
} ) )
)
: null;

/**
* Extract HTTP status code from various error formats.
* WordPress REST API errors may have different shapes.
Expand Down Expand Up @@ -133,14 +120,5 @@ export const queryClient = new QueryClient( {
} );

export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => {
return (
<QueryClientProvider client={ queryClient }>
<>{ children }</>
{ ReactQueryDevtools && (
<Suspense fallback={ null }>
<ReactQueryDevtools initialIsOpen={ false } />
</Suspense>
) }
</QueryClientProvider>
);
return <QueryClientProvider client={ queryClient }>{ children }</QueryClientProvider>;
};
10 changes: 9 additions & 1 deletion projects/packages/premium-analytics/src/class-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,17 @@ public static function init( $options = array() ) {
Api_Proxy_Controller::register();
Notices_Controller::register();

// Hydrate the widget type registry from the build manifest at init.
// Load the widget type registry: hydration routine, registry-time and
// runtime filters, and the registry accessors.
require_once __DIR__ . '/widget-types.php';

// Apply Premium Analytics' availability policy: hooks the registry-time
// filter to keep developer-only types out of production.
require_once __DIR__ . '/widget-availability.php';

// Hydrate the registry with the availability filter in place.
bootstrap_widget_types();

// Expose dashboard widget modules over REST and wire them into the
// page import map for dynamic import() on the client.
require_once __DIR__ . '/widget-modules.php';
Expand Down
61 changes: 61 additions & 0 deletions projects/packages/premium-analytics/src/widget-availability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/**
* Widget availability policy (consumer layer).
*
* Premium Analytics' policy over the neutral filters in widget-types.php:
* availability is the consumer's job, core only offers the hooks.
*
* Ships one policy: the developer-only React Query Devtools widget is never
* registered in production. Hooking the registry-time filter (a hard hide)
* keeps every registry consumer correct without a filtered accessor.
*
* @package automattic/jetpack-premium-analytics
*/

namespace Automattic\Jetpack\PremiumAnalytics;

/**
* Removes developer-only candidates in production.
*
* Split from the hook callback so both branches are testable without touching
* the global environment.
*
* @param array $widget_candidates Manifest candidates, each with a `name`.
* @param string $environment Site environment type.
* @return array The candidates, minus developer-only types in production.
*/
function remove_dev_only_widget_types( $widget_candidates, $environment ) {
if ( 'production' !== $environment ) {
return $widget_candidates;
}

// Types that must never reach a production dashboard. Matched by name, not
// by `category: developer`: wp-build does not copy `category` into the PHP
// manifest yet, so it is not queryable here. Switch to a category check
// once the manifest carries it.
$dev_only = array( 'jpa/react-query-dev-tool' );

return array_values(
array_filter(
$widget_candidates,
static function ( $widget ) use ( $dev_only ) {
return ! in_array( $widget['name'] ?? '', $dev_only, true );
}
)
);
}

/**
* Registry-time callback: hides developer-only types in production.
*
* Defaults to `production`; a site opts in via `WP_ENVIRONMENT_TYPE`
* (`local`, `development`, `staging`).
*
* @param array $widget_candidates Manifest candidates.
* @return array The candidates, minus developer-only types in production.
*/
function filter_registrable_widget_types_by_environment( $widget_candidates ) {
return remove_dev_only_widget_types( $widget_candidates, wp_get_environment_type() );
}

add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_environment' );
21 changes: 10 additions & 11 deletions projects/packages/premium-analytics/src/widget-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@
/**
* Dashboard widget modules: REST exposure + import-map wiring.
*
* Reads the widget types from Widget_Type_Registry (hydrated from the build
* manifest in widget-types.php) and exposes them to the client through the
* `/jetpack/v4/widget-modules` REST endpoint, plus adds each widget's render
* and metadata modules to the dashboard page's import map so the client can
* dynamically `import()` them on demand. The host feeds the REST records to
* `useWidgetTypes()` in @wordpress/widget-primitives.
* Reads get_available_widget_types() (the registry filtered by
* widget-availability.php) and exposes it two ways: the
* `/jetpack/v4/widget-modules` REST list, and the page import map, where each
* widget's render and metadata modules are registered for dynamic `import()`.
*
* @package automattic/jetpack-premium-analytics
*/
Expand Down Expand Up @@ -35,14 +33,14 @@ function register_widget_modules_rest_route() {
add_action( 'rest_api_init', __NAMESPACE__ . '\\register_widget_modules_rest_route' );

/**
* Build the REST response: one record per registered widget.
* Build the REST response: one record per available widget type.
*
* @return \WP_REST_Response
*/
function get_widget_modules_response() {
$records = array();

foreach ( get_registered_widget_types() as $widget_type ) {
foreach ( get_available_widget_types() as $widget_type ) {
$records[] = array(
'name' => $widget_type->name,
'render_module' => $widget_type->render_module,
Expand All @@ -55,20 +53,21 @@ function get_widget_modules_response() {
}

/**
* Add registered widget modules to the dashboard page import map as dynamic
* dependencies, so the client can `import()` them on demand.
* Add available widget modules to the page import map as dynamic dependencies,
* so the client can `import()` them on demand.
*
* @param array $boot_dependencies Boot dependencies for the page.
* @return array Updated boot dependencies.
*/
function add_widget_modules_to_boot_deps( $boot_dependencies ) {
foreach ( get_registered_widget_types() as $widget_type ) {
foreach ( get_available_widget_types() as $widget_type ) {
if ( ! empty( $widget_type->render_module ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
'id' => $widget_type->render_module,
);
}

if ( ! empty( $widget_type->widget_module ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
Expand Down
95 changes: 74 additions & 21 deletions projects/packages/premium-analytics/src/widget-types.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
<?php
/**
* Widget Types: hydrates the registry from the build manifest.
* Widget Types: registry hydration plus the availability filter hooks.
*
* The wp-build-generated `build/widgets.php` exposes the discovered widgets via
* `jpa_get_registered_widget_modules()`. This file copies that manifest into
* the in-memory Widget_Type_Registry at `init`, giving the rest of the plugin a
* single, queryable source of widget types instead of re-parsing the manifest.
* Copies the wp-build manifest (`jpa_get_registered_widget_modules()`) into the
* in-memory Widget_Type_Registry, so the plugin queries the registry instead
* of re-parsing the manifest.
*
* This logic lives behind an experimental flag in Gutenberg; Premium Analytics
* ships its own PA-namespaced copy so it does not depend on that flag.
* This is the problem-agnostic "core" layer (a PA-namespaced copy of the
* experimental Gutenberg API): it exposes the hooks a consumer uses to scope
* widget types, but never decides availability itself.
*
* - REGISTRABLE_WIDGET_TYPES_FILTER (registry-time): drop candidates before
* they register, gone everywhere. For hard availability.
* - WIDGET_TYPES_FILTER (runtime): scope the registered set on read. For
* request-dependent or soft state (e.g. shown locked).
*
* @package automattic/jetpack-premium-analytics
*/
Expand All @@ -18,14 +23,21 @@
require_once __DIR__ . '/class-widget-type.php';
require_once __DIR__ . '/class-widget-type-registry.php';

/**
* Registry-time filter over the manifest candidates, before they are registered.
*/
const REGISTRABLE_WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_registrable_widget_types';

/**
* Runtime filter over the registered widget types map, read for the client.
*/
const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types';

/**
* Hydrates the widget type registry from the build manifest.
*
* Iterates the widgets discovered by the build pipeline (via
* `jpa_get_registered_widget_modules()`) and registers each one in the
* registry. The manifest is the single source of widget authorship in this
* codebase; this loop is a deterministic copy of it into the in-memory
* registry, with no filters in between.
* Each manifest widget is copied into the registry, gated by
* REGISTRABLE_WIDGET_TYPES_FILTER so a consumer can drop a candidate first.
*
* @return void
*/
Expand All @@ -39,6 +51,18 @@ function register_widget_types() {
// @phan-suppress-next-line PhanUndeclaredFunction -- Generated by wp-build into build/widgets.php, outside Phan's analysis scope. The function_exists() guard above protects the call at runtime.
$jetpack_widget_modules = jpa_get_registered_widget_modules();

/**
* Filters the widget type candidates before they are registered.
*
* A dropped candidate is never registered, so it is gone from the REST list,
* the import map, and any registry reader. Use for hard availability; for
* soft state that must stay visible (e.g. shown locked), filter on read via
* WIDGET_TYPES_FILTER.
*
* @param array $jetpack_widget_modules Manifest candidates, each with a `name`.
*/
$jetpack_widget_modules = apply_filters( REGISTRABLE_WIDGET_TYPES_FILTER, $jetpack_widget_modules );

foreach ( $jetpack_widget_modules as $widget ) {
if ( empty( $widget['name'] ) || $registry->is_registered( $widget['name'] ) ) {
continue;
Expand All @@ -55,20 +79,49 @@ function register_widget_types() {
}
}

if ( did_action( 'init' ) ) {
register_widget_types();
} else {
add_action( 'init', __NAMESPACE__ . '\\register_widget_types' );
/**
* Hydrates the registry now if init has run, otherwise on init.
*
* Call after the availability filters are hooked, so the registry-time
* filter applies during hydration.
*
* @return void
*/
function bootstrap_widget_types() {
if ( did_action( 'init' ) ) {
register_widget_types();
} else {
add_action( 'init', __NAMESPACE__ . '\\register_widget_types' );
}
}

/**
* Returns all widget types registered in the registry.
* Returns the raw registry. For the client-facing set use
* get_available_widget_types().
*
* Convenience accessor around `Widget_Type_Registry::get_all_registered()` for
* callers that prefer a function-based API.
*
* @return Widget_Type[] Associative array of `$name => $widget_type` pairs.
* @return Widget_Type[] Map of `$name => $widget_type`.
*/
function get_registered_widget_types() {
return Widget_Type_Registry::get_instance()->get_all_registered();
}

/**
* Returns the registered widget types scoped through WIDGET_TYPES_FILTER.
*
* Use this, not get_registered_widget_types(), wherever widget types reach the
* client, so the REST list and import map share one policy.
*
* @return Widget_Type[] Map of `$name => Widget_Type`.
*/
function get_available_widget_types() {
/**
* Filters the widget types available to the dashboard this request.
*
* Removing an entry drops it from the REST list and the import map. The type
* stays registered, so use this (not the registry-time filter) when a
* consumer must still see it, e.g. to show it locked.
*
* @param Widget_Type[] $widget_types Map of `$name => Widget_Type`.
*/
return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() );
}
Loading
Loading