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..3765e3e61689
--- /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 (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/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/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php
index 9973bf8436a3..33d91f18fd87 100644
--- a/projects/packages/premium-analytics/src/class-analytics.php
+++ b/projects/packages/premium-analytics/src/class-analytics.php
@@ -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';
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..faa7dd68fda3
--- /dev/null
+++ b/projects/packages/premium-analytics/src/widget-availability.php
@@ -0,0 +1,61 @@
+ $widget_type->name,
'render_module' => $widget_type->render_module,
@@ -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',
diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php
index e3f71ba7f599..3acc82219790 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;
@@ -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() );
+}
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..199920f44556
--- /dev/null
+++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php
@@ -0,0 +1,93 @@
+ '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() );
+
+ $names = array_column( filter_registrable_widget_types_by_environment( $this->widget_candidates() ), 'name' );
+
+ $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.' );
+ }
+
+ /**
+ * 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();
+ $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.' );
+ }
+}
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',
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..a4069b1b65ac
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@automattic/jetpack-premium-analytics-widget-react-query-dev-tool",
+ "version": "0.1.0-alpha",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@jetpack-premium-analytics/data": "link:../../packages/data",
+ "@tanstack/react-query-devtools": "5.90.2",
+ "@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
new file mode 100644
index 000000000000..35d1c5fca3cb
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { queryClient } from '@jetpack-premium-analytics/data';
+import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
+/**
+ * Internal dependencies
+ */
+import styles from './style.module.css';
+
+/**
+ * React Query Devtools as a dashboard widget.
+ *
+ * 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 {React.ReactNode} The rendered devtools panel.
+ */
+export default function ReactQueryDevTool(): React.ReactNode {
+ 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..bdc86cfe9220
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts
@@ -0,0 +1,19 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { bug } from '@wordpress/icons';
+
+/**
+ * Widget type definition.
+ *
+ * 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',
+ title: __( 'React Query Devtools', 'jetpack-premium-analytics' ),
+ icon: bug,
+};