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/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..6fc61a4b8a18
--- /dev/null
+++ b/projects/packages/premium-analytics/src/widget-availability.php
@@ -0,0 +1,70 @@
+ Widget_Type`).
+ */
+const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types';
+
+/**
+ * Returns the widget types available for the current request.
+ *
+ * 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[] 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 `/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() );
+}
+
+/**
+ * Hides developer-only widget 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`.
+ *
+ * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`.
+ * @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;
+ }
+
+ // 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..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 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
*/
@@ -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,
@@ -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/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php
new file mode 100644
index 000000000000..bdc2a9bc6151
--- /dev/null
+++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php
@@ -0,0 +1,64 @@
+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.' );
+ }
+}
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..b1286a25a698
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts
@@ -0,0 +1,18 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+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.
+ */
+export default {
+ name: 'jpa/react-query-dev-tool',
+ title: __( 'React Query Devtools', 'jetpack-premium-analytics' ),
+ icon: bug,
+};