From 1f164d378a5ad47025939c87ed6804941c413d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 11:09:15 +0200 Subject: [PATCH 01/10] add filterable widget availability layer gate widget types per request before REST list and import map --- .../premium-analytics/src/class-analytics.php | 4 + .../src/widget-availability.php | 77 +++++++++++++++++++ .../premium-analytics/src/widget-modules.php | 17 ++-- .../tests/php/Widget_Availability_Test.php | 69 +++++++++++++++++ 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 projects/packages/premium-analytics/src/widget-availability.php create mode 100644 projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index daccd953800c..c50101ede430 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -70,6 +70,10 @@ public static function init( $options = array() ) { // Hydrate the widget type registry from the build manifest at init. require_once __DIR__ . '/widget-types.php'; + // Layer the availability filter over the registry (environment gating + // and any host overrides) before the modules below expose the types. + require_once __DIR__ . '/widget-availability.php'; + // 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'; diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php new file mode 100644 index 000000000000..f1b5d386efd4 --- /dev/null +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -0,0 +1,77 @@ + Widget_Type` map. + */ +const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types'; + +/** + * Returns the widget types available for the current request. + * + * Starts from every registered widget type and runs the map through + * WIDGET_TYPES_FILTER, the single extension point for hiding widget types per + * request. Callers that expose widget types to the client should use this + * instead of get_registered_widget_types() so the same policy is applied to the + * REST list and the import map alike. + * + * @return Widget_Type[] Associative array of `$name => $widget_type` pairs. + */ +function get_available_widget_types() { + /** + * Filters the widget types available to the dashboard for this request. + * + * Removing an entry hides that widget type from the client entirely: it + * drops out of the `/jetpack/v4/widget-modules` REST list and out of the + * page import map, so it cannot be rendered or added to a dashboard. + * + * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. + */ + return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() ); +} + +/** + * Hides developer-only widget types when running in production. + * + * Keyed off the WordPress core environment type, so a site opts in to these + * widgets by declaring a non-production environment (e.g. `WP_ENVIRONMENT_TYPE` + * set to `local`, `development`, or `staging`). + * + * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. + * @return Widget_Type[] The map without production-restricted widget types. + */ +function filter_widget_types_by_environment( $widget_types ) { + if ( 'production' !== wp_get_environment_type() ) { + return $widget_types; + } + + // Widget types that must never reach a production dashboard. + $non_production_only = array( 'jpa/react-query-dev-tool' ); + + foreach ( $non_production_only as $widget_type_name ) { + unset( $widget_types[ $widget_type_name ] ); + } + + return $widget_types; +} + +add_filter( WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_widget_types_by_environment' ); diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index 96046e01a8c2..d5e4927b9449 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -2,12 +2,12 @@ /** * 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 the available widget types (the registry from widget-types.php, run + * through the availability filter in widget-availability.php) and exposes them + * to the client through the `/jetpack/v4/widget-modules` REST endpoint. + * + * Plus, it adds each widget's render and metadata modules to the dashboard page's + * import map so the client can dynamically `import()` them on demand. * * @package automattic/jetpack-premium-analytics */ @@ -42,7 +42,7 @@ function register_widget_modules_rest_route() { 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, @@ -62,13 +62,14 @@ function get_widget_modules_response() { * @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', diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php new file mode 100644 index 000000000000..72e6d1bc55c9 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -0,0 +1,69 @@ +assertSame( 'production', wp_get_environment_type() ); + + $widget_types = array( + 'jpa/react-query-dev-tool' => new Widget_Type( 'jpa/react-query-dev-tool' ), + 'jpa/hello-world' => new Widget_Type( 'jpa/hello-world' ), + ); + + $filtered = filter_widget_types_by_environment( $widget_types ); + + $this->assertArrayNotHasKey( 'jpa/react-query-dev-tool', $filtered, 'Developer-only widgets must be hidden in production.' ); + $this->assertArrayHasKey( 'jpa/hello-world', $filtered, 'Regular widgets remain available.' ); + } + + /** + * Verifies get_available_widget_types() runs the registry through the + * WIDGET_TYPES_FILTER so any callback can scope the visible set. + */ + public function test_get_available_widget_types_applies_filter() { + $registry = Widget_Type_Registry::get_instance(); + $registry->register( 'test/sentinel' ); + + $callback = static function ( $widget_types ) { + unset( $widget_types['test/sentinel'] ); + return $widget_types; + }; + add_filter( WIDGET_TYPES_FILTER, $callback ); + + $available = get_available_widget_types(); + + remove_filter( WIDGET_TYPES_FILTER, $callback ); + $registry->unregister( 'test/sentinel' ); + + $this->assertArrayNotHasKey( 'test/sentinel', $available, 'A filter callback can remove a widget type from the available set.' ); + } +} From 01df29634308a9501f234f83ab5d6fd4c54c3bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 11:09:35 +0200 Subject: [PATCH 02/10] add React Query Devtools dev-only widget replace inline provider devtools with a non-production widget --- .../changelog/add-react-query-devtools-widget | 4 +++ .../packages/data/package.json | 3 -- .../src/providers/query-client-provider.tsx | 26 ++------------- .../widgets/react-query-dev-tool/package.json | 11 +++++++ .../widgets/react-query-dev-tool/render.tsx | 32 +++++++++++++++++++ .../react-query-dev-tool/style.module.css | 5 +++ .../widgets/react-query-dev-tool/widget.json | 7 ++++ .../widgets/react-query-dev-tool/widget.ts | 19 +++++++++++ 8 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-react-query-devtools-widget create mode 100644 projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json create mode 100644 projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx create mode 100644 projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css create mode 100644 projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json create mode 100644 projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts diff --git a/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget new file mode 100644 index 000000000000..78f1b1ac4484 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Dashboard: add a React Query Devtools widget, gated to non-production environments through a new `jetpack_premium_analytics_widget_types` filter for scoping widget types server-side. diff --git a/projects/packages/premium-analytics/packages/data/package.json b/projects/packages/premium-analytics/packages/data/package.json index 46765a1e3e32..79b6e32b2af5 100644 --- a/projects/packages/premium-analytics/packages/data/package.json +++ b/projects/packages/premium-analytics/packages/data/package.json @@ -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" } } diff --git a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx index dfb504ca2e26..4121d4466454 100644 --- a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx +++ b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx @@ -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 */ @@ -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. @@ -133,14 +120,5 @@ export const queryClient = new QueryClient( { } ); export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => { - return ( - - <>{ children } - { ReactQueryDevtools && ( - - - - ) } - - ); + return { children }; }; diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json new file mode 100644 index 000000000000..de909fddd34d --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json @@ -0,0 +1,11 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-react-query-dev-tool", + "version": "0.1.0-alpha", + "private": true, + "type": "module", + "dependencies": { + "@jetpack-premium-analytics/data": "workspace:*", + "@tanstack/react-query-devtools": "5.90.2", + "@wordpress/icons": "^13.0.0" + } +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx new file mode 100644 index 000000000000..bb882402993a --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { queryClient } from '@jetpack-premium-analytics/data'; +import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +/** + * Renders the React Query Devtools as a dashboard widget. + * + * The panel is bound to the dashboard's shared `queryClient` singleton through + * the explicit `client` prop rather than React context: this widget bundles its + * own copy of `@tanstack/react-query`, while `queryClient` comes from the shared + * data module. Passing the instance directly keeps the panel inspecting the very + * cache every other widget reads from, sidestepping the duplicate-context issue. + * + * Visibility is gated server-side — the `jpa/react-query-dev-tool` type is + * removed from the widget list in production (see `src/widget-availability.php`), + * so this render module is never requested there. + * + * @return The rendered devtools panel. + */ +export default function ReactQueryDevTool() { + return ( +
+ +
+ ); +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css b/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css new file mode 100644 index 000000000000..5bd9e47257a5 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css @@ -0,0 +1,5 @@ +.root { + height: 100%; + min-height: 480px; + overflow: auto; +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json new file mode 100644 index 000000000000..b5699f2d5d30 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json @@ -0,0 +1,7 @@ +{ + "name": "jpa/react-query-dev-tool", + "title": "React Query Devtools", + "description": "Inspect the dashboard's React Query cache. Available outside production only.", + "category": "developer", + "presentation": "full-bleed" +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts new file mode 100644 index 000000000000..c7fdea1d645b --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { bug } from '@wordpress/icons'; + +/** + * Widget type definition. + * + * Developer tool, exposed only outside production. Availability is enforced + * server-side by the `jetpack_premium_analytics_widget_types` filter (see + * `src/widget-availability.php`); this metadata only describes the type for the + * dashboard's widget picker. + */ +export default { + name: 'jpa/react-query-dev-tool', + title: 'React Query Devtools', + icon: bug, + presentation: 'full-bleed', +}; From f8db9f18b74c292045ac581f36e31fb2acb575a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 12:50:41 +0200 Subject: [PATCH 03/10] fix Phan errors in widget availability test cover functions with ::func / CoversFunction, not CoversClass --- .../tests/php/Widget_Availability_Test.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index 72e6d1bc55c9..bdc2a9bc6151 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -7,7 +7,6 @@ namespace Automattic\Jetpack\PremiumAnalytics; -use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversFunction; use WorDBless\BaseTestCase; @@ -15,15 +14,11 @@ require_once __DIR__ . '/../../src/widget-availability.php'; /** - * @covers \Automattic\Jetpack\PremiumAnalytics\get_available_widget_types - * @covers \Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment - * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment * @covers ::Automattic\Jetpack\PremiumAnalytics\get_available_widget_types + * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment */ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\get_available_widget_types' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment' )] -#[CoversClass( filter_widget_types_by_environment::class )] -#[CoversClass( get_available_widget_types::class )] class Widget_Availability_Test extends BaseTestCase { /** From 8c19a7504bbad3d557ac64d9b2399dab86b1de2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 12:50:52 +0200 Subject: [PATCH 04/10] tighten widget + availability docblocks shorten prose; document the wp_get_environment_type production default --- .../src/widget-availability.php | 51 ++++++++----------- .../premium-analytics/src/widget-modules.php | 16 +++--- .../widgets/react-query-dev-tool/render.tsx | 15 +++--- .../widgets/react-query-dev-tool/widget.ts | 7 ++- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index f1b5d386efd4..6fc61a4b8a18 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -1,18 +1,15 @@ Widget_Type` map. + * Filter over the available widget types map (`$name => Widget_Type`). */ const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types'; /** * Returns the widget types available for the current request. * - * Starts from every registered widget type and runs the map through - * WIDGET_TYPES_FILTER, the single extension point for hiding widget types per - * request. Callers that expose widget types to the client should use this - * instead of get_registered_widget_types() so the same policy is applied to the - * REST list and the import map alike. + * Every registered widget type, run through WIDGET_TYPES_FILTER. Use this, not + * get_registered_widget_types(), anywhere widget types reach the client, so the + * same policy covers the REST list and the import map. * - * @return Widget_Type[] Associative array of `$name => $widget_type` pairs. + * @return Widget_Type[] Map of `$name => Widget_Type`. */ function get_available_widget_types() { /** - * Filters the widget types available to the dashboard for this request. + * Filters the widget types available to the dashboard this request. * - * Removing an entry hides that widget type from the client entirely: it - * drops out of the `/jetpack/v4/widget-modules` REST list and out of the - * page import map, so it cannot be rendered or added to a dashboard. + * Removing an entry drops it from the `/jetpack/v4/widget-modules` REST list + * and the page import map, so it cannot be rendered or added. * * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. */ @@ -50,21 +43,21 @@ function get_available_widget_types() { } /** - * Hides developer-only widget types when running in production. + * Hides developer-only widget types in production. * - * Keyed off the WordPress core environment type, so a site opts in to these - * widgets by declaring a non-production environment (e.g. `WP_ENVIRONMENT_TYPE` - * set to `local`, `development`, or `staging`). + * Keyed off wp_get_environment_type(), which defaults to `production`: a site + * opts in by declaring `WP_ENVIRONMENT_TYPE` as `local`, `development`, or + * `staging`. * * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. - * @return Widget_Type[] The map without production-restricted widget types. + * @return Widget_Type[] The map minus developer-only types in production. */ function filter_widget_types_by_environment( $widget_types ) { if ( 'production' !== wp_get_environment_type() ) { return $widget_types; } - // Widget types that must never reach a production dashboard. + // Types that must never reach a production dashboard. $non_production_only = array( 'jpa/react-query-dev-tool' ); foreach ( $non_production_only as $widget_type_name ) { diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index d5e4927b9449..8c591f7e0d43 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -2,12 +2,10 @@ /** * Dashboard widget modules: REST exposure + import-map wiring. * - * Reads the available widget types (the registry from widget-types.php, run - * through the availability filter in widget-availability.php) and exposes them - * to the client through the `/jetpack/v4/widget-modules` REST endpoint. - * - * Plus, it adds each widget's render and metadata modules to the dashboard page's - * import map so the client can dynamically `import()` them on demand. + * 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 */ @@ -35,7 +33,7 @@ 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 */ @@ -55,8 +53,8 @@ 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. diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx index bb882402993a..0ccc9ff9ecc0 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx @@ -9,17 +9,14 @@ import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; import styles from './style.module.css'; /** - * Renders the React Query Devtools as a dashboard widget. + * React Query Devtools as a dashboard widget. * - * The panel is bound to the dashboard's shared `queryClient` singleton through - * the explicit `client` prop rather than React context: this widget bundles its - * own copy of `@tanstack/react-query`, while `queryClient` comes from the shared - * data module. Passing the instance directly keeps the panel inspecting the very - * cache every other widget reads from, sidestepping the duplicate-context issue. + * Bound to the shared `queryClient` via the explicit `client` prop, not context: + * the widget bundles its own `@tanstack/react-query`, so passing the instance + * directly inspects the real cache and sidesteps the duplicate-context problem. * - * Visibility is gated server-side — the `jpa/react-query-dev-tool` type is - * removed from the widget list in production (see `src/widget-availability.php`), - * so this render module is never requested there. + * Server-gated: widget-availability.php drops `jpa/react-query-dev-tool` in + * production, so this module is never requested there. * * @return The rendered devtools panel. */ diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts index c7fdea1d645b..9474d5142c6e 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -6,10 +6,9 @@ import { bug } from '@wordpress/icons'; /** * Widget type definition. * - * Developer tool, exposed only outside production. Availability is enforced - * server-side by the `jetpack_premium_analytics_widget_types` filter (see - * `src/widget-availability.php`); this metadata only describes the type for the - * dashboard's widget picker. + * Developer tool, gated to non-production server-side by the + * `jetpack_premium_analytics_widget_types` filter (widget-availability.php). + * This metadata only describes the type for the dashboard's widget picker. */ export default { name: 'jpa/react-query-dev-tool', From 3b11b65d973cc0c1482bf1b3e8c68d00f2183d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 17:16:38 +0200 Subject: [PATCH 05/10] fix react-query-devtools widget i18n and deps wrap title in __(), link: data dep, declare react --- .../widgets/react-query-dev-tool/package.json | 6 ++++-- .../widgets/react-query-dev-tool/render.tsx | 12 +++++++----- .../widgets/react-query-dev-tool/widget.ts | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json index de909fddd34d..a4069b1b65ac 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json @@ -4,8 +4,10 @@ "private": true, "type": "module", "dependencies": { - "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/data": "link:../../packages/data", "@tanstack/react-query-devtools": "5.90.2", - "@wordpress/icons": "^13.0.0" + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "react": "18.3.1" } } diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx index 0ccc9ff9ecc0..35d1c5fca3cb 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx @@ -11,16 +11,18 @@ import styles from './style.module.css'; /** * React Query Devtools as a dashboard widget. * - * Bound to the shared `queryClient` via the explicit `client` prop, not context: - * the widget bundles its own `@tanstack/react-query`, so passing the instance - * directly inspects the real cache and sidesteps the duplicate-context problem. + * Bound to the shared `queryClient` via the explicit `client` prop rather than + * the React Query context. The panel renders inside this widget's own lazily + * loaded module, so a context lookup is not guaranteed to resolve to the + * dashboard's provider; passing the singleton directly always targets the real + * cache. * * Server-gated: widget-availability.php drops `jpa/react-query-dev-tool` in * production, so this module is never requested there. * - * @return The rendered devtools panel. + * @return {React.ReactNode} The rendered devtools panel. */ -export default function ReactQueryDevTool() { +export default function ReactQueryDevTool(): React.ReactNode { return (
diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts index 9474d5142c6e..b1286a25a698 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { __ } from '@wordpress/i18n'; import { bug } from '@wordpress/icons'; /** @@ -12,7 +13,6 @@ import { bug } from '@wordpress/icons'; */ export default { name: 'jpa/react-query-dev-tool', - title: 'React Query Devtools', + title: __( 'React Query Devtools', 'jetpack-premium-analytics' ), icon: bug, - presentation: 'full-bleed', }; From b5b9d6847b96d25f404c96938ee6d8503aafe8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 26 Jun 2026 17:17:16 +0200 Subject: [PATCH 06/10] drop redundant presentation in locations widget widget.json is the canonical source read by the build --- projects/packages/premium-analytics/widgets/locations/widget.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/packages/premium-analytics/widgets/locations/widget.ts b/projects/packages/premium-analytics/widgets/locations/widget.ts index afa65d07ba8a..49100a79c20c 100644 --- a/projects/packages/premium-analytics/widgets/locations/widget.ts +++ b/projects/packages/premium-analytics/widgets/locations/widget.ts @@ -23,7 +23,6 @@ export default { name: 'jpa/locations', title: __( 'Locations', 'jetpack-premium-analytics' ), icon: mapMarker, - presentation: 'full-bleed', attributes: [ { id: 'max', From d467d3bc24b41e77c5358d5942ef39bae0f0ac7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 29 Jun 2026 15:46:46 +0100 Subject: [PATCH 07/10] add registry-time widget filter for dev gating Expose registry + runtime widget-type filters in core; gate the dev widget at registration, not on read. --- .../changelog/add-react-query-devtools-widget | 2 +- .../premium-analytics/src/class-analytics.php | 5 +- .../src/widget-availability.php | 84 ++++++++----------- .../premium-analytics/src/widget-types.php | 77 +++++++++++++---- .../tests/php/Widget_Availability_Test.php | 61 ++++++++++---- 5 files changed, 145 insertions(+), 84 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget index 78f1b1ac4484..3765e3e61689 100644 --- a/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget +++ b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget @@ -1,4 +1,4 @@ Significance: minor Type: added -Dashboard: add a React Query Devtools widget, gated to non-production environments through a new `jetpack_premium_analytics_widget_types` filter for scoping widget types server-side. +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. diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 6497d4e30f1f..b08f5139a0c8 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -70,8 +70,9 @@ public static function init( $options = array() ) { // Hydrate the widget type registry from the build manifest at init. require_once __DIR__ . '/widget-types.php'; - // Layer the availability filter over the registry (environment gating - // and any host overrides) before the modules below expose the types. + // Apply Premium Analytics' availability policy: it hooks the registry-time + // filter from widget-types.php to keep developer-only types out of + // production before the modules below expose the registry. require_once __DIR__ . '/widget-availability.php'; // Expose dashboard widget modules over REST and wire them into the diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index 6fc61a4b8a18..a0fd0b90950d 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -1,15 +1,13 @@ Widget_Type`). - */ -const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types'; - -/** - * Returns the widget types available for the current request. + * Removes developer-only candidates in production. * - * Every registered widget type, run through WIDGET_TYPES_FILTER. Use this, not - * get_registered_widget_types(), anywhere widget types reach the client, so the - * same policy covers the REST list and the import map. + * Split from the hook callback so both branches are testable without touching + * the global environment. * - * @return Widget_Type[] Map of `$name => Widget_Type`. + * @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 get_available_widget_types() { - /** - * Filters the widget types available to the dashboard this request. - * - * Removing an entry drops it from the `/jetpack/v4/widget-modules` REST list - * and the page import map, so it cannot be rendered or added. - * - * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. - */ - return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() ); +function remove_dev_only_widget_types( $widget_candidates, $environment ) { + if ( 'production' !== $environment ) { + return $widget_candidates; + } + + // Types that must never reach a production dashboard. + $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 ); + } + ) + ); } /** - * Hides developer-only widget types in production. + * Registry-time callback: hides developer-only types in production. * - * Keyed off wp_get_environment_type(), which defaults to `production`: a site - * opts in by declaring `WP_ENVIRONMENT_TYPE` as `local`, `development`, or - * `staging`. + * Defaults to `production`; a site opts in via `WP_ENVIRONMENT_TYPE` + * (`local`, `development`, `staging`). * - * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. - * @return Widget_Type[] The map minus developer-only types in production. + * @param array $widget_candidates Manifest candidates. + * @return array The candidates, minus developer-only types in production. */ -function filter_widget_types_by_environment( $widget_types ) { - if ( 'production' !== wp_get_environment_type() ) { - return $widget_types; - } - - // Types that must never reach a production dashboard. - $non_production_only = array( 'jpa/react-query-dev-tool' ); - - foreach ( $non_production_only as $widget_type_name ) { - unset( $widget_types[ $widget_type_name ] ); - } - - return $widget_types; +function filter_registrable_widget_types_by_environment( $widget_candidates ) { + return remove_dev_only_widget_types( $widget_candidates, wp_get_environment_type() ); } -add_filter( WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_widget_types_by_environment' ); +add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_environment' ); diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php index e3f71ba7f599..dd0add2e7a61 100644 --- a/projects/packages/premium-analytics/src/widget-types.php +++ b/projects/packages/premium-analytics/src/widget-types.php @@ -1,14 +1,19 @@ is_registered( $widget['name'] ) ) { continue; @@ -62,13 +86,32 @@ function 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() ); +} diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index bdc2a9bc6151..199920f44556 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -15,34 +15,63 @@ /** * @covers ::Automattic\Jetpack\PremiumAnalytics\get_available_widget_types - * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment + * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_environment + * @covers ::Automattic\Jetpack\PremiumAnalytics\remove_dev_only_widget_types */ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\get_available_widget_types' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_environment' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\remove_dev_only_widget_types' )] class Widget_Availability_Test extends BaseTestCase { /** - * In production, developer-only widget types are dropped while the rest - * pass through untouched. + * Candidate set shaped like the build manifest entries. + * + * @return array[] List of widget candidates. */ - public function test_filter_removes_dev_only_widget_in_production() { - // WorDBless reports the environment type as 'production' by default. - $this->assertSame( 'production', wp_get_environment_type() ); - - $widget_types = array( - 'jpa/react-query-dev-tool' => new Widget_Type( 'jpa/react-query-dev-tool' ), - 'jpa/hello-world' => new Widget_Type( 'jpa/hello-world' ), + private function widget_candidates() { + return array( + array( 'name' => 'jpa/react-query-dev-tool' ), + array( 'name' => 'jpa/hello-world' ), ); + } + + /** + * In production, developer-only candidates are dropped; the rest pass through. + */ + public function test_dev_only_widget_removed_in_production() { + $names = array_column( remove_dev_only_widget_types( $this->widget_candidates(), 'production' ), 'name' ); + + $this->assertNotContains( 'jpa/react-query-dev-tool', $names, 'Developer-only widgets must be hidden in production.' ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); + } + + /** + * Outside production, candidates pass through (covers the non-production branch). + */ + public function test_dev_only_widget_kept_outside_production() { + foreach ( array( 'local', 'development', 'staging' ) as $environment ) { + $names = array_column( remove_dev_only_widget_types( $this->widget_candidates(), $environment ), 'name' ); + + $this->assertContains( 'jpa/react-query-dev-tool', $names, "Developer-only widgets must remain available in the {$environment} environment." ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); + } + } + + /** + * The registry-time callback reads the env (production by default) and drops + * the developer-only candidate. + */ + public function test_registry_filter_callback_drops_dev_widget_by_default() { + $this->assertSame( 'production', wp_get_environment_type() ); - $filtered = filter_widget_types_by_environment( $widget_types ); + $names = array_column( filter_registrable_widget_types_by_environment( $this->widget_candidates() ), 'name' ); - $this->assertArrayNotHasKey( 'jpa/react-query-dev-tool', $filtered, 'Developer-only widgets must be hidden in production.' ); - $this->assertArrayHasKey( 'jpa/hello-world', $filtered, 'Regular widgets remain available.' ); + $this->assertNotContains( 'jpa/react-query-dev-tool', $names, 'The registry-time callback must drop the developer widget in production.' ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); } /** - * Verifies get_available_widget_types() runs the registry through the - * WIDGET_TYPES_FILTER so any callback can scope the visible set. + * Reading the available set runs the registry through WIDGET_TYPES_FILTER. */ public function test_get_available_widget_types_applies_filter() { $registry = Widget_Type_Registry::get_instance(); From 895e62edafcaa5cfd079f2a5fd6e752a24214961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 09:47:26 +0100 Subject: [PATCH 08/10] fix filter reference in dev widget docblock name the registry-time filter, not the runtime one --- .../widgets/react-query-dev-tool/widget.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts index b1286a25a698..bdc86cfe9220 100644 --- a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -7,9 +7,10 @@ import { bug } from '@wordpress/icons'; /** * Widget type definition. * - * Developer tool, gated to non-production server-side by the - * `jetpack_premium_analytics_widget_types` filter (widget-availability.php). - * This metadata only describes the type for the dashboard's widget picker. + * Developer tool, dropped from production at registration time by the + * `jetpack_premium_analytics_registrable_widget_types` filter + * (widget-availability.php). This metadata only describes the type for the + * dashboard's widget picker. */ export default { name: 'jpa/react-query-dev-tool', From f35abcbd8a4e73183698287cc1469363fcc09a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 09:48:30 +0100 Subject: [PATCH 09/10] explain name-based dev widget gating wp-build does not expose widget category to PHP yet --- .../packages/premium-analytics/src/widget-availability.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index a0fd0b90950d..faa7dd68fda3 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -29,7 +29,10 @@ function remove_dev_only_widget_types( $widget_candidates, $environment ) { return $widget_candidates; } - // Types that must never reach a production dashboard. + // 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( From defc2d292a12716a84cf6ccca0bf2f8454072232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 09:52:11 +0100 Subject: [PATCH 10/10] fix widget registry hydration order hydrate via bootstrap_widget_types() once the filter is hooked --- .../premium-analytics/src/class-analytics.php | 11 ++++++---- .../premium-analytics/src/widget-types.php | 22 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index b08f5139a0c8..33d91f18fd87 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -67,14 +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: it hooks the registry-time - // filter from widget-types.php to keep developer-only types out of - // production before the modules below expose the registry. + // 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'; diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php index dd0add2e7a61..3acc82219790 100644 --- a/projects/packages/premium-analytics/src/widget-types.php +++ b/projects/packages/premium-analytics/src/widget-types.php @@ -3,8 +3,8 @@ * Widget Types: registry hydration plus the availability filter hooks. * * Copies the wp-build manifest (`jpa_get_registered_widget_modules()`) into the - * in-memory Widget_Type_Registry at `init`, so the plugin queries the registry - * instead of re-parsing the manifest. + * in-memory Widget_Type_Registry, so the plugin queries the registry instead + * of re-parsing the manifest. * * 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 @@ -79,10 +79,20 @@ 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' ); + } } /**