From 39a785e8742a12656a6b7d1abfbf295add9520a5 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 8 May 2026 10:24:17 +0200 Subject: [PATCH 1/2] fix(settings): defer sentry app form mount until data loads The Sentry App edit page mounted `useScrapsForm` before the app data had finished loading, so the form's internal state was initialized from empty/default values. When the data arrived the form re-rendered but TanStack Form does not pick up new `defaultValues` after init, causing visible flashes (e.g. the Alert Rule Action switch flicking from off to on) and a risk of saving stale values. Extract the form into `SentryApplicationFormBody`, which the parent only mounts once the app query resolves (or for the new-app route), so `useScrapsForm` is initialized with the real values on first render. Tokens are still prefetched in parallel from the parent so the credentials panel renders without waiting on a second roundtrip. Refs #114138 --- .../sentryApplicationDetails.tsx | 668 +++++++++--------- 1 file changed, 339 insertions(+), 329 deletions(-) diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx index 451ba984d641bb..2e8eddbbc4f658 100644 --- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx +++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx @@ -221,7 +221,6 @@ function getSchemaFieldValue(schema: SentryApp['schema'] | null | undefined) { } export default function SentryApplicationDetails() { - const navigate = useNavigate(); const location = useLocation(); const {appSlug} = useParams<{appSlug: string}>(); const organization = useOrganization(); @@ -229,17 +228,13 @@ export default function SentryApplicationDetails() { const hasPageFrame = useHasPageFrameFeature(); const queryClient = useQueryClient(); - const isEditingApp = !!appSlug; - - const SENTRY_APP_API_TOKENS_QUERY_KEY = makeSentryAppApiTokensQueryKey(appSlug); + const isInternalRoute = location.pathname.endsWith('new-internal/'); - const sentryAppQueryOptions = sentryAppApiOptions({ - appSlug: isEditingApp ? appSlug : null, - }); + const sentryAppQueryOptions = sentryAppApiOptions({appSlug: appSlug ?? null}); const { data: app, - isPending, + isLoading, isError, refetch, } = useQuery({ @@ -258,13 +253,59 @@ export default function SentryApplicationDetails() { return found ? {json: found, headers: {}} : undefined; }, }); + const {data: tokens = []} = useApiQuery( - SENTRY_APP_API_TOKENS_QUERY_KEY, - { - staleTime: 30000, - enabled: isEditingApp, - } + makeSentryAppApiTokensQueryKey(appSlug ?? ''), + {staleTime: 30_000, enabled: !!appSlug} + ); + + const isInternal = app ? app.status === 'internal' : isInternalRoute; + const headerTitle = tct('[action] [type] Integration', { + action: app ? 'Edit' : 'Create', + type: isInternal ? 'Internal' : 'Public', + }); + + return ( +
+ {hasPageFrame ? ( + + ) : ( + + )} + + {isLoading ? ( + + ) : isError ? ( + + ) : ( + + )} +
); +} + +function SentryApplicationForm({ + app, + appSlug, + tokens, + isInternal, +}: { + app: SentryApp | undefined; + appSlug: string | undefined; + isInternal: boolean; + tokens: InternalAppApiToken[]; +}) { + const navigate = useNavigate(); + const organization = useOrganization(); + const queryClient = useQueryClient(); + + const sentryAppQueryOptions = sentryAppApiOptions({appSlug: appSlug ?? null}); + const [newTokens, setNewTokens] = useState([]); const [scopeErrors, setScopeErrors] = useState({permissions: {}}); @@ -323,23 +364,10 @@ export default function SentryApplicationDetails() { return organization.access.includes('org:write'); }; - const isInternal = () => { - if (app) { - return app.status === 'internal'; - } - return location.pathname.endsWith('new-internal/'); - }; - const showAuthInfo = () => !(app?.clientSecret?.[0] === '*'); - const headerTitle = () => { - const action = app ? 'Edit' : 'Create'; - const type = isInternal() ? 'Internal' : 'Public'; - return tct('[action] [type] Integration', {action, type}); - }; - const handleSubmitSuccess = (data: SentryApp) => { - const type = isInternal() ? 'internal' : 'public'; + const type = isInternal ? 'internal' : 'public'; const baseUrl = `/settings/${organization.slug}/developer-settings/`; const url = app ? `${baseUrl}?type=${type}` : `${baseUrl}${data.slug}/`; @@ -357,7 +385,7 @@ export default function SentryApplicationDetails() { } ); - refetch(); + queryClient.invalidateQueries({queryKey: sentryAppQueryOptions.queryKey}); } else { addSuccessMessage(t('%s successfully created.', data.name)); } @@ -380,7 +408,11 @@ export default function SentryApplicationDetails() { const handleFinishNewToken = (newToken: NewInternalAppApiToken) => { const updatedNewTokens = newTokens.filter(token => token.id !== newToken.id); const updatedTokens = tokens.concat(newToken); - setApiQueryData(queryClient, SENTRY_APP_API_TOKENS_QUERY_KEY, updatedTokens); + setApiQueryData( + queryClient, + makeSentryAppApiTokensQueryKey(appSlug ?? ''), + updatedTokens + ); setNewTokens(updatedNewTokens); }; @@ -391,7 +423,11 @@ export default function SentryApplicationDetails() { const updatedTokens = tokens.filter(tok => tok.id !== token.id); await removeTokenMutation.mutateAsync({sentryAppSlug: app.slug, tokenId: token.id}); - setApiQueryData(queryClient, SENTRY_APP_API_TOKENS_QUERY_KEY, updatedTokens); + setApiQueryData( + queryClient, + makeSentryAppApiTokensQueryKey(appSlug ?? ''), + updatedTokens + ); }; const renderTokens = () => { @@ -470,7 +506,7 @@ export default function SentryApplicationDetails() { model={app} onSave={addAvatar} title={isColor ? t('Logo') : t('Small Icon')} - help={styleProps.help.concat(isInternal() ? '' : t(' Required for publishing.'))} + help={styleProps.help.concat(isInternal ? '' : t(' Required for publishing.'))} defaultChoice={{ label: styleProps.label, description: styleProps.description, @@ -484,13 +520,13 @@ export default function SentryApplicationDetails() { author: app?.author ?? '', webhookUrl: app?.webhookUrl ?? '', redirectUrl: app?.redirectUrl ?? '', - verifyInstall: isInternal() ? false : (app?.verifyInstall ?? true), + verifyInstall: isInternal ? false : (app?.verifyInstall ?? true), isAlertable: app?.isAlertable ?? false, schema: getSchemaFieldValue(app?.schema), overview: app?.overview ?? '', allowedOrigins: convertMultilineFieldValue(app?.allowedOrigins ?? []), organization: organization.slug, - isInternal: isInternal(), + isInternal, scopes: app ? [...app.scopes] : [], events: app ? normalize(app.events) : [], }; @@ -589,321 +625,295 @@ export default function SentryApplicationDetails() { }); return ( -
- {hasPageFrame ? ( - - ) : ( - - )} - - {isEditingApp && isPending ? ( - - ) : isEditingApp && isError ? ( - - ) : ( - - - - {field => ( - - - - )} - - - {!isInternal() && ( - - {field => ( - - - + + + + {field => ( + + + + )} + + + {!isInternal && ( + + {field => ( + + required + > + + )} - - { - const isAlertable = fieldApi.form.getFieldValue('isAlertable'); - if (isInternal() && !value && isAlertable) { - fieldApi.form.setFieldValue('isAlertable', false); - } - }, - }} - > - {field => ( - - ), - } - )} - required={!isInternal()} - > - - + + )} + + { + const isAlertable = fieldApi.form.getFieldValue('isAlertable'); + if (isInternal && !value && isAlertable) { + fieldApi.form.setFieldValue('isAlertable', false); + } + }, + }} + > + {field => ( + + ), + } )} - - - {!isInternal() && ( - - {field => ( - - - - )} - + required={!isInternal} + > + + + )} + + + {!isInternal && ( + + {field => ( + + + )} - - {!isInternal() && ( - - {field => ( - - - + + )} + + {!isInternal && ( + + {field => ( + + > + + )} - - - {field => ( - - ), - } - )} - > - isInternal() && !state.values.webhookUrl} - > - {webhookDisabled => ( - - )} - - - )} - - - - {field => ( - - ), - } - )} - > - - - )} - - - - {field => ( - - - + + )} + + + {field => ( + + ), + } )} - - - - {field => ( - - + isInternal && !state.values.webhookUrl}> + {webhookDisabled => ( + - + )} + + + )} + + + + {field => ( + + ), + } )} - - - - {getAvatarChooser(true)} - {getAvatarChooser(false)} - - isInternal() && !state.values.webhookUrl}> - {webhookDisabled => ( - form.setFieldValue('scopes', scopes)} - onEventsChange={events => form.setFieldValue('events', events)} + > + - )} - - - {app?.status === 'internal' && ( - - - , - ]} - isEmpty={tokens.length === 0} - emptyMessage={t("You haven't created any authentication tokens yet.")} + + )} + + + + {field => ( + - {renderTokens()} - + + )} + - {app && ( - - {t('Credentials')} - - {app.status !== 'internal' && ( - - {({id}: {id: string}) => ( - {app.clientId ?? ''} - )} - + + {field => ( + + + + )} + + + + {getAvatarChooser(true)} + {getAvatarChooser(false)} + + isInternal && !state.values.webhookUrl}> + {webhookDisabled => ( + form.setFieldValue('scopes', scopes)} + onEventsChange={events => form.setFieldValue('events', events)} + /> + )} + + + {app?.status === 'internal' && ( + + + , + ]} + isEmpty={tokens.length === 0} + emptyMessage={t("You haven't created any authentication tokens yet.")} + > + {renderTokens()} + + )} + + {app && ( + + {t('Credentials')} + + {app.status !== 'internal' && ( + + {({id}: {id: string}) => ( + {app.clientId ?? ''} )} - + )} + - {({id}: {id: string}) => - app.clientSecret ? ( - + {({id}: {id: string}) => + app.clientSecret ? ( + + {app.clientSecret} + + ) : ( + + {t('hidden')} + {hasTokenAccess() ? ( + - {app.clientSecret} - - ) : ( - - {t('hidden')} - {hasTokenAccess() ? ( - - - - ) : undefined} - - ) - } - - - - )} - - - {t('Save Changes')} - - + + + ) : undefined} + + ) + } + + + )} -
+ + + {t('Save Changes')} + + ); } From 9d8857e2a40c91df0ce5a31348c0dd72dccd027f Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 8 May 2026 12:12:41 +0200 Subject: [PATCH 2/2] fix(settings): wait for fresh data before mounting sentry app form The detail useQuery uses placeholderData to instantly seed the page from the list cache (so the breadcrumb name does not flicker). With placeholderData set, the query reports isLoading=false on first render, so the form mounted with stale list values. TanStack Form snapshots defaultValues on mount, so the form ignored the real response when it arrived and could save stale data. Also gate the form on isPlaceholderData so it only mounts once the real fetch resolves. The breadcrumb still uses app?.name from the placeholder, preserving the no-flicker UX. Refs #114138 --- .../organizationDeveloperSettings/sentryApplicationDetails.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx index 2e8eddbbc4f658..96ae9d23ed9590 100644 --- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx +++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx @@ -236,6 +236,7 @@ export default function SentryApplicationDetails() { data: app, isLoading, isError, + isPlaceholderData, refetch, } = useQuery({ ...sentryAppQueryOptions, @@ -273,7 +274,7 @@ export default function SentryApplicationDetails() { )} - {isLoading ? ( + {isLoading || isPlaceholderData ? ( ) : isError ? (