From 9bfbd7c7ffb241f6a1e7e96ccb746df3679cb919 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Sat, 4 Oct 2025 15:31:08 +0300 Subject: [PATCH 1/6] Forms: Preload initial inbox data and essential endpoints Preloads first page of responses, counts, filters, and entity types for instant rendering. Related: https://github.com/Automattic/jetpack/pull/45339 --- .../forms/changelog/optimize-inbox-preloading | 4 ++ .../forms/src/dashboard/class-dashboard.php | 45 ++++++++++++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 projects/packages/forms/changelog/optimize-inbox-preloading diff --git a/projects/packages/forms/changelog/optimize-inbox-preloading b/projects/packages/forms/changelog/optimize-inbox-preloading new file mode 100644 index 0000000000000..9074c88a20f40 --- /dev/null +++ b/projects/packages/forms/changelog/optimize-inbox-preloading @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Forms: preload initial inbox data and essential endpoints for faster page load. diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 2484d40de1101..f866dad29a719 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -71,7 +71,7 @@ public function load_admin_scripts() { 'in_footer' => true, 'textdomain' => 'jetpack-forms', 'enqueue' => true, - 'dependencies' => array( 'wp-api-fetch' ), + 'dependencies' => array( 'wp-api-fetch', 'wp-data', 'wp-core-data', 'wp-dom-ready' ), ) ); @@ -83,16 +83,49 @@ public function load_admin_scripts() { Connection_Initial_State::render_script( self::SCRIPT_HANDLE ); // Preload Forms endpoints needed in dashboard context. - $preload_paths = array( + // Pre-fetch the first inbox page so the UI renders instantly on first load. + $preload_params = array( + '_fields' => 'id,status,date,date_gmt,author_name,author_email,author_url,author_avatar,ip,entry_title,entry_permalink,has_file,fields', + 'context' => 'view', + 'order' => 'desc', + 'orderby' => 'date', + 'page' => 1, + 'per_page' => 20, + 'status' => 'draft,publish', + ); + \ksort( $preload_params ); + $initial_responses_path = \add_query_arg( $preload_params, '/wp/v2/feedback' ); + $initial_responses_locale_path = \add_query_arg( + \array_merge( + $preload_params, + array( '_locale' => 'user' ) + ), + '/wp/v2/feedback' + ); + $preload_paths = array( + '/wp/v2/types?context=view', '/wp/v2/feedback/config', - '/wp/v2/feedback/config?_locale=user', '/wp/v2/feedback/integrations?version=2', - '/wp/v2/feedback/integrations?version=2&_locale=user', + '/wp/v2/feedback/counts', + '/wp/v2/feedback/filters', + $initial_responses_path, + $initial_responses_locale_path, ); - $preload_data = array_reduce( $preload_paths, 'rest_preload_api_request', array() ); + $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() ); + + // Normalize keys to match what apiFetch will request (without domain). + $preload_data = array(); + foreach ( $preload_data_raw as $key => $value ) { + $normalized_key = preg_replace( '#^https?://[^/]+/wp-json#', '', $key ); + $preload_data[ $normalized_key ] = $value; + } + wp_add_inline_script( self::SCRIPT_HANDLE, - 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( ' . wp_json_encode( $preload_data ) . ' ) );', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), 'before' ); } From 1dab9a2676d156e80c9cc4dfd87b3815a5f0681d Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Thu, 9 Oct 2025 18:40:14 -0300 Subject: [PATCH 2/6] Set default values for currentQuery state --- .../packages/forms/src/dashboard/store/reducer.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/store/reducer.js b/projects/packages/forms/src/dashboard/store/reducer.js index 51b226ab980ee..ff1c1e97f6362 100644 --- a/projects/packages/forms/src/dashboard/store/reducer.js +++ b/projects/packages/forms/src/dashboard/store/reducer.js @@ -20,7 +20,17 @@ const filters = ( state = {}, action ) => { return state; }; -const currentQuery = ( state = {}, action ) => { +const currentQuery = ( + state = { + context: 'view', + order: 'desc', + orderby: 'date', + page: 1, + per_page: 20, + status: 'draft,publish', + }, + action +) => { if ( action.type === SET_CURRENT_QUERY ) { return action.currentQuery; } From 2852debd54d77d2b517cbdea6e891012b23e2278 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Thu, 9 Oct 2025 18:40:26 -0300 Subject: [PATCH 3/6] Remove unused _fields parameter from preload params --- projects/packages/forms/src/dashboard/class-dashboard.php | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index f866dad29a719..79c16764f2c82 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -85,7 +85,6 @@ public function load_admin_scripts() { // Preload Forms endpoints needed in dashboard context. // Pre-fetch the first inbox page so the UI renders instantly on first load. $preload_params = array( - '_fields' => 'id,status,date,date_gmt,author_name,author_email,author_url,author_avatar,ip,entry_title,entry_permalink,has_file,fields', 'context' => 'view', 'order' => 'desc', 'orderby' => 'date', From 0cf2f16087a3312e634acd1d139cb9cd335a524a Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 9 Oct 2025 21:08:48 -0300 Subject: [PATCH 4/6] use a resolver to prevent multiple requests to counts endpoint --- .../components/empty-spam-button/index.tsx | 2 +- .../components/empty-trash-button/index.tsx | 2 +- .../src/dashboard/hooks/use-inbox-data.ts | 66 ++++++++----------- .../forms/src/dashboard/store/resolvers.js | 28 ++++++++ .../forms/src/dashboard/store/selectors.js | 13 +++- 5 files changed, 69 insertions(+), 42 deletions(-) diff --git a/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx b/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx index 3952109cbcd73..33e5116c77b55 100644 --- a/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx +++ b/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx @@ -46,7 +46,7 @@ const EmptySpamButton = ( { // Use props if provided, otherwise use hook const hookData = useInboxData(); const totalItemsSpam = totalItemsSpamProp ?? hookData.totalItemsSpam; - const isLoadingCounts = isLoadingCountsProp ?? hookData.isLoadingCounts; + const isLoadingCounts = isLoadingCountsProp ?? false; const { selectedResponsesCount, currentQuery } = hookData; useEffect( () => { diff --git a/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx b/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx index 317e7b824fb72..f21902d765ad9 100644 --- a/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx +++ b/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx @@ -46,7 +46,7 @@ const EmptyTrashButton = ( { // Use props if provided, otherwise use hook const hookData = useInboxData(); const totalItemsTrash = totalItemsTrashProp ?? hookData.totalItemsTrash; - const isLoadingCounts = isLoadingCountsProp ?? hookData.isLoadingCounts; + const isLoadingCounts = isLoadingCountsProp ?? false; const { selectedResponsesCount, currentQuery } = hookData; useEffect( () => { diff --git a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts index a53c8f8ba756e..4e821d57204bc 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts @@ -1,11 +1,9 @@ /** * External dependencies */ -import apiFetch from '@wordpress/api-fetch'; import { useEntityRecords, store as coreDataStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useState } from '@wordpress/element'; -import { addQueryArgs } from '@wordpress/url'; +import { useMemo } from '@wordpress/element'; import { useSearchParams } from 'react-router'; /** * Internal dependencies @@ -39,7 +37,6 @@ interface UseInboxDataReturn { totalItemsTrash: number; records: FormResponse[]; isLoadingData: boolean; - isLoadingCounts: boolean; totalItems: number; totalPages: number; selectedResponsesCount: number; @@ -58,7 +55,7 @@ interface UseInboxDataReturn { */ export default function useInboxData(): UseInboxDataReturn { const [ searchParams ] = useSearchParams(); - const { setCurrentQuery, setSelectedResponses, setCounts } = useDispatch( dashboardStore ); + const { setCurrentQuery, setSelectedResponses } = useDispatch( dashboardStore ); const urlStatus = searchParams.get( 'status' ); const statusFilter = getStatusFilter( urlStatus ); @@ -107,40 +104,32 @@ export default function useInboxData(): UseInboxDataReturn { [ rawRecords ] ); - const [ isLoadingCounts, setIsLoadingCounts ] = useState( false ); + // Prepare query params for counts resolver + const countsQueryParams = useMemo( () => { + const params: Record< string, unknown > = {}; + if ( currentQuery?.search ) { + params.search = currentQuery.search; + } + if ( currentQuery?.parent ) { + params.parent = currentQuery.parent; + } + if ( currentQuery?.before ) { + params.before = currentQuery.before; + } + if ( currentQuery?.after ) { + params.after = currentQuery.after; + } + return params; + }, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] ); - useEffect( () => { - const fetchCounts = async () => { - setIsLoadingCounts( true ); - const params: Record< string, unknown > = {}; - if ( currentQuery?.search ) { - params.search = currentQuery.search; - } - if ( currentQuery?.parent ) { - params.parent = currentQuery.parent; - } - if ( currentQuery?.before ) { - params.before = currentQuery.before; - } - if ( currentQuery?.after ) { - params.after = currentQuery.after; - } - const path = addQueryArgs( '/wp/v2/feedback/counts', params ); - const response = await apiFetch< { inbox: number; spam: number; trash: number } >( { - path, - } ); - setCounts( response ); - setIsLoadingCounts( false ); - }; - - fetchCounts(); - }, [ - currentQuery?.search, - currentQuery?.parent, - currentQuery?.before, - currentQuery?.after, - setCounts, - ] ); + // Use the getCounts selector with resolver - this will automatically fetch and cache counts + // The resolver ensures counts are only fetched once for the same query params across all hook instances + useSelect( + select => { + select( dashboardStore ).getCounts( countsQueryParams ); + }, + [ countsQueryParams ] + ); return { totalItemsInbox, @@ -148,7 +137,6 @@ export default function useInboxData(): UseInboxDataReturn { totalItemsTrash, records, isLoadingData: isLoadingRecordsData, - isLoadingCounts, totalItems, totalPages, selectedResponsesCount, diff --git a/projects/packages/forms/src/dashboard/store/resolvers.js b/projects/packages/forms/src/dashboard/store/resolvers.js index 0ee99b638f2a1..aaf67bb85d341 100644 --- a/projects/packages/forms/src/dashboard/store/resolvers.js +++ b/projects/packages/forms/src/dashboard/store/resolvers.js @@ -1,4 +1,5 @@ import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; import { INVALIDATE_FILTERS } from './action-types'; export const getFilters = @@ -11,3 +12,30 @@ export const getFilters = }; getFilters.shouldInvalidate = action => action.type === INVALIDATE_FILTERS; + +/** + * Resolver for fetching counts based on current query parameters. + * + * @param {object} queryParams - Query parameters for filtering counts. + * @return {void} + */ +export const getCounts = + ( queryParams = {} ) => + async ( { dispatch } ) => { + const params = {}; + if ( queryParams?.search ) { + params.search = queryParams.search; + } + if ( queryParams?.parent ) { + params.parent = queryParams.parent; + } + if ( queryParams?.before ) { + params.before = queryParams.before; + } + if ( queryParams?.after ) { + params.after = queryParams.after; + } + const path = addQueryArgs( '/wp/v2/feedback/counts', params ); + const response = await apiFetch( { path } ); + dispatch.setCounts( response ); + }; diff --git a/projects/packages/forms/src/dashboard/store/selectors.js b/projects/packages/forms/src/dashboard/store/selectors.js index ffb740419d1b2..ae514c2b8c4ce 100644 --- a/projects/packages/forms/src/dashboard/store/selectors.js +++ b/projects/packages/forms/src/dashboard/store/selectors.js @@ -4,7 +4,18 @@ export const getCurrentStatus = state => state.currentQuery?.status ?? 'draft,pu export const getSelectedResponsesFromCurrentDataset = state => state.selectedResponsesFromCurrentDataset; export const getSelectedResponsesCount = state => state.selectedResponsesFromCurrentDataset.length; -export const getCounts = state => state.counts; + +/** + * Get counts with query parameters. + * This selector works with a resolver to fetch counts based on query params. + * + * @param {object} state - The current state. + * @param {object} queryParams - Query parameters for filtering counts (used by resolver). + * @return {object} The counts object. + */ +// eslint-disable-next-line no-unused-vars +export const getCounts = ( state, queryParams ) => state.counts; + export const getInboxCount = state => state.counts.inbox; export const getSpamCount = state => state.counts.spam; export const getTrashCount = state => state.counts.trash; From 3eb6c49b35d5588fa1b164d68db7145a1867678d Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 9 Oct 2025 21:30:57 -0300 Subject: [PATCH 5/6] fix emptyspam/emptytrash buttons not clearing up the counts --- .../dashboard/components/empty-spam-button/index.tsx | 5 +++++ .../dashboard/components/empty-trash-button/index.tsx | 5 +++++ .../packages/forms/src/dashboard/store/action-types.js | 1 + projects/packages/forms/src/dashboard/store/actions.js | 10 ++++++++++ .../packages/forms/src/dashboard/store/resolvers.js | 4 +++- 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx b/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx index 33e5116c77b55..78a6c33132af0 100644 --- a/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx +++ b/projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx @@ -15,6 +15,7 @@ import { store as noticesStore } from '@wordpress/notices'; * Internal dependencies */ import useInboxData from '../../hooks/use-inbox-data'; +import { store as dashboardStore } from '../../store'; type CoreStore = typeof coreStore & { invalidateResolution: ( selector: string, args: unknown[] ) => void; @@ -42,6 +43,7 @@ const EmptySpamButton = ( { const [ isEmpty, setIsEmpty ] = useState( true ); const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { invalidateResolution } = useDispatch( coreStore ) as unknown as CoreStore; + const { invalidateCounts } = useDispatch( dashboardStore ); // Use props if provided, otherwise use hook const hookData = useInboxData(); @@ -103,12 +105,15 @@ const EmptySpamButton = ( { 'feedback', { ...currentQuery, per_page: 1, _fields: 'id' }, ] ); + // invalidate counts to refresh the counts across all status tabs + invalidateCounts(); } ); }, [ closeConfirmDialog, createErrorNotice, createSuccessNotice, invalidateResolution, + invalidateCounts, isEmpty, isEmptying, currentQuery, diff --git a/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx b/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx index f21902d765ad9..68852fff6a02f 100644 --- a/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx +++ b/projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx @@ -15,6 +15,7 @@ import { store as noticesStore } from '@wordpress/notices'; * Internal dependencies */ import useInboxData from '../../hooks/use-inbox-data'; +import { store as dashboardStore } from '../../store'; type CoreStore = typeof coreStore & { invalidateResolution: ( selector: string, args: unknown[] ) => void; @@ -42,6 +43,7 @@ const EmptyTrashButton = ( { const [ isEmpty, setIsEmpty ] = useState( true ); const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { invalidateResolution } = useDispatch( coreStore ) as unknown as CoreStore; + const { invalidateCounts } = useDispatch( dashboardStore ); // Use props if provided, otherwise use hook const hookData = useInboxData(); @@ -103,12 +105,15 @@ const EmptyTrashButton = ( { 'feedback', { ...currentQuery, per_page: 1, _fields: 'id' }, ] ); + // invalidate counts to refresh the counts across all status tabs + invalidateCounts(); } ); }, [ closeConfirmDialog, createErrorNotice, createSuccessNotice, invalidateResolution, + invalidateCounts, isEmpty, isEmptying, currentQuery, diff --git a/projects/packages/forms/src/dashboard/store/action-types.js b/projects/packages/forms/src/dashboard/store/action-types.js index d3a3c45b5fabd..0724570353719 100644 --- a/projects/packages/forms/src/dashboard/store/action-types.js +++ b/projects/packages/forms/src/dashboard/store/action-types.js @@ -4,3 +4,4 @@ export const SET_CURRENT_QUERY = 'SET_CURRENT_QUERY'; export const SET_SELECTED_RESPONSES = 'SET_SELECTED_RESPONSES'; export const SET_COUNTS = 'SET_COUNTS'; export const UPDATE_COUNTS_OPTIMISTICALLY = 'UPDATE_COUNTS_OPTIMISTICALLY'; +export const INVALIDATE_COUNTS = 'INVALIDATE_COUNTS'; diff --git a/projects/packages/forms/src/dashboard/store/actions.js b/projects/packages/forms/src/dashboard/store/actions.js index fe035db21c2f5..ce61bf6357b22 100644 --- a/projects/packages/forms/src/dashboard/store/actions.js +++ b/projects/packages/forms/src/dashboard/store/actions.js @@ -9,6 +9,7 @@ import { INVALIDATE_FILTERS, SET_COUNTS, UPDATE_COUNTS_OPTIMISTICALLY, + INVALIDATE_COUNTS, } from './action-types'; /** @@ -30,6 +31,15 @@ export const invalidateFilters = () => { return { type: INVALIDATE_FILTERS }; }; +/** + * Invalidate the counts when responses are deleted. + * + * @return {object} Action object. + */ +export const invalidateCounts = () => { + return { type: INVALIDATE_COUNTS }; +}; + /** * Set the selected responses from current data set. * diff --git a/projects/packages/forms/src/dashboard/store/resolvers.js b/projects/packages/forms/src/dashboard/store/resolvers.js index aaf67bb85d341..0b1a9b726dd97 100644 --- a/projects/packages/forms/src/dashboard/store/resolvers.js +++ b/projects/packages/forms/src/dashboard/store/resolvers.js @@ -1,6 +1,6 @@ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import { INVALIDATE_FILTERS } from './action-types'; +import { INVALIDATE_FILTERS, INVALIDATE_COUNTS } from './action-types'; export const getFilters = () => @@ -39,3 +39,5 @@ export const getCounts = const response = await apiFetch( { path } ); dispatch.setCounts( response ); }; + +getCounts.shouldInvalidate = action => action.type === INVALIDATE_COUNTS; From 9b4c134d8ea165bd886c4e590fad70004346f97b Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Thu, 9 Oct 2025 21:49:24 -0300 Subject: [PATCH 6/6] Add invalidateCounts mock to button test files --- .../js/dashboard/components/empty-spam-button/index.test.jsx | 2 ++ .../js/dashboard/components/empty-trash-button/index.test.jsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/projects/packages/forms/tests/js/dashboard/components/empty-spam-button/index.test.jsx b/projects/packages/forms/tests/js/dashboard/components/empty-spam-button/index.test.jsx index 8250bb951c2eb..fa27512de3b5b 100644 --- a/projects/packages/forms/tests/js/dashboard/components/empty-spam-button/index.test.jsx +++ b/projects/packages/forms/tests/js/dashboard/components/empty-spam-button/index.test.jsx @@ -76,6 +76,7 @@ jest.mock( '@wordpress/data', () => { setCounts: jest.fn(), setCurrentQuery: jest.fn(), setSelectedResponses: jest.fn(), + invalidateCounts: jest.fn(), }; const mockSelect = { @@ -102,6 +103,7 @@ jest.mock( '@wordpress/data', () => { setCounts: mockDispatch.setCounts, setCurrentQuery: mockDispatch.setCurrentQuery, setSelectedResponses: mockDispatch.setSelectedResponses, + invalidateCounts: mockDispatch.invalidateCounts, }; } return {}; diff --git a/projects/packages/forms/tests/js/dashboard/components/empty-trash-button/index.test.jsx b/projects/packages/forms/tests/js/dashboard/components/empty-trash-button/index.test.jsx index 44a6ff0dc2640..c7dd5c9cc1b35 100644 --- a/projects/packages/forms/tests/js/dashboard/components/empty-trash-button/index.test.jsx +++ b/projects/packages/forms/tests/js/dashboard/components/empty-trash-button/index.test.jsx @@ -76,6 +76,7 @@ jest.mock( '@wordpress/data', () => { setCounts: jest.fn(), setCurrentQuery: jest.fn(), setSelectedResponses: jest.fn(), + invalidateCounts: jest.fn(), }; const mockSelect = { @@ -102,6 +103,7 @@ jest.mock( '@wordpress/data', () => { setCounts: mockDispatch.setCounts, setCurrentQuery: mockDispatch.setCurrentQuery, setSelectedResponses: mockDispatch.setSelectedResponses, + invalidateCounts: mockDispatch.invalidateCounts, }; } return {};