From 0c77d128022f9d4d1cf7eb922363138dc933729f Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 15:56:39 +0200 Subject: [PATCH 1/3] feat(experiments): share RefreshControl and wire up results refresh - Extract generic RefreshControl (button + message slot + theme prop) into base/forms, replacing the exposures-only AsOfRefreshControl - Add ExperimentResultsRefreshControl with the full throttle/poll/view-state machine, surfaced as a primary button below the status button on the detail header - Convert refreshExperimentBayesianResults to a queryFn that reads Retry-After for 429 throttling parity with exposures - Add a RefreshControl Storybook story and resultsViewState unit tests - SCSS token hygiene: replace phantom --font-*-size custom properties in results.scss with literals (font-size is not tokenised); use the colorIconDanger token instead of a hardcoded #e53e3e --- frontend/common/services/useExperiment.ts | 26 +++- .../components/RefreshControl.stories.tsx | 121 +++++++++++++++ .../components/base/forms/RefreshControl.tsx | 39 +++++ .../results/AsOfRefreshControl.tsx | 36 ----- .../results/ExperimentDetailHeader.tsx | 14 +- .../results/ExperimentExposuresPanel.tsx | 51 ++++--- .../ExperimentResultsRefreshControl.tsx | 141 ++++++++++++++++++ .../__tests__/resultsViewState.test.ts | 82 ++++++++++ .../experiments/results/results.scss | 18 +-- .../experiments/results/resultsViewState.ts | 50 +++++++ 10 files changed, 503 insertions(+), 75 deletions(-) create mode 100644 frontend/documentation/components/RefreshControl.stories.tsx create mode 100644 frontend/web/components/base/forms/RefreshControl.tsx delete mode 100644 frontend/web/components/experiments/results/AsOfRefreshControl.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx create mode 100644 frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts create mode 100644 frontend/web/components/experiments/results/resultsViewState.ts diff --git a/frontend/common/services/useExperiment.ts b/frontend/common/services/useExperiment.ts index 5a6bbda8ac6d..d8feaf3f5c34 100644 --- a/frontend/common/services/useExperiment.ts +++ b/frontend/common/services/useExperiment.ts @@ -106,10 +106,28 @@ export const experimentService = service invalidatesTags: (_res, _err, { experimentId }) => [ { id: experimentId, type: 'ExperimentResults' }, ], - query: ({ environmentId, experimentId }) => ({ - method: 'POST', - url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`, - }), + queryFn: async ( + { environmentId, experimentId }, + _api, + _extraOptions, + baseQuery, + ) => { + const result = await baseQuery({ + method: 'POST', + url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`, + }) + if (result.error) { + const retryAfter = + result.meta?.response?.headers?.get('Retry-After') + return { + error: { + ...result.error, + retryAfter: retryAfter ? parseInt(retryAfter, 10) : null, + }, + } + } + return { data: undefined } + }, }), refreshExperimentExposures: builder.mutation< Res['experimentExposures'], diff --git a/frontend/documentation/components/RefreshControl.stories.tsx b/frontend/documentation/components/RefreshControl.stories.tsx new file mode 100644 index 000000000000..af02babcb39d --- /dev/null +++ b/frontend/documentation/components/RefreshControl.stories.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import RefreshControl from 'components/base/forms/RefreshControl' +import { themeClassNames } from 'components/base/forms/Button' + +const themeOptions = Object.keys(themeClassNames) as Array< + keyof typeof themeClassNames +> + +const meta: Meta = { + argTypes: { + children: { + control: 'text', + description: 'Button label. Defaults to "Refresh".', + }, + disabled: { + control: 'boolean', + description: 'Disables the button, preventing interaction.', + }, + disabledReason: { + control: 'text', + description: 'Tooltip explaining why the button is disabled.', + }, + isRefreshing: { + control: 'boolean', + description: + 'Shows a busy label and disables the button while a refresh is in flight.', + }, + label: { + control: 'text', + description: + 'Related message rendered beneath the button — e.g. a retry countdown, an in-progress notice, or an error.', + }, + theme: { + control: 'select', + description: 'Visual variant of the button.', + options: themeOptions, + table: { defaultValue: { summary: 'secondary' } }, + }, + }, + args: { + children: 'Refresh', + disabled: false, + isRefreshing: false, + onRefresh: () => {}, + theme: 'secondary', + }, + component: RefreshControl, + parameters: { layout: 'centered' }, + title: 'Components/RefreshControl', +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Refreshing: Story = { + args: { + isRefreshing: true, + label: 'Computing… results will update automatically.', + }, + parameters: { + docs: { + description: { + story: + 'While a refresh is in flight the button shows a busy label and disables itself. The `label` slot carries the in-progress message.', + }, + }, + }, +} + +export const Throttled: Story = { + args: { + disabled: true, + label: 'Computing, retry in 4m 30s', + }, + parameters: { + docs: { + description: { + story: + 'After hitting the API rate limit (HTTP 429), the caller disables the button and feeds a Retry-After countdown into `label`.', + }, + }, + }, +} + +export const Disabled: Story = { + args: { + disabled: true, + disabledReason: 'Refresh is disabled because the experiment is complete.', + }, + parameters: { + docs: { + description: { + story: + 'Disabled state with a `disabledReason` surfaced as the button tooltip.', + }, + }, + }, +} + +export const PrimaryWithError: Story = { + args: { + children: 'Refresh results', + label: ( + The last results computation failed. + ), + theme: 'primary', + }, + parameters: { + docs: { + description: { + story: + 'Primary theme as used on the experiment results header, with an error message in the `label` slot.', + }, + }, + }, +} diff --git a/frontend/web/components/base/forms/RefreshControl.tsx b/frontend/web/components/base/forms/RefreshControl.tsx new file mode 100644 index 000000000000..beeed0dae935 --- /dev/null +++ b/frontend/web/components/base/forms/RefreshControl.tsx @@ -0,0 +1,39 @@ +import { FC, ReactNode } from 'react' +import Button, { themeClassNames } from './Button' + +type RefreshControlProps = { + onRefresh: () => void + isRefreshing: boolean + disabled: boolean + disabledReason?: string + theme?: keyof typeof themeClassNames + label?: ReactNode + children?: ReactNode +} + +const RefreshControl: FC = ({ + children, + disabled, + disabledReason, + isRefreshing, + label, + onRefresh, + theme = 'secondary', +}) => ( +
+ + {label ? ( +
{label}
+ ) : null} +
+) + +export default RefreshControl diff --git a/frontend/web/components/experiments/results/AsOfRefreshControl.tsx b/frontend/web/components/experiments/results/AsOfRefreshControl.tsx deleted file mode 100644 index ddf8c707364a..000000000000 --- a/frontend/web/components/experiments/results/AsOfRefreshControl.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FC } from 'react' -import moment from 'moment' -import Button from 'components/base/forms/Button' - -type AsOfRefreshControlProps = { - asOf: string | null - isRefreshing: boolean - disabled: boolean - disabledReason?: string - onRefresh: () => void -} - -export const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => ( - - {asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''} - -) - -const AsOfRefreshControl: FC = ({ - disabled, - disabledReason, - isRefreshing, - onRefresh, -}) => ( - -) - -export default AsOfRefreshControl diff --git a/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx index 021d7a2c39a4..1075ed003de0 100644 --- a/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx +++ b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx @@ -12,6 +12,7 @@ import { useStartExperimentMutation, useUpdateExperimentMutation, } from 'common/services/useExperiment' +import ExperimentResultsRefreshControl from './ExperimentResultsRefreshControl' import { Experiment } from 'common/types/responses' import Tooltip from 'components/Tooltip' import { getPrimaryMetric } from 'components/experiments/constants' @@ -269,14 +270,23 @@ const ExperimentDetailHeader: FC = ({ return ( <>
-
+

{experiment.name}

- {renderActions()} +
+ {renderActions()} + {experiment.status !== 'created' && ( + + )} +
{metricName && {metricName}} diff --git a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx index ee5d9b93ce9d..79c7779ed808 100644 --- a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx +++ b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx @@ -1,8 +1,11 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import moment from 'moment' import { LineChart } from 'components/charts' import ContentCard from 'components/base/grid/ContentCard' import Button from 'components/base/forms/Button' +import RefreshControl from 'components/base/forms/RefreshControl' import Icon from 'components/icons/Icon' +import { colorIconDanger } from 'common/theme/tokens' import { useGetExperimentExposuresQuery, useRefreshExperimentExposuresMutation, @@ -21,9 +24,14 @@ import { canRefreshExposures, deriveExposuresViewState, } from './exposuresViewState' -import AsOfRefreshControl, { AsOfLabel } from './AsOfRefreshControl' import './results.scss' +const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => ( + + {asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''} + +) + const buildLegendLabels = (totals: VariantTotal[]): Record => { const labels: Record = {} totals.forEach((t) => { @@ -156,26 +164,21 @@ const ExperimentExposuresPanel: FC = ({ }, [refresh, environmentId, experiment.id]) const action = ( -
- - {retryAfter !== null && ( -
- Computing, retry in {formatCountdown(retryAfter)} -
- )} -
+ ) const asOf = exposures?.as_of ?? null @@ -207,7 +210,7 @@ const ExperimentExposuresPanel: FC = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -244,7 +247,7 @@ const ExperimentExposuresPanel: FC = ({ <>
- + The last exposure computation failed. Showing previously computed data. @@ -256,7 +259,7 @@ const ExperimentExposuresPanel: FC = ({ {!payload && viewState.kind === 'error' && (
- + The last exposure computation failed.
)} diff --git a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx new file mode 100644 index 000000000000..4004c6f7fe02 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx @@ -0,0 +1,141 @@ +import { FC, ReactNode, useCallback, useEffect, useState } from 'react' +import { + useGetExperimentBayesianResultsQuery, + useRefreshExperimentBayesianResultsMutation, +} from 'common/services/useExperiment' +import { ExperimentStatus } from 'common/types/responses' +import RefreshControl from 'components/base/forms/RefreshControl' +import { + POLL_TIMEOUT_MS, + REFRESH_POLL_INTERVAL_MS, + canRefreshResults, + deriveResultsViewState, +} from './resultsViewState' + +const parseRetryAfter = (err: unknown): number | null => { + const fetchErr = err as { + status?: number + retryAfter?: number | null + } + if (fetchErr.status !== 429) return null + if (fetchErr.retryAfter) return fetchErr.retryAfter + return 300 +} + +const formatCountdown = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +type ExperimentResultsRefreshControlProps = { + environmentId: string + experimentId: number + status: ExperimentStatus +} + +const REFRESH_DISABLED_COPY: Record = { + final: 'Refresh is disabled because the experiment is complete.', + not_started: 'Start the experiment to compute results.', +} + +const ExperimentResultsRefreshControl: FC< + ExperimentResultsRefreshControlProps +> = ({ environmentId, experimentId, status }) => { + const [pollInterval, setPollInterval] = useState(0) + const [refreshRequested, setRefreshRequested] = useState(false) + const [pollStartedAt, setPollStartedAt] = useState(null) + const [retryAfter, setRetryAfter] = useState(null) + + const { data: results } = useGetExperimentBayesianResultsQuery( + { environmentId, experimentId }, + { pollingInterval: pollInterval, refetchOnMountOrArgChange: true }, + ) + const [refresh, { isLoading: isSubmitting }] = + useRefreshExperimentBayesianResultsMutation() + + const viewState = deriveResultsViewState(results) + const availability = canRefreshResults(status, results) + + const pollTimedOut = + pollStartedAt !== null && Date.now() - pollStartedAt > POLL_TIMEOUT_MS + const shouldPoll = + !pollTimedOut && (viewState.kind === 'refreshing' || refreshRequested) + const nextPollInterval = shouldPoll ? REFRESH_POLL_INTERVAL_MS : 0 + useEffect(() => { + setPollInterval(nextPollInterval) + }, [nextPollInterval]) + + useEffect(() => { + if (viewState.kind === 'loaded' || viewState.kind === 'error') { + setRefreshRequested(false) + setPollStartedAt(null) + } + }, [viewState.kind]) + + useEffect(() => { + if (pollTimedOut) { + setRefreshRequested(false) + setPollStartedAt(null) + } + }, [pollTimedOut]) + + useEffect(() => { + if (retryAfter === null || retryAfter <= 0) return + const timer = setInterval(() => { + setRetryAfter((prev) => { + if (prev === null || prev <= 1) return null + return prev - 1 + }) + }, 1000) + return () => clearInterval(timer) + }, [retryAfter !== null]) // eslint-disable-line react-hooks/exhaustive-deps + + const isRefreshing = viewState.kind === 'refreshing' || isSubmitting + const hasData = !!results?.payload + + const handleRefresh = useCallback(async () => { + const result = await refresh({ environmentId, experimentId }) + if ('error' in result && result.error) { + const seconds = parseRetryAfter(result.error) + if (seconds !== null) { + setRetryAfter(seconds) + } else { + toast('Failed to refresh results', 'danger') + } + } else { + setRefreshRequested(true) + setPollStartedAt(Date.now()) + } + }, [refresh, environmentId, experimentId]) + + let label: ReactNode = undefined + if (retryAfter !== null) { + label = `Computing, retry in ${formatCountdown(retryAfter)}` + } else if (isRefreshing) { + label = 'Computing… results will update automatically.' + } else if (viewState.kind === 'error') { + label = ( + The last results computation failed. + ) + } + + return ( + + Refresh results + + ) +} + +export default ExperimentResultsRefreshControl diff --git a/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts b/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts new file mode 100644 index 000000000000..f7427b0c0e75 --- /dev/null +++ b/frontend/web/components/experiments/results/__tests__/resultsViewState.test.ts @@ -0,0 +1,82 @@ +import { + canRefreshResults, + deriveResultsViewState, +} from 'components/experiments/results/resultsViewState' +import { + ExperimentBayesianResults, + ExperimentStatus, +} from 'common/types/responses' + +const results = ( + over: Partial = {}, +): ExperimentBayesianResults => ({ + as_of: null, + is_final: false, + last_error_at: null, + payload: null, + refresh_requested_at: null, + ...over, +}) + +const loaded = results({ + as_of: '2026-06-12T10:00:00Z', + payload: { metrics: [], srm_p_value: null }, +}) + +describe('deriveResultsViewState', () => { + it('is empty when there is no payload and nothing in flight', () => { + expect(deriveResultsViewState(results()).kind).toBe('empty') + }) + + it('is loaded when a payload is present and fresh', () => { + expect(deriveResultsViewState(loaded).kind).toBe('loaded') + }) + + it('is refreshing when a request is newer than the last result', () => { + const state = deriveResultsViewState({ + ...loaded, + refresh_requested_at: '2026-06-12T11:00:00Z', + }) + expect(state.kind).toBe('refreshing') + }) + + it('is error when the last error is newer than as_of, preserving stale payload', () => { + const state = deriveResultsViewState({ + ...loaded, + last_error_at: '2026-06-12T12:00:00Z', + }) + expect(state).toEqual({ kind: 'error', staleAvailable: true }) + }) + + it('prefers refreshing over a prior error', () => { + const state = deriveResultsViewState({ + ...loaded, + last_error_at: '2026-06-12T12:00:00Z', + refresh_requested_at: '2026-06-12T13:00:00Z', + }) + expect(state.kind).toBe('refreshing') + }) +}) + +describe('canRefreshResults', () => { + const cases: [ExperimentStatus, boolean][] = [ + ['created', false], + ['running', true], + ['paused', true], + ['completed', true], + ] + cases.forEach(([status, can]) => { + it(`${status} → canRefresh=${can}`, () => { + expect(canRefreshResults(status, loaded).canRefresh).toBe(can) + }) + }) + + it('blocks refresh once the results are final', () => { + expect(canRefreshResults('running', { ...loaded, is_final: true })).toEqual( + { + canRefresh: false, + reason: 'final', + }, + ) + }) +}) diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index ab5329d319e0..3bb9e5b4913c 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -3,7 +3,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: var(--font-caption-size, 12px); + font-size: 12px; margin-bottom: 6px; } @@ -70,7 +70,7 @@ &__lift-value { flex-shrink: 0; - font-size: var(--font-caption-size, 12px); + font-size: 12px; font-weight: var(--font-weight-regular); white-space: nowrap; } @@ -88,7 +88,7 @@ align-items: center; gap: 8px; padding: 16px 20px 0; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; } &__axis-chart { @@ -104,7 +104,7 @@ &__axis-tick-label { position: absolute; transform: translateX(-50%); - font-size: var(--font-caption-size, 12px); + font-size: 12px; font-weight: var(--font-weight-bold); color: var(--color-text-secondary); white-space: nowrap; @@ -147,7 +147,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: var(--font-caption-size, 12px); + font-size: 12px; color: var(--color-text-default); white-space: nowrap; padding-right: 6px; @@ -187,7 +187,7 @@ display: flex; align-items: center; gap: 8px; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; white-space: nowrap; } @@ -231,7 +231,7 @@ border-bottom: 1px solid var(--color-border-default); .react-tooltip.react-tooltip { - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; font-weight: var(--font-weight-regular); text-transform: none; letter-spacing: normal; @@ -244,7 +244,7 @@ td { padding: 22px 20px; - font-size: var(--font-body-sm-size, 13px); + font-size: 13px; color: var(--color-text-default); border-bottom: 1px solid var(--color-border-default); vertical-align: middle; @@ -280,7 +280,7 @@ display: flex; align-items: center; gap: 10px; - font-size: var(--font-caption-size, 12px); + font-size: 12px; color: var(--color-text-secondary); } } diff --git a/frontend/web/components/experiments/results/resultsViewState.ts b/frontend/web/components/experiments/results/resultsViewState.ts new file mode 100644 index 000000000000..d1d887acb49f --- /dev/null +++ b/frontend/web/components/experiments/results/resultsViewState.ts @@ -0,0 +1,50 @@ +import { + ExperimentBayesianResults, + ExperimentStatus, +} from 'common/types/responses' + +export type ResultsViewState = + | { kind: 'empty' } + | { kind: 'loaded' } + | { kind: 'refreshing' } + | { kind: 'error'; staleAvailable: boolean } + +export type RefreshReason = 'not_started' | 'final' +export type RefreshAvailability = { + canRefresh: boolean + reason?: RefreshReason +} + +export const REFRESH_POLL_INTERVAL_MS = 10000 +export const POLL_TIMEOUT_MS = 120000 + +const ms = (iso: string | null): number => (iso ? new Date(iso).getTime() : 0) + +const isRefreshing = (r: ExperimentBayesianResults): boolean => { + const requested = ms(r.refresh_requested_at) + return requested > 0 && requested > Math.max(ms(r.as_of), ms(r.last_error_at)) +} + +const hasError = (r: ExperimentBayesianResults): boolean => + ms(r.last_error_at) > ms(r.as_of) + +export const deriveResultsViewState = ( + results: ExperimentBayesianResults | null | undefined, +): ResultsViewState => { + if (!results) return { kind: 'empty' } + if (isRefreshing(results)) return { kind: 'refreshing' } + if (hasError(results)) { + return { kind: 'error', staleAvailable: !!results.payload } + } + if (results.payload) return { kind: 'loaded' } + return { kind: 'empty' } +} + +export const canRefreshResults = ( + status: ExperimentStatus, + results: ExperimentBayesianResults | null | undefined, +): RefreshAvailability => { + if (status === 'created') return { canRefresh: false, reason: 'not_started' } + if (results?.is_final) return { canRefresh: false, reason: 'final' } + return { canRefresh: true } +} From 10fe499ac2039d29ab7fb9da8411f5c03486da52 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 23 Jun 2026 17:50:02 +0200 Subject: [PATCH 2/3] fix(experiments): move results refresh to the Results heading and stop the load flash - Move the refresh control out of the detail header onto the Results heading line; use the default secondary (gray) theme instead of primary - Drop refetchOnMountOrArgChange from the control's bayesian-results query: the page already loads it, so the forced second fetch on mount re-animated the scorecards --- .../experiments/results/ExperimentDetailHeader.tsx | 14 ++------------ .../results/ExperimentResultsRefreshControl.tsx | 3 +-- .../web/components/pages/ExperimentDetailPage.tsx | 10 +++++++++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx index 1075ed003de0..021d7a2c39a4 100644 --- a/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx +++ b/frontend/web/components/experiments/results/ExperimentDetailHeader.tsx @@ -12,7 +12,6 @@ import { useStartExperimentMutation, useUpdateExperimentMutation, } from 'common/services/useExperiment' -import ExperimentResultsRefreshControl from './ExperimentResultsRefreshControl' import { Experiment } from 'common/types/responses' import Tooltip from 'components/Tooltip' import { getPrimaryMetric } from 'components/experiments/constants' @@ -270,23 +269,14 @@ const ExperimentDetailHeader: FC = ({ return ( <>
-
+

{experiment.name}

-
- {renderActions()} - {experiment.status !== 'created' && ( - - )} -
+ {renderActions()}
{metricName && {metricName}} diff --git a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx index 4004c6f7fe02..ff3b74a0f11c 100644 --- a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx +++ b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx @@ -49,7 +49,7 @@ const ExperimentResultsRefreshControl: FC< const { data: results } = useGetExperimentBayesianResultsQuery( { environmentId, experimentId }, - { pollingInterval: pollInterval, refetchOnMountOrArgChange: true }, + { pollingInterval: pollInterval }, ) const [refresh, { isLoading: isSubmitting }] = useRefreshExperimentBayesianResultsMutation() @@ -131,7 +131,6 @@ const ExperimentResultsRefreshControl: FC< isRefreshing={isRefreshing && hasData} label={label} onRefresh={handleRefresh} - theme='primary' > Refresh results diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index 196fd06cbfd7..ecdc24fc681c 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -12,6 +12,7 @@ import ExperimentConfiguration from 'components/experiments/results/ExperimentCo import ExperimentSummaryScorecard from 'components/experiments/results/ExperimentSummaryScorecard' import ExperimentMetricScorecard from 'components/experiments/results/ExperimentMetricScorecard' import ExperimentExposuresPanel from 'components/experiments/results/ExperimentExposuresPanel' +import ExperimentResultsRefreshControl from 'components/experiments/results/ExperimentResultsRefreshControl' type ExperimentDetailParams = { projectId: string @@ -88,7 +89,14 @@ const ExperimentDetailPage: FC = () => { {experiment.status !== 'created' && ( <> -
Results
+
+
Results
+ +
Date: Tue, 23 Jun 2026 18:05:19 +0200 Subject: [PATCH 3/3] fix(experiments): keep refresh button in loading state until recompute lands Derive the button's refreshing state from the refreshRequested intent (set on click, cleared on completion or error) instead of the transient isSubmitting, so it no longer flickers out of the loading state in the gap between the 202 and the refetch confirming the refreshing view-state. Applies to both the results and exposures refresh controls. --- .../experiments/results/ExperimentExposuresPanel.tsx | 10 ++++++---- .../results/ExperimentResultsRefreshControl.tsx | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx index 79c7779ed808..e29b94df32a8 100644 --- a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx +++ b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx @@ -141,25 +141,27 @@ const ExperimentExposuresPanel: FC = ({ [payload, identities], ) - const isRefreshing = viewState.kind === 'refreshing' || isSubmitting + const isRefreshing = + refreshRequested || viewState.kind === 'refreshing' || isSubmitting const headline = payload ? getHeadlineTotal(payload) : 0 const hasData = !!payload && headline > 0 const handleRefresh = useCallback(async () => { + setRefreshRequested(true) + setPollStartedAt(Date.now()) const result = await refresh({ environmentId, experimentId: experiment.id, }) if ('error' in result && result.error) { + setRefreshRequested(false) + setPollStartedAt(null) const seconds = parseRetryAfter(result.error) if (seconds !== null) { setRetryAfter(seconds) } else { toast('Failed to refresh exposures', 'danger') } - } else { - setRefreshRequested(true) - setPollStartedAt(Date.now()) } }, [refresh, environmentId, experiment.id]) diff --git a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx index ff3b74a0f11c..19cab5e1136c 100644 --- a/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx +++ b/frontend/web/components/experiments/results/ExperimentResultsRefreshControl.tsx @@ -91,21 +91,23 @@ const ExperimentResultsRefreshControl: FC< return () => clearInterval(timer) }, [retryAfter !== null]) // eslint-disable-line react-hooks/exhaustive-deps - const isRefreshing = viewState.kind === 'refreshing' || isSubmitting + const isRefreshing = + refreshRequested || viewState.kind === 'refreshing' || isSubmitting const hasData = !!results?.payload const handleRefresh = useCallback(async () => { + setRefreshRequested(true) + setPollStartedAt(Date.now()) const result = await refresh({ environmentId, experimentId }) if ('error' in result && result.error) { + setRefreshRequested(false) + setPollStartedAt(null) const seconds = parseRetryAfter(result.error) if (seconds !== null) { setRetryAfter(seconds) } else { toast('Failed to refresh results', 'danger') } - } else { - setRefreshRequested(true) - setPollStartedAt(Date.now()) } }, [refresh, environmentId, experimentId])