From a0798d9b54734cd629784331bc0be5b155922745 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Fri, 29 May 2026 14:41:00 +0800 Subject: [PATCH 01/11] feat(premium-analytics): add site-sync package --- pnpm-lock.yaml | 6 + .../packages/premium-analytics/package.json | 2 + .../packages/site-sync/package.json | 18 ++ .../site-sync/src/api/fetch-sync-status.ts | 18 ++ .../site-sync/src/api/trigger-full-sync.ts | 17 ++ .../packages/site-sync/src/constants.ts | 20 +++ .../hooks/__tests__/use-sync-status.test.ts | 120 +++++++++++++ .../site-sync/src/hooks/use-sync-status.ts | 127 ++++++++++++++ .../packages/site-sync/src/index.ts | 2 + .../site-sync/src/jetpack-script-data.d.ts | 14 ++ .../packages/site-sync/src/status.test.ts | 160 ++++++++++++++++++ .../packages/site-sync/src/status.ts | 56 ++++++ .../packages/site-sync/src/types.ts | 39 +++++ 13 files changed, 599 insertions(+) create mode 100644 projects/packages/premium-analytics/packages/site-sync/package.json create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/constants.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/index.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/status.test.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/status.ts create mode 100644 projects/packages/premium-analytics/packages/site-sync/src/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 344a9a69455b..43377eefde25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3862,6 +3862,9 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data '@automattic/number-formatters': specifier: workspace:* version: link:../../js-packages/number-formatters @@ -3923,6 +3926,9 @@ importers: '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/jest': specifier: 30.0.0 version: 30.0.0 diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 711e8245bd78..604f80167d86 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -31,6 +31,7 @@ } }, "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", "@automattic/number-formatters": "workspace:*", "@date-fns/tz": "1.4.1", "@tanstack/react-query": "5.90.8", @@ -53,6 +54,7 @@ "@storybook/react": "10.3.6", "@tanstack/react-query-devtools": "5.90.2", "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.14.0", diff --git a/projects/packages/premium-analytics/packages/site-sync/package.json b/projects/packages/premium-analytics/packages/site-sync/package.json new file mode 100644 index 000000000000..e862e61bd72c --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "@automattic/jetpack-premium-analytics-site-sync", + "version": "0.1.0", + "type": "module", + "wpScript": true, + "module": "build-module/index.mjs", + "wpScriptModuleExports": "./build-module/index.mjs", + "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", + "@wordpress/api-fetch": "7.48.0", + "@wordpress/i18n": "^6.9.0", + "react": "18.3.1" + }, + "devDependencies": { + "@testing-library/react": "16.3.2" + } +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts new file mode 100644 index 000000000000..017c6e5c9a7f --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { SYNC_STATUS_PATH } from '../constants'; +import type { SyncStatusApiResponse } from '../types'; + +/** + * Fetch the current sync status from Jetpack core. + * + * @return The current sync status. + */ +export function fetchSyncStatus(): Promise< SyncStatusApiResponse > { + return apiFetch( { path: SYNC_STATUS_PATH } ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts b/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts new file mode 100644 index 000000000000..ee16471a2d5b --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { FULL_SYNC_PATH } from '../constants'; + +/** + * Trigger a Jetpack full sync. + * + * @return The full-sync trigger response. + */ +export function triggerFullSync(): Promise< unknown > { + return apiFetch( { path: FULL_SYNC_PATH, method: 'POST' } ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts new file mode 100644 index 000000000000..a76673f5072c --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts @@ -0,0 +1,20 @@ +/** + * Polling interval in milliseconds. + */ +export const POLL_INTERVAL = 3_000; + +/** + * Jetpack core sync status endpoint (queue + full-sync state). + */ +export const SYNC_STATUS_PATH = '/jetpack/v4/sync/status'; + +/** + * Jetpack core full-sync trigger endpoint. + */ +export const FULL_SYNC_PATH = '/jetpack/v4/sync/full-sync'; + +/** + * Sync-module key whose progress gates the analytics dashboard. Mirrors the + * backend default (`Sync_Status_Tracker::ANALYTICS_SYNC_MODULE`). + */ +export const ANALYTICS_SYNC_MODULE = 'woocommerce_analytics'; diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts new file mode 100644 index 000000000000..5aabc33db11a --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import { renderHook, act, waitFor } from '@testing-library/react'; +/** + * Internal dependencies + */ +import { fetchSyncStatus } from '../../api/fetch-sync-status'; +import { triggerFullSync } from '../../api/trigger-full-sync'; +import { useSyncStatus } from '../use-sync-status'; +import type { SyncStatusApiResponse } from '../../types'; + +jest.mock( '../../api/fetch-sync-status' ); +jest.mock( '../../api/trigger-full-sync' ); +jest.mock( '@automattic/jetpack-script-data' ); + +const mockFetch = fetchSyncStatus as jest.MockedFunction< typeof fetchSyncStatus >; +const mockTrigger = triggerFullSync as jest.MockedFunction< typeof triggerFullSync >; +const mockScriptData = getScriptData as jest.MockedFunction< typeof getScriptData >; + +/** + * Build a raw sync-status API response for tests. + * + * @param overrides - Fields to override on the default running-analytics response. + * @return A raw sync-status API response. + */ +function rawStatus( overrides: Partial< SyncStatusApiResponse > = {} ): SyncStatusApiResponse { + return { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + ...overrides, + }; +} + +beforeEach( () => { + jest.useFakeTimers(); + // Default: milestone not set. + mockScriptData.mockReturnValue( { + premium_analytics: { initial_full_sync_finished: 0 }, + } as ReturnType< typeof getScriptData > ); + mockFetch.mockResolvedValue( rawStatus() ); + mockTrigger.mockResolvedValue( undefined ); +} ); + +afterEach( () => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); +} ); + +describe( 'useSyncStatus', () => { + it( 'exposes normalized progress after the first poll', async () => { + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.data?.percentage ).toBe( 50 ); + expect( result.current.data?.isRunning ).toBe( true ); + expect( result.current.error ).toBeNull(); + } ); + + it( 'reports complete and stops polling when analytics reaches 100', async () => { + mockFetch.mockResolvedValue( + rawStatus( { + finished: true, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + } ) + ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); + const callsAfterComplete = mockFetch.mock.calls.length; + + await act( async () => { + jest.advanceTimersByTime( 10_000 ); + } ); + expect( mockFetch.mock.calls ).toHaveLength( callsAfterComplete ); + } ); + + it( 'flags a stalled sync with an error', async () => { + mockFetch.mockResolvedValue( + rawStatus( { + started: true, + finished: true, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + } ) + ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isStalled ).toBe( true ) ); + expect( result.current.error ).toBeInstanceOf( Error ); + } ); + + it( 'surfaces fetch errors and never rejects triggerSync', async () => { + mockFetch.mockRejectedValueOnce( new Error( 'boom' ) ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.error ).toBeInstanceOf( Error ) ); + expect( result.current.error?.message ).toBe( 'boom' ); + + // triggerSync resolves even if the trigger call fails. + mockTrigger.mockRejectedValueOnce( new Error( 'nope' ) ); + await act( async () => { + await result.current.triggerSync(); + } ); + expect( result.current.error?.message ).toBe( 'nope' ); + } ); + + it( 'starts complete and skips polling when the milestone is set', async () => { + mockScriptData.mockReturnValue( { + premium_analytics: { initial_full_sync_finished: 1_700_000_000 }, + } as ReturnType< typeof getScriptData > ); + + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); + expect( mockFetch ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts new file mode 100644 index 000000000000..8c79e2248a7d --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef, useCallback } from 'react'; +/** + * Internal dependencies + */ +import { fetchSyncStatus } from '../api/fetch-sync-status'; +import { triggerFullSync } from '../api/trigger-full-sync'; +import { POLL_INTERVAL } from '../constants'; +import { toSyncStatus, isSyncComplete, isSyncStalled } from '../status'; +import type { SyncStatus, UseSyncStatusReturn } from '../types'; + +/** + * Read the page-load milestone injected by the backend Sync_Status_Tracker. + * Static for the lifetime of the page — never re-read while polling. + * + * @return The initial full-sync milestone (unix ts), or 0 if never finished. + */ +function readMilestone(): number { + return getScriptData()?.premium_analytics?.initial_full_sync_finished ?? 0; +} + +/** + * Polls Jetpack's sync status and returns analytics-scoped progress. + * + * Polling auto-stops when the sync is complete, stalled, or errors. If the + * page-load milestone is already set, the dashboard is gated open immediately + * and no polling occurs. `triggerSync` POSTs the full-sync trigger and resumes + * polling; it never rejects (failures surface via `error`). + * + * @return The current sync state plus a `triggerSync` action. + */ +export function useSyncStatus(): UseSyncStatusReturn { + const milestoneRef = useRef< number >( readMilestone() ); + const [ data, setData ] = useState< SyncStatus >(); + const [ error, setError ] = useState< Error | null >( null ); + const [ isStalled, setIsStalled ] = useState( false ); + + const intervalRef = useRef< ReturnType< typeof setInterval > | null >( null ); + // Hold the latest `poll` in a ref so the interval always calls the current + // closure. Preserves the original package's pollRef pattern and keeps the + // interval stable if `poll`'s identity ever changes. + const pollRef = useRef< () => void >(); + + const clearPolling = useCallback( () => { + if ( intervalRef.current ) { + clearInterval( intervalRef.current ); + intervalRef.current = null; + } + }, [] ); + + const poll = useCallback( () => { + fetchSyncStatus() + .then( raw => { + const status = toSyncStatus( raw, milestoneRef.current ); + setData( status ); + setError( null ); + setIsStalled( false ); + + if ( isSyncComplete( status ) ) { + clearPolling(); + return; + } + + if ( isSyncStalled( status ) ) { + clearPolling(); + setIsStalled( true ); + setError( + new Error( __( 'Sync has stalled. Please try again.', 'jetpack-premium-analytics' ) ) + ); + } + } ) + .catch( ( e: unknown ) => { + clearPolling(); + const message = + e instanceof Error + ? e.message + : __( 'Unable to get sync status.', 'jetpack-premium-analytics' ); + setError( new Error( message ) ); + } ); + }, [ clearPolling ] ); + + pollRef.current = poll; + + const startPolling = useCallback( () => { + clearPolling(); + intervalRef.current = setInterval( () => { + pollRef.current?.(); + }, POLL_INTERVAL ); + }, [ clearPolling ] ); + + const triggerSync = useCallback( async () => { + clearPolling(); + setError( null ); + setIsStalled( false ); + + try { + await triggerFullSync(); + poll(); + startPolling(); + } catch ( e: unknown ) { + const message = + e instanceof Error ? e.message : __( 'Unable to start sync.', 'jetpack-premium-analytics' ); + setError( new Error( message ) ); + } + }, [ clearPolling, poll, startPolling ] ); + + useEffect( () => { + // Already finished before this page load — gate open, no polling needed. + if ( milestoneRef.current > 0 ) { + setData( toSyncStatus( {}, milestoneRef.current ) ); + return; + } + + poll(); + startPolling(); + return clearPolling; + }, [ poll, startPolling, clearPolling ] ); + + const isComplete = data ? isSyncComplete( data ) : false; + const isLoading = ! data && ! error; + + return { data, error, isLoading, isComplete, isStalled, triggerSync }; +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/index.ts b/projects/packages/premium-analytics/packages/site-sync/src/index.ts new file mode 100644 index 000000000000..db2c2897a38a --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/index.ts @@ -0,0 +1,2 @@ +export { useSyncStatus } from './hooks/use-sync-status'; +export type { SyncStatus, SyncStatusApiResponse, UseSyncStatusReturn } from './types'; diff --git a/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts b/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts new file mode 100644 index 000000000000..f366df3d7891 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts @@ -0,0 +1,14 @@ +/** + * The backend `Sync_Status_Tracker` (jetpack PR #49211) injects this block into + * `window.JetpackScriptData` via the `jetpack_admin_js_script_data` filter. The + * base `@automattic/jetpack-script-data` types don't know about it, so augment. + */ +import '@automattic/jetpack-script-data'; + +declare module '@automattic/jetpack-script-data' { + interface JetpackScriptData { + premium_analytics?: { + initial_full_sync_finished: number; + }; + } +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts new file mode 100644 index 000000000000..b5da2b27e181 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts @@ -0,0 +1,160 @@ +import { toSyncStatus, isSyncComplete, isSyncStalled } from './status'; + +describe( 'toSyncStatus', () => { + it( 'reports not-started when the sync has never run', () => { + const status = toSyncStatus( { started: false }, 0 ); + expect( status ).toEqual( { + isStarted: false, + isRunning: false, + percentage: 0, + initialFullSyncFinished: 0, + } ); + } ); + + it( 'computes analytics-scoped percentage from the module bucket', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 1, total: 4 } }, + }, + 0 + ); + expect( status.isRunning ).toBe( true ); + expect( status.percentage ).toBe( 25 ); + } ); + + it( 'ignores non-analytics modules when computing percentage', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { + posts: { sent: 100, total: 100 }, + woocommerce_analytics: { sent: 1, total: 2 }, + }, + }, + 0 + ); + expect( status.percentage ).toBe( 50 ); + } ); + + it( 'is 100% when the page-load milestone is set', () => { + const status = toSyncStatus( { started: false }, 1_700_000_000 ); + expect( status.percentage ).toBe( 100 ); + expect( status.initialFullSyncFinished ).toBe( 1_700_000_000 ); + } ); + + it( 'caps percentage at 100', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 9, total: 4 } }, + }, + 0 + ); + expect( status.percentage ).toBe( 100 ); + } ); + + it( 'is complete (and gates open) when analytics hits 100% even if finished is still false', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + }, + 0 + ); + expect( status.percentage ).toBe( 100 ); + expect( status.isRunning ).toBe( true ); + expect( isSyncComplete( status ) ).toBe( true ); + expect( isSyncStalled( status ) ).toBe( false ); + } ); + + it( 'treats a numeric finished timestamp as finished', () => { + const status = toSyncStatus( + { + started: true, + finished: 1_700_000_000, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + }, + 0 + ); + // total > 0 branch: floor(1/2 * 100) = 50; numeric finished coerces to true + // so isRunning = started && !finished = false. + expect( status.percentage ).toBe( 50 ); + expect( status.isRunning ).toBe( false ); + expect( isSyncStalled( status ) ).toBe( true ); + } ); +} ); + +describe( 'isSyncComplete', () => { + it( 'is complete when the milestone is set', () => { + expect( + isSyncComplete( { + isStarted: false, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 1_700_000_000, + } ) + ).toBe( true ); + } ); + + it( 'is complete when analytics progress reaches 100 this session', () => { + expect( + isSyncComplete( { + isStarted: true, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 0, + } ) + ).toBe( true ); + } ); + + it( 'is not complete mid-progress', () => { + expect( + isSyncComplete( { + isStarted: true, + isRunning: true, + percentage: 50, + initialFullSyncFinished: 0, + } ) + ).toBe( false ); + } ); +} ); + +describe( 'isSyncStalled', () => { + it( 'is stalled when started, no longer running, and not complete', () => { + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 50, + initialFullSyncFinished: 0, + } ) + ).toBe( true ); + } ); + + it( 'is not stalled when it never started', () => { + expect( + isSyncStalled( { + isStarted: false, + isRunning: false, + percentage: 0, + initialFullSyncFinished: 0, + } ) + ).toBe( false ); + } ); + + it( 'is not stalled when complete', () => { + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 1_700_000_000, + } ) + ).toBe( false ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.ts new file mode 100644 index 000000000000..c88b416a4d55 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.ts @@ -0,0 +1,56 @@ +/** + * Internal dependencies + */ +import { ANALYTICS_SYNC_MODULE } from './constants'; +import type { SyncStatus, SyncStatusApiResponse } from './types'; + +/** + * Normalize Jetpack's raw sync status into the analytics-scoped shape. + * + * @param raw - Raw GET /jetpack/v4/sync/status response. + * @param milestone - Page-load milestone (unix ts, or 0 if never finished). + * @return Analytics-scoped sync status. + */ +export function toSyncStatus( raw: SyncStatusApiResponse, milestone: number ): SyncStatus { + const started = Boolean( raw.started ); + const finished = Boolean( raw.finished ); + const bucket = raw.progress?.[ ANALYTICS_SYNC_MODULE ]; + const total = bucket?.total ?? 0; + const sent = bucket?.sent ?? 0; + + let percentage = 0; + if ( total > 0 ) { + percentage = Math.min( 100, Math.floor( ( sent / total ) * 100 ) ); + } else if ( milestone > 0 || finished ) { + // No analytics bucket in this batch, but the sync has finished (now or + // before) — treat analytics as fully synced. + percentage = 100; + } + + return { + isStarted: started, + isRunning: started && ! finished, + percentage, + initialFullSyncFinished: milestone, + }; +} + +/** + * The analytics initial sync has finished — either before this page load + * (milestone) or analytics progress reached 100 during this session. + * @param status - Normalized sync status. + * @return Whether the analytics initial sync has finished. + */ +export function isSyncComplete( status: SyncStatus ): boolean { + return status.initialFullSyncFinished > 0 || status.percentage >= 100; +} + +/** + * Stalled = the sync started but is no longer running and hasn't completed. A + * sync that never started is NOT stalled — it just needs to be triggered. + * @param status - Normalized sync status. + * @return Whether the sync has stalled. + */ +export function isSyncStalled( status: SyncStatus ): boolean { + return status.isStarted && ! status.isRunning && ! isSyncComplete( status ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/types.ts b/projects/packages/premium-analytics/packages/site-sync/src/types.ts new file mode 100644 index 000000000000..07262f6da459 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/types.ts @@ -0,0 +1,39 @@ +/** + * Subset of Jetpack core's GET /jetpack/v4/sync/status response that this + * package consumes. `progress` is keyed by sync-module name; each module + * reports items `sent` of `total`. + */ +export type SyncStatusApiResponse = { + started?: boolean; + finished?: boolean | number; + progress?: Record< string, { sent?: number; total?: number } >; +}; + +/** + * Normalized, analytics-scoped sync status. + */ +export type SyncStatus = { + isStarted: boolean; + isRunning: boolean; + /** Analytics-module progress, 0–100, computed client-side. */ + percentage: number; + /** Page-load milestone: unix ts when the initial analytics sync first finished, else 0. */ + initialFullSyncFinished: number; +}; + +/** + * Return type for the useSyncStatus hook. + */ +export type UseSyncStatusReturn = { + data: SyncStatus | undefined; + error: Error | null; + isLoading: boolean; + isComplete: boolean; + isStalled: boolean; + /** + * POST the full-sync trigger and resume polling. The returned promise always + * resolves; failures surface via `error` so callers can `void triggerSync()` + * from event handlers without an unhandled rejection. + */ + triggerSync: () => Promise< void >; +}; From 693104a9c6b3f839798dfbc0e8ab4f581719d841 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Fri, 29 May 2026 14:41:03 +0800 Subject: [PATCH 02/11] feat(premium-analytics): configure apiFetch auth in init module --- .../premium-analytics/packages/init/package.json | 2 ++ .../premium-analytics/packages/init/src/index.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/projects/packages/premium-analytics/packages/init/package.json b/projects/packages/premium-analytics/packages/init/package.json index 91b3a4e0251b..063d4b69c7db 100644 --- a/projects/packages/premium-analytics/packages/init/package.json +++ b/projects/packages/premium-analytics/packages/init/package.json @@ -7,6 +7,8 @@ "module": "build-module/index.mjs", "wpScriptModuleExports": "./build-module/index.mjs", "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", + "@wordpress/api-fetch": "7.48.0", "@wordpress/boot": "0.14.1", "@wordpress/data": "10.48.0", "@wordpress/icons": "^13.0.0" diff --git a/projects/packages/premium-analytics/packages/init/src/index.ts b/projects/packages/premium-analytics/packages/init/src/index.ts index f4a5da3c37c8..3955304e7c49 100644 --- a/projects/packages/premium-analytics/packages/init/src/index.ts +++ b/projects/packages/premium-analytics/packages/init/src/index.ts @@ -1,6 +1,8 @@ /** * External dependencies */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import apiFetch from '@wordpress/api-fetch'; import { store as bootStore } from '@wordpress/boot'; import { dispatch } from '@wordpress/data'; import { chartBar } from '@wordpress/icons'; @@ -10,6 +12,16 @@ import { chartBar } from '@wordpress/icons'; * Runs before routes render. */ export async function init(): Promise< void > { + // Point apiFetch at this site's REST API and authenticate requests. Required + // before any package (e.g. site-sync) calls apiFetch against /jetpack/v4/*. + const site = getScriptData()?.site; + if ( site?.rest_root ) { + apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); + } + if ( site?.rest_nonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); + } + dispatch( bootStore ).updateMenuItem( 'dashboard', { icon: chartBar, } ); From 0bf911c81692bf0b121b8af3a91ff8eace79f561 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Fri, 29 May 2026 14:41:05 +0800 Subject: [PATCH 03/11] changelog: add premium-analytics site-sync entry --- projects/packages/premium-analytics/changelog/add-site-sync | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/packages/premium-analytics/changelog/add-site-sync diff --git a/projects/packages/premium-analytics/changelog/add-site-sync b/projects/packages/premium-analytics/changelog/add-site-sync new file mode 100644 index 000000000000..78c519e2c855 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-site-sync @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add site-sync package (useSyncStatus hook) and configure apiFetch auth in init. From 76be00a237955684abfcc76572c8cb9627be6abc Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 13:24:38 +0800 Subject: [PATCH 04/11] test(premium-analytics): harness wiring useSyncStatus (posts stand-in) into dashboard DO NOT MERGE. Testing-only branch for PR #49267. Wires useSyncStatus into the dashboard route (debug panel + trigger). The real woocommerce_analytics sync module is not registered yet, so points ANALYTICS_SYNC_MODULE and the jetpack_premium_analytics_sync_modules milestone filter at posts so reviewers can exercise the flow. Adds a temporary link: dep on site-sync. --- pnpm-lock.yaml | 3 +++ .../packages/premium-analytics/package.json | 1 + .../packages/site-sync/src/constants.ts | 6 +++++- .../routes/dashboard/stage.tsx | 20 +++++++++++++++++++ .../premium-analytics/src/class-analytics.php | 6 ++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dca00f27284e..5745c3e7853f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3895,6 +3895,9 @@ importers: '@jetpack-premium-analytics/routing': specifier: link:packages/routing version: link:packages/routing + '@jetpack-premium-analytics/site-sync': + specifier: link:packages/site-sync + version: link:packages/site-sync '@jetpack-premium-analytics/ui': specifier: link:packages/ui version: link:packages/ui diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 0b84e7cdbbb4..4ddd99fef132 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -41,6 +41,7 @@ "@jetpack-premium-analytics/fields": "link:packages/fields", "@jetpack-premium-analytics/formatters": "link:packages/formatters", "@jetpack-premium-analytics/routing": "link:packages/routing", + "@jetpack-premium-analytics/site-sync": "link:packages/site-sync", "@jetpack-premium-analytics/ui": "link:packages/ui", "@tanstack/react-query": "5.90.8", "@wordpress/api-fetch": "7.48.0", diff --git a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts index a76673f5072c..fe1dcc0d0319 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts @@ -16,5 +16,9 @@ export const FULL_SYNC_PATH = '/jetpack/v4/sync/full-sync'; /** * Sync-module key whose progress gates the analytics dashboard. Mirrors the * backend default (`Sync_Status_Tracker::ANALYTICS_SYNC_MODULE`). + * + * TEMP (harness): the production value is `woocommerce_analytics`, but that sync + * module is not registered yet, so this throwaway branch watches `posts` (always + * enqueued) so the panel can show real progress. Do not merge. */ -export const ANALYTICS_SYNC_MODULE = 'woocommerce_analytics'; +export const ANALYTICS_SYNC_MODULE = 'posts'; diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 034a332bc4eb..ac255302708d 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -1,10 +1,30 @@ +import { useSyncStatus } from '@jetpack-premium-analytics/site-sync'; import { __ } from '@wordpress/i18n'; +// TEMP (do not commit): visual harness for useSyncStatus E2E testing. +// NOTE: the real watched module `woocommerce_analytics` is not registered as a +// Jetpack sync module yet, so this branch points ANALYTICS_SYNC_MODULE (and the +// backend milestone filter) at `posts` purely so the panel can show real sync +// progress. See the PR testing instructions. export const stage = () => { + const { data, error, isLoading, isComplete, isStalled, triggerSync } = useSyncStatus(); + return (

{ __( 'Analytics', 'jetpack-premium-analytics' ) }

{ __( 'Welcome to the Analytics dashboard.', 'jetpack-premium-analytics' ) }

+ +
+

site-sync / useSyncStatus (TEMP harness — watching `posts`)

+
    +
  • isLoading: { String( isLoading ) }
  • +
  • isComplete: { String( isComplete ) }
  • +
  • isStalled: { String( isStalled ) }
  • +
  • error: { error ? error.message : 'null' }
  • +
+
{ JSON.stringify( data, null, 2 ) }
+ +
); }; diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 0b7686dc605a..6ec52383de70 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -62,6 +62,12 @@ public static function init( $options = array() ) { Sync_Status_Tracker::configure(); Api_Proxy_Controller::register(); + // TEMP (harness, do not merge): the real analytics sync module + // `woocommerce_analytics` is not registered yet, so flip the milestone on + // the always-enqueued `posts` full-sync instead. Mirrors the frontend + // ANALYTICS_SYNC_MODULE override so reviewers can exercise the flow. + add_filter( 'jetpack_premium_analytics_sync_modules', static fn() => array( 'posts' ) ); + add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) ); add_action( 'jetpack-premium-analytics_init', array( static::class, 'register_sidebar_items' ) ); } From 3925a2b41bca26f8b987734ee1bf1d4ce723ecd8 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 13:36:17 +0800 Subject: [PATCH 05/11] Revert "test(premium-analytics): harness wiring useSyncStatus (posts stand-in) into dashboard" This reverts commit 76be00a237955684abfcc76572c8cb9627be6abc. --- pnpm-lock.yaml | 3 --- .../packages/premium-analytics/package.json | 1 - .../packages/site-sync/src/constants.ts | 6 +----- .../routes/dashboard/stage.tsx | 20 ------------------- .../premium-analytics/src/class-analytics.php | 6 ------ 5 files changed, 1 insertion(+), 35 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5745c3e7853f..dca00f27284e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3895,9 +3895,6 @@ importers: '@jetpack-premium-analytics/routing': specifier: link:packages/routing version: link:packages/routing - '@jetpack-premium-analytics/site-sync': - specifier: link:packages/site-sync - version: link:packages/site-sync '@jetpack-premium-analytics/ui': specifier: link:packages/ui version: link:packages/ui diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 4ddd99fef132..0b84e7cdbbb4 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -41,7 +41,6 @@ "@jetpack-premium-analytics/fields": "link:packages/fields", "@jetpack-premium-analytics/formatters": "link:packages/formatters", "@jetpack-premium-analytics/routing": "link:packages/routing", - "@jetpack-premium-analytics/site-sync": "link:packages/site-sync", "@jetpack-premium-analytics/ui": "link:packages/ui", "@tanstack/react-query": "5.90.8", "@wordpress/api-fetch": "7.48.0", diff --git a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts index fe1dcc0d0319..a76673f5072c 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts @@ -16,9 +16,5 @@ export const FULL_SYNC_PATH = '/jetpack/v4/sync/full-sync'; /** * Sync-module key whose progress gates the analytics dashboard. Mirrors the * backend default (`Sync_Status_Tracker::ANALYTICS_SYNC_MODULE`). - * - * TEMP (harness): the production value is `woocommerce_analytics`, but that sync - * module is not registered yet, so this throwaway branch watches `posts` (always - * enqueued) so the panel can show real progress. Do not merge. */ -export const ANALYTICS_SYNC_MODULE = 'posts'; +export const ANALYTICS_SYNC_MODULE = 'woocommerce_analytics'; diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index ac255302708d..034a332bc4eb 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -1,30 +1,10 @@ -import { useSyncStatus } from '@jetpack-premium-analytics/site-sync'; import { __ } from '@wordpress/i18n'; -// TEMP (do not commit): visual harness for useSyncStatus E2E testing. -// NOTE: the real watched module `woocommerce_analytics` is not registered as a -// Jetpack sync module yet, so this branch points ANALYTICS_SYNC_MODULE (and the -// backend milestone filter) at `posts` purely so the panel can show real sync -// progress. See the PR testing instructions. export const stage = () => { - const { data, error, isLoading, isComplete, isStalled, triggerSync } = useSyncStatus(); - return (

{ __( 'Analytics', 'jetpack-premium-analytics' ) }

{ __( 'Welcome to the Analytics dashboard.', 'jetpack-premium-analytics' ) }

- -
-

site-sync / useSyncStatus (TEMP harness — watching `posts`)

-
    -
  • isLoading: { String( isLoading ) }
  • -
  • isComplete: { String( isComplete ) }
  • -
  • isStalled: { String( isStalled ) }
  • -
  • error: { error ? error.message : 'null' }
  • -
-
{ JSON.stringify( data, null, 2 ) }
- -
); }; diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 6ec52383de70..0b7686dc605a 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -62,12 +62,6 @@ public static function init( $options = array() ) { Sync_Status_Tracker::configure(); Api_Proxy_Controller::register(); - // TEMP (harness, do not merge): the real analytics sync module - // `woocommerce_analytics` is not registered yet, so flip the milestone on - // the always-enqueued `posts` full-sync instead. Mirrors the frontend - // ANALYTICS_SYNC_MODULE override so reviewers can exercise the flow. - add_filter( 'jetpack_premium_analytics_sync_modules', static fn() => array( 'posts' ) ); - add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) ); add_action( 'jetpack-premium-analytics_init', array( static::class, 'register_sidebar_items' ) ); } From 99a25a78376202c5bd5b5c309f9afa6f099d4f9e Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 13:43:02 +0800 Subject: [PATCH 06/11] feat(premium-analytics): refresh sync milestone live from /sync/status poll --- .../changelog/wooa7s-1321-live-sync-milestone | 4 ++++ .../src/hooks/__tests__/use-sync-status.test.ts | 14 ++++++++++++++ .../site-sync/src/hooks/use-sync-status.ts | 11 ++++++++++- .../packages/site-sync/src/types.ts | 8 +++++++- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone b/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone new file mode 100644 index 000000000000..7767e2c4a48a --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +useSyncStatus now refreshes initial_full_sync_finished live from each /sync/status poll (seeded from page-load script-data), so the milestone can flip mid-session. diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts index 5aabc33db11a..506ba3379b77 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -117,4 +117,18 @@ describe( 'useSyncStatus', () => { await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); expect( mockFetch ).not.toHaveBeenCalled(); } ); + + it( 'updates the milestone live from the sync-status poll', async () => { + // Milestone unset at page load; the backend then exposes it on the poll. + mockFetch.mockResolvedValue( rawStatus( { initial_full_sync_finished: 1_700_000_500 } ) ); + + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => + expect( result.current.data?.initialFullSyncFinished ).toBe( 1_700_000_500 ) + ); + // Milestone > 0 ⇒ complete even though analytics progress is only at 50%. + expect( result.current.isComplete ).toBe( true ); + expect( result.current.data?.percentage ).toBe( 50 ); + } ); } ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts index 8c79e2248a7d..1de468cef7fd 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts @@ -15,7 +15,8 @@ import type { SyncStatus, UseSyncStatusReturn } from '../types'; /** * Read the page-load milestone injected by the backend Sync_Status_Tracker. - * Static for the lifetime of the page — never re-read while polling. + * Used as the initial seed at mount; thereafter the milestone is refreshed live + * from each /sync/status poll (see `poll`), so it can flip mid-session. * * @return The initial full-sync milestone (unix ts), or 0 if never finished. */ @@ -55,6 +56,14 @@ export function useSyncStatus(): UseSyncStatusReturn { const poll = useCallback( () => { fetchSyncStatus() .then( raw => { + // Refresh the milestone live: the backend exposes the persisted + // value on every /sync/status response, so it can flip mid-session + // even though the script-data seed was captured once at mount. + const live = raw.initial_full_sync_finished ?? 0; + if ( live > milestoneRef.current ) { + milestoneRef.current = live; + } + const status = toSyncStatus( raw, milestoneRef.current ); setData( status ); setError( null ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/types.ts b/projects/packages/premium-analytics/packages/site-sync/src/types.ts index 07262f6da459..f45c62ca8748 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/types.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/types.ts @@ -7,6 +7,12 @@ export type SyncStatusApiResponse = { started?: boolean; finished?: boolean | number; progress?: Record< string, { sent?: number; total?: number } >; + /** + * Persisted analytics initial-full-sync milestone (unix ts, or 0), injected + * onto this response by the backend Sync_Status_Tracker so it can be read live + * on every poll rather than only at page load. + */ + initial_full_sync_finished?: number; }; /** @@ -17,7 +23,7 @@ export type SyncStatus = { isRunning: boolean; /** Analytics-module progress, 0–100, computed client-side. */ percentage: number; - /** Page-load milestone: unix ts when the initial analytics sync first finished, else 0. */ + /** Milestone (unix ts) when the initial analytics sync first finished — seeded from script-data, refreshed live from the poll; else 0. */ initialFullSyncFinished: number; }; From 6e2d76e62920fe7336e3ae01d4c7ffe3e030cde6 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 13:56:13 +0800 Subject: [PATCH 07/11] fix(premium-analytics): retry transient sync-status poll errors instead of stopping A single failed /sync/status fetch tore down polling permanently; recovery required a full-sync re-trigger. Keep polling through transient errors and only give up after MAX_POLL_FAILURES consecutive failures. Add tests for continued polling, unmount cleanup, triggerSync resume, and error self-heal. --- .../packages/site-sync/src/constants.ts | 7 ++ .../hooks/__tests__/use-sync-status.test.ts | 92 +++++++++++++++++++ .../site-sync/src/hooks/use-sync-status.ts | 18 +++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts index a76673f5072c..15ea95db3888 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts @@ -3,6 +3,13 @@ */ export const POLL_INTERVAL = 3_000; +/** + * Consecutive poll failures tolerated before polling gives up. A transient + * error (a network blip, a 500) is retried on the next tick; only a sustained + * run of failures stops polling and surfaces a terminal error. + */ +export const MAX_POLL_FAILURES = 3; + /** * Jetpack core sync status endpoint (queue + full-sync state). */ diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts index 506ba3379b77..4f6efe404d76 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -8,6 +8,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; */ import { fetchSyncStatus } from '../../api/fetch-sync-status'; import { triggerFullSync } from '../../api/trigger-full-sync'; +import { POLL_INTERVAL, MAX_POLL_FAILURES } from '../../constants'; import { useSyncStatus } from '../use-sync-status'; import type { SyncStatusApiResponse } from '../../types'; @@ -118,6 +119,97 @@ describe( 'useSyncStatus', () => { expect( mockFetch ).not.toHaveBeenCalled(); } ); + it( 'keeps polling on each interval while the sync is still running', async () => { + mockFetch.mockResolvedValue( rawStatus() ); // 50%, never completes. + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( mockFetch ).toHaveBeenCalledTimes( 1 ); + + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL ); + } ); + expect( mockFetch ).toHaveBeenCalledTimes( 2 ); + + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL ); + } ); + expect( mockFetch ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'stops polling after the hook unmounts', async () => { + const { result, unmount } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + const callsAtUnmount = mockFetch.mock.calls.length; + + unmount(); + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL * 3 ); + } ); + expect( mockFetch ).toHaveBeenCalledTimes( callsAtUnmount ); + } ); + + it( 'resumes polling and re-fetches after a successful triggerSync', async () => { + // Start stalled so the initial poll tears down the interval. + mockFetch.mockResolvedValue( rawStatus( { started: true, finished: true } ) ); + const { result } = renderHook( () => useSyncStatus() ); + await waitFor( () => expect( result.current.isStalled ).toBe( true ) ); + + // Backend is healthy again on the next trigger. + mockFetch.mockResolvedValue( rawStatus() ); + const before = mockFetch.mock.calls.length; + + await act( async () => { + await result.current.triggerSync(); + } ); + + expect( mockTrigger ).toHaveBeenCalledTimes( 1 ); + expect( mockFetch.mock.calls.length ).toBeGreaterThan( before ); // Immediate poll(). + expect( result.current.error ).toBeNull(); + expect( result.current.isStalled ).toBe( false ); + + const afterTrigger = mockFetch.mock.calls.length; + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL ); + } ); + expect( mockFetch.mock.calls.length ).toBeGreaterThan( afterTrigger ); // Polling resumed. + } ); + + it( 'keeps polling through a transient fetch error and self-heals on the next success', async () => { + mockFetch.mockRejectedValueOnce( new Error( 'blip' ) ); + const { result } = renderHook( () => useSyncStatus() ); + + // The error surfaces, but polling is not torn down. + await waitFor( () => expect( result.current.error?.message ).toBe( 'blip' ) ); + + // The next tick succeeds and clears the error. + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL ); + } ); + await waitFor( () => expect( result.current.error ).toBeNull() ); + expect( result.current.data?.percentage ).toBe( 50 ); + } ); + + it( 'gives up polling after MAX_POLL_FAILURES consecutive fetch errors', async () => { + mockFetch.mockRejectedValue( new Error( 'down' ) ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.error?.message ).toBe( 'down' ) ); + + // Drive past the failure cap, then confirm polling has stopped. + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL * ( MAX_POLL_FAILURES + 1 ) ); + } ); + const callsAfterGivingUp = mockFetch.mock.calls.length; + + await act( async () => { + jest.advanceTimersByTime( POLL_INTERVAL * 5 ); + } ); + expect( mockFetch ).toHaveBeenCalledTimes( callsAfterGivingUp ); + expect( result.current.error?.message ).toBe( 'down' ); + } ); + it( 'updates the milestone live from the sync-status poll', async () => { // Milestone unset at page load; the backend then exposes it on the poll. mockFetch.mockResolvedValue( rawStatus( { initial_full_sync_finished: 1_700_000_500 } ) ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts index 1de468cef7fd..1c9520745087 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts @@ -9,7 +9,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; */ import { fetchSyncStatus } from '../api/fetch-sync-status'; import { triggerFullSync } from '../api/trigger-full-sync'; -import { POLL_INTERVAL } from '../constants'; +import { POLL_INTERVAL, MAX_POLL_FAILURES } from '../constants'; import { toSyncStatus, isSyncComplete, isSyncStalled } from '../status'; import type { SyncStatus, UseSyncStatusReturn } from '../types'; @@ -27,7 +27,9 @@ function readMilestone(): number { /** * Polls Jetpack's sync status and returns analytics-scoped progress. * - * Polling auto-stops when the sync is complete, stalled, or errors. If the + * Polling auto-stops when the sync completes or stalls, or after + * `MAX_POLL_FAILURES` consecutive fetch errors; a single transient error is + * retried on the next tick and self-heals on the next success. If the * page-load milestone is already set, the dashboard is gated open immediately * and no polling occurs. `triggerSync` POSTs the full-sync trigger and resumes * polling; it never rejects (failures surface via `error`). @@ -41,6 +43,9 @@ export function useSyncStatus(): UseSyncStatusReturn { const [ isStalled, setIsStalled ] = useState( false ); const intervalRef = useRef< ReturnType< typeof setInterval > | null >( null ); + // Consecutive fetch failures. Reset on every success and whenever polling + // (re)starts; polling only gives up once this reaches `MAX_POLL_FAILURES`. + const failureCountRef = useRef( 0 ); // Hold the latest `poll` in a ref so the interval always calls the current // closure. Preserves the original package's pollRef pattern and keeps the // interval stable if `poll`'s identity ever changes. @@ -65,6 +70,7 @@ export function useSyncStatus(): UseSyncStatusReturn { } const status = toSyncStatus( raw, milestoneRef.current ); + failureCountRef.current = 0; setData( status ); setError( null ); setIsStalled( false ); @@ -83,11 +89,16 @@ export function useSyncStatus(): UseSyncStatusReturn { } } ) .catch( ( e: unknown ) => { - clearPolling(); const message = e instanceof Error ? e.message : __( 'Unable to get sync status.', 'jetpack-premium-analytics' ); + // Keep polling through transient blips; only give up once failures + // pile up, so a momentary network/500 hiccup self-heals next tick. + failureCountRef.current += 1; + if ( failureCountRef.current >= MAX_POLL_FAILURES ) { + clearPolling(); + } setError( new Error( message ) ); } ); }, [ clearPolling ] ); @@ -96,6 +107,7 @@ export function useSyncStatus(): UseSyncStatusReturn { const startPolling = useCallback( () => { clearPolling(); + failureCountRef.current = 0; intervalRef.current = setInterval( () => { pollRef.current?.(); }, POLL_INTERVAL ); From 49dea704411579236f8cdea279761672cd83c11b Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 13:56:26 +0800 Subject: [PATCH 08/11] fix(premium-analytics): guard apiFetch middleware against duplicate registration init() registered root-URL/nonce middleware onto the shared apiFetch chain on every call. Latch behind a module flag so re-mounts or a second boot don't stack duplicates; only latch once registration actually happened. --- .../packages/init/src/index.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/projects/packages/premium-analytics/packages/init/src/index.ts b/projects/packages/premium-analytics/packages/init/src/index.ts index 3955304e7c49..8dbbabeef35f 100644 --- a/projects/packages/premium-analytics/packages/init/src/index.ts +++ b/projects/packages/premium-analytics/packages/init/src/index.ts @@ -7,6 +7,11 @@ import { store as bootStore } from '@wordpress/boot'; import { dispatch } from '@wordpress/data'; import { chartBar } from '@wordpress/icons'; +// apiFetch middleware registers onto a shared, process-wide chain. Guard so +// repeated init() calls (re-mount, HMR, a future second boot) don't stack +// duplicate root-URL/nonce middleware. +let authConfigured = false; + /** * Initialize the Jetpack Analytics app. * Runs before routes render. @@ -14,12 +19,19 @@ import { chartBar } from '@wordpress/icons'; export async function init(): Promise< void > { // Point apiFetch at this site's REST API and authenticate requests. Required // before any package (e.g. site-sync) calls apiFetch against /jetpack/v4/*. - const site = getScriptData()?.site; - if ( site?.rest_root ) { - apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); - } - if ( site?.rest_nonce ) { - apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); + if ( ! authConfigured ) { + const site = getScriptData()?.site; + if ( site?.rest_root ) { + apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); + } + if ( site?.rest_nonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); + } + // Only latch once we actually registered, so an early call before + // script-data is ready doesn't permanently skip configuration. + if ( site?.rest_root || site?.rest_nonce ) { + authConfigured = true; + } } dispatch( bootStore ).updateMenuItem( 'dashboard', { From a18eceadf4dabd30d65fa1d353eba9c255c5cf47 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 14:00:02 +0800 Subject: [PATCH 09/11] Remove redundant changelog entry (covered by add-site-sync) --- .../changelog/wooa7s-1321-live-sync-milestone | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone b/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone deleted file mode 100644 index 7767e2c4a48a..000000000000 --- a/projects/packages/premium-analytics/changelog/wooa7s-1321-live-sync-milestone +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: changed - -useSyncStatus now refreshes initial_full_sync_finished live from each /sync/status poll (seeded from page-load script-data), so the milestone can flip mid-session. From 3a5aec3b1c46e85bcb9064c0fba3127ff67865a3 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 14:05:37 +0800 Subject: [PATCH 10/11] fix(premium-analytics): gate sync completion on milestone AND progress (match upstream) --- .../hooks/__tests__/use-sync-status.test.ts | 5 +-- .../packages/site-sync/src/status.test.ts | 33 +++++++++++++++++-- .../packages/site-sync/src/status.ts | 10 +++--- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts index 4f6efe404d76..b96ea026ff0a 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -66,6 +66,7 @@ describe( 'useSyncStatus', () => { rawStatus( { finished: true, progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + initial_full_sync_finished: 1_700_000_000, } ) ); const { result } = renderHook( () => useSyncStatus() ); @@ -219,8 +220,8 @@ describe( 'useSyncStatus', () => { await waitFor( () => expect( result.current.data?.initialFullSyncFinished ).toBe( 1_700_000_500 ) ); - // Milestone > 0 ⇒ complete even though analytics progress is only at 50%. - expect( result.current.isComplete ).toBe( true ); + // Milestone is live, but analytics progress is only 50% ⇒ not complete (AND). + expect( result.current.isComplete ).toBe( false ); expect( result.current.data?.percentage ).toBe( 50 ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts index b5da2b27e181..a764f1926b34 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts @@ -57,7 +57,7 @@ describe( 'toSyncStatus', () => { expect( status.percentage ).toBe( 100 ); } ); - it( 'is complete (and gates open) when analytics hits 100% even if finished is still false', () => { + it( 'is not complete from progress alone until the milestone is set', () => { const status = toSyncStatus( { started: true, @@ -68,6 +68,22 @@ describe( 'toSyncStatus', () => { ); expect( status.percentage ).toBe( 100 ); expect( status.isRunning ).toBe( true ); + // 100% progress but the milestone is unset ⇒ not complete (AND), and not + // stalled because the sync is still running. + expect( isSyncComplete( status ) ).toBe( false ); + expect( isSyncStalled( status ) ).toBe( false ); + } ); + + it( 'is complete once progress is 100% and the milestone is set', () => { + const status = toSyncStatus( + { + started: true, + finished: true, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + }, + 1_700_000_000 + ); + expect( status.percentage ).toBe( 100 ); expect( isSyncComplete( status ) ).toBe( true ); expect( isSyncStalled( status ) ).toBe( false ); } ); @@ -101,7 +117,7 @@ describe( 'isSyncComplete', () => { ).toBe( true ); } ); - it( 'is complete when analytics progress reaches 100 this session', () => { + it( 'is not complete from progress alone without the milestone', () => { expect( isSyncComplete( { isStarted: true, @@ -109,7 +125,18 @@ describe( 'isSyncComplete', () => { percentage: 100, initialFullSyncFinished: 0, } ) - ).toBe( true ); + ).toBe( false ); + } ); + + it( 'is not complete when the milestone is set but progress is below 100', () => { + expect( + isSyncComplete( { + isStarted: true, + isRunning: true, + percentage: 50, + initialFullSyncFinished: 1_700_000_000, + } ) + ).toBe( false ); } ); it( 'is not complete mid-progress', () => { diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.ts index c88b416a4d55..9a4d54d0e3c7 100644 --- a/projects/packages/premium-analytics/packages/site-sync/src/status.ts +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.ts @@ -21,9 +21,8 @@ export function toSyncStatus( raw: SyncStatusApiResponse, milestone: number ): S let percentage = 0; if ( total > 0 ) { percentage = Math.min( 100, Math.floor( ( sent / total ) * 100 ) ); - } else if ( milestone > 0 || finished ) { - // No analytics bucket in this batch, but the sync has finished (now or - // before) — treat analytics as fully synced. + } else if ( milestone > 0 ) { + // No analytics bucket this batch, but the milestone is set: initial sync done. percentage = 100; } @@ -36,13 +35,12 @@ export function toSyncStatus( raw: SyncStatusApiResponse, milestone: number ): S } /** - * The analytics initial sync has finished — either before this page load - * (milestone) or analytics progress reached 100 during this session. + * Determine whether sync is complete. * @param status - Normalized sync status. * @return Whether the analytics initial sync has finished. */ export function isSyncComplete( status: SyncStatus ): boolean { - return status.initialFullSyncFinished > 0 || status.percentage >= 100; + return status.percentage >= 100 && status.initialFullSyncFinished > 0; } /** From 2484d1495cb7c410eeb6e900e4738798ee61da78 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Mon, 15 Jun 2026 14:07:19 +0800 Subject: [PATCH 11/11] refactor(premium-analytics): extract setupApiFetch in init (match upstream) --- .../packages/init/src/index.ts | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/projects/packages/premium-analytics/packages/init/src/index.ts b/projects/packages/premium-analytics/packages/init/src/index.ts index 8dbbabeef35f..4dd80aa0b7f7 100644 --- a/projects/packages/premium-analytics/packages/init/src/index.ts +++ b/projects/packages/premium-analytics/packages/init/src/index.ts @@ -12,27 +12,35 @@ import { chartBar } from '@wordpress/icons'; // duplicate root-URL/nonce middleware. let authConfigured = false; +/** + * Configure the bundled apiFetch instance with the WordPress REST API root URL + * and authentication nonce from Jetpack script data. Runs once before routes + * render so shared packages (e.g. site-sync) can call the REST API. + */ +function setupApiFetch(): void { + if ( authConfigured ) { + return; + } + const site = getScriptData()?.site; + if ( site?.rest_root ) { + apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); + } + if ( site?.rest_nonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); + } + // Only latch once we actually registered, so an early call before + // script-data is ready doesn't permanently skip configuration. + if ( site?.rest_root || site?.rest_nonce ) { + authConfigured = true; + } +} + /** * Initialize the Jetpack Analytics app. * Runs before routes render. */ export async function init(): Promise< void > { - // Point apiFetch at this site's REST API and authenticate requests. Required - // before any package (e.g. site-sync) calls apiFetch against /jetpack/v4/*. - if ( ! authConfigured ) { - const site = getScriptData()?.site; - if ( site?.rest_root ) { - apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); - } - if ( site?.rest_nonce ) { - apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); - } - // Only latch once we actually registered, so an early call before - // script-data is ready doesn't permanently skip configuration. - if ( site?.rest_root || site?.rest_nonce ) { - authConfigured = true; - } - } + setupApiFetch(); dispatch( bootStore ).updateMenuItem( 'dashboard', { icon: chartBar,