From c6d21804178c286bfa3c658c4ef90c4a32fed7ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:35:56 -0700 Subject: [PATCH] fix(mobile): keep source-control layout steady while Create PR eligibility loads (#11467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep source-control layout steady while Create PR eligibility loads The Create PR entry unmounted until the first hostedReview.getCreationEligibility answer arrived, so on a cold open the changed-files list painted first and then shifted down 54pt (createPrBlock marginTop 12 + createPrButton minHeight 42) when the button appeared — while the user was already tapping (#8411). - buildMobileCreatePrAction: cold loading now reserves the row with a disabled placeholder instead of unmounting it. - useMobileHostedReviewEligibility: a fetch-imminent idle frame renders as an in-flight load, so the reservation is present on the first painted frame. - New per-worktree+branch memory of the last resolved eligibility seeds cold loads, so branches whose answer is hidden (existing review, unsupported provider) do not get a placeholder that collapses on every reopen. Fixes #8411 * fix(mobile): harden source-control layout reservation * fix(mobile): keep review status row footprint fixed * fix(mobile): derive eligibility state from keyed snapshots --- .../MobileSourceControlCreatePrEntry.tsx | 17 +- .../mobile-create-pr-action.test.ts | 109 +++++- .../source-control/mobile-create-pr-action.ts | 46 ++- .../mobile-source-control-styles.ts | 13 +- ...e-mobile-hosted-review-eligibility.test.ts | 324 +++++++++++++++++- .../use-mobile-hosted-review-eligibility.ts | 114 +++--- ...-mobile-source-control-create-pr-action.ts | 3 + .../use-mobile-source-control-state.ts | 1 + 8 files changed, 525 insertions(+), 102 deletions(-) diff --git a/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx b/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx index c58838e68..b60c63217 100644 --- a/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx +++ b/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx @@ -13,6 +13,7 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) { return null } const enabled = !action.disabled + const copy = action.hint ?? action.label return ( )} - - {action.label} + + {copy} - {action.hint ? ( - - {action.hint} - - ) : null} ) } diff --git a/mobile/src/source-control/mobile-create-pr-action.test.ts b/mobile/src/source-control/mobile-create-pr-action.test.ts index 99b86cc85..c665b034f 100644 --- a/mobile/src/source-control/mobile-create-pr-action.test.ts +++ b/mobile/src/source-control/mobile-create-pr-action.test.ts @@ -4,7 +4,10 @@ import type { HostedReviewCreationEligibility, HostedReviewProvider } from '../../../src/shared/hosted-review' -import { buildMobileCreatePrAction } from './mobile-create-pr-action' +import { + buildMobileCreatePrAction, + type MobileCreatePrEligibilityState +} from './mobile-create-pr-action' function eligibility( overrides: Partial = {} @@ -108,26 +111,21 @@ describe('buildMobileCreatePrAction', () => { 'unsupported_provider', 'detached_head', null - ])('hides %s', (blockedReason) => { + ])('keeps a disabled status row for %s', (blockedReason) => { const { descriptor } = action({ eligibility: eligibility({ canCreate: false, blockedReason }) }) - expect(descriptor.visible).toBe(false) + expect(descriptor).toMatchObject({ visible: true, disabled: true }) + expect(descriptor.hint).toBeTruthy() }) - it('hides cold loading, errors, and missing branches', () => { - const onCreatePr = vi.fn() - - expect( - buildMobileCreatePrAction({ - branch: 'feature', - eligibilityState: { kind: 'loading', eligibility: null }, - busyAction: null, - onCreatePr - }).visible - ).toBe(false) - expect(action({ eligibility: null }).descriptor.visible).toBe(false) + it('keeps an unavailable row after errors but hides missing branches', () => { + expect(action({ eligibility: null }).descriptor).toMatchObject({ + visible: true, + disabled: true, + label: 'Review status unavailable' + }) expect(action({ branch: null }).descriptor.visible).toBe(false) }) @@ -146,12 +144,16 @@ describe('buildMobileCreatePrAction', () => { expect(onCreatePr).not.toHaveBeenCalled() }) - it('hides any provider that does not support hosted-review creation', () => { + it('keeps a disabled status row for providers without review creation', () => { const { descriptor } = action({ eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) }) - expect(descriptor.visible).toBe(false) + expect(descriptor).toMatchObject({ + visible: true, + disabled: true, + label: 'Review creation unavailable for this provider' + }) }) it('keeps a creatable button visible but disabled while a newer eligibility loads', () => { @@ -167,3 +169,76 @@ describe('buildMobileCreatePrAction', () => { expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: false }) }) }) + +// Issue #8411: the Create PR entry renders directly above the Stage All row and +// the changed-files list, so appearing late shifts the list down by +// createPrBlock marginTop (12) + createPrButton height (42) = 54pt while the +// user is already reading it. The row must keep its footprint across the cold +// eligibility fetch. +describe('cold-mount layout stability (issue #8411)', () => { + // useMobileHostedReviewEligibility's real cold-mount sequence for a creatable + // branch: no prior snapshot exists, so `loading` carries eligibility: null. + const coldMount: MobileCreatePrEligibilityState[] = [ + { kind: 'loading', eligibility: null }, + { kind: 'ready', eligibility: eligibility({ canCreate: true }) } + ] + + it('reserves the button row while the first eligibility request is in flight', () => { + const descriptors = coldMount.map((eligibilityState) => + buildMobileCreatePrAction({ + branch: 'feature', + eligibilityState, + busyAction: null, + onCreatePr: vi.fn() + }) + ) + + const footprint = descriptors.map(({ visible }) => visible) + + // On the buggy parent this was [false, true] -- the false -> true step is the jump. + expect(footprint).toEqual([true, true]) + }) + + it.each([ + [ + 'existing review', + { + kind: 'ready', + eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' }) + } satisfies MobileCreatePrEligibilityState + ], + [ + 'unsupported provider', + { + kind: 'ready', + eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + } satisfies MobileCreatePrEligibilityState + ], + ['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState] + ])('keeps the cold-load footprint when %s resolves', (_label, resolvedState) => { + const footprint = [coldMount[0], resolvedState].map((eligibilityState) => { + const descriptor = buildMobileCreatePrAction({ + branch: 'feature', + eligibilityState, + busyAction: null, + onCreatePr: vi.fn() + }) + return descriptor.visible + }) + + expect(footprint).toEqual([true, true]) + }) + + it('does not offer a tappable action against unresolved eligibility', () => { + const descriptor = buildMobileCreatePrAction({ + branch: 'feature', + eligibilityState: { kind: 'loading', eligibility: null }, + busyAction: null, + onCreatePr: vi.fn() + }) + + // Reserving space must not make the placeholder actionable: there is no + // canCreate/pushFirst answer yet, so a tap has nothing correct to do. + expect(descriptor.disabled).toBe(true) + }) +}) diff --git a/mobile/src/source-control/mobile-create-pr-action.ts b/mobile/src/source-control/mobile-create-pr-action.ts index 0636fc015..fe75cc60a 100644 --- a/mobile/src/source-control/mobile-create-pr-action.ts +++ b/mobile/src/source-control/mobile-create-pr-action.ts @@ -1,7 +1,4 @@ -import type { - HostedReviewCreationBlockedReason, - HostedReviewCreationEligibility -} from '../../../src/shared/hosted-review' +import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { supportsHostedReviewCreation } from '../../../src/shared/hosted-review-creation-providers' import { hostedReviewCopy } from './hosted-review-copy' import { getMobilePrCreateBlockMessage } from './mobile-pr-create' @@ -29,13 +26,6 @@ export type BuildMobileCreatePrActionArgs = { onCreatePr: (pushFirst: boolean) => void } -const HIDDEN_BLOCKED_REASONS = new Set([ - 'detached_head', - 'existing_review', - 'unsupported_provider', - null -]) - const BUSY_ACTIONS = new Set(['create-pr', 'push-create-pr']) function hiddenAction(onPress: () => void): MobileCreatePrAction { @@ -56,18 +46,42 @@ export function buildMobileCreatePrAction({ onCreatePr }: BuildMobileCreatePrActionArgs): MobileCreatePrAction { const noop = () => {} - if (!branch || eligibilityState.kind === 'idle' || eligibilityState.kind === 'error') { + if (!branch || eligibilityState.kind === 'idle') { return hiddenAction(noop) } + if (eligibilityState.kind === 'error') { + return { + visible: true, + label: 'Review status unavailable', + disabled: true, + loading: false, + pushFirst: false, + onPress: noop + } + } const eligibility = eligibilityState.eligibility if (!eligibility) { - return hiddenAction(noop) + return { + visible: true, + label: 'Checking review status…', + disabled: true, + loading: true, + pushFirst: false, + onPress: noop + } } // Why: mirror desktop's structural provider gate (supportsHostedReviewCreation) // instead of relying on the host always emitting a hidden blockedReason for // non-creatable providers like bitbucket. if (!supportsHostedReviewCreation(eligibility.provider)) { - return hiddenAction(noop) + return { + visible: true, + label: 'Review creation unavailable for this provider', + disabled: true, + loading: false, + pushFirst: false, + onPress: noop + } } const copy = hostedReviewCopy(eligibility.provider) const label = `Create ${copy.titleLabel}` @@ -95,10 +109,6 @@ export function buildMobileCreatePrAction({ } } - if (HIDDEN_BLOCKED_REASONS.has(eligibility.blockedReason)) { - return hiddenAction(noop) - } - const hint = getMobilePrCreateBlockMessage({ provider: eligibility.provider, diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index d8dc20a14..fe8d8da0a 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -207,11 +207,10 @@ const baseStyles = StyleSheet.create({ fontWeight: '600' }, createPrBlock: { - marginTop: spacing.md, - gap: spacing.xs + marginTop: spacing.md }, createPrButton: { - minHeight: 42, + height: 42, borderRadius: radii.button, backgroundColor: colors.textPrimary, alignItems: 'center', @@ -236,10 +235,12 @@ const baseStyles = StyleSheet.create({ createPrButtonTextDisabled: { color: colors.textSecondary }, - createPrHint: { - color: colors.textMuted, + createPrButtonHint: { fontSize: typography.metaSize, - lineHeight: 16 + fontWeight: '600', + lineHeight: 16, + textAlign: 'center', + flexShrink: 1 } }) diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts index cc1f4965d..13109a610 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts @@ -1,10 +1,48 @@ -import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { - acceptsMobileHostedReviewEligibilityLoad, buildMobileHostedReviewEligibilityLoadKey, eligibilityStateAfterMobileHostedReviewError, - shouldFetchMobileHostedReviewEligibility + renderedMobileHostedReviewEligibilityState, + shouldFetchMobileHostedReviewEligibility, + useMobileHostedReviewEligibility, + type MobileHostedReviewEligibilityLoadKey, + type MobileHostedReviewEligibilityLoadSnapshot } from './use-mobile-hosted-review-eligibility' +import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action' + +function eligibility( + overrides: Partial = {} +): HostedReviewCreationEligibility { + return { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'main', + title: 'feature', + body: '', + ...overrides + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function loadSnapshot( + key: MobileHostedReviewEligibilityLoadKey, + state: MobileCreatePrEligibilityState +): MobileHostedReviewEligibilityLoadSnapshot { + return { key, state } +} describe('mobile hosted review eligibility loader core', () => { it('does not fetch while disconnected or detached', () => { @@ -24,8 +62,9 @@ describe('mobile hosted review eligibility loader core', () => { ).toBe(false) }) - it('accepts only the latest generation for the current worktree branch identity', () => { + it('builds distinct keys for different branches', () => { const first = buildMobileHostedReviewEligibilityLoadKey({ + hostId: 'host-1', worktreeId: 'wt-1', branch: 'feature-a', hasUpstream: true, @@ -34,6 +73,7 @@ describe('mobile hosted review eligibility loader core', () => { hasUncommittedChanges: false }) const second = buildMobileHostedReviewEligibilityLoadKey({ + hostId: 'host-1', worktreeId: 'wt-1', branch: 'feature-b', hasUpstream: true, @@ -42,17 +82,281 @@ describe('mobile hosted review eligibility loader core', () => { hasUncommittedChanges: false }) + expect(first.identity).not.toBe(second.identity) + expect(first.fetch).not.toBe(second.fetch) + }) + + it('scopes load identity to the paired host', () => { + const input = { + worktreeId: 'repo-1::/workspace', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: false + } + const local = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'local' }) + const ssh = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'ssh-builder' }) + + expect(local.identity).not.toBe(ssh.identity) + expect(local.fetch).not.toBe(ssh.fetch) + }) + + it('renders a superseded same-identity snapshot as loading', () => { + const input = { + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0 + } + const older = buildMobileHostedReviewEligibilityLoadKey({ + ...input, + hasUncommittedChanges: false + }) + const newest = buildMobileHostedReviewEligibilityLoadKey({ + ...input, + hasUncommittedChanges: true + }) + + expect(older.identity).toBe(newest.identity) expect( - acceptsMobileHostedReviewEligibilityLoad({ - generation: 1, - currentGeneration: 2, - identity: first.identity, - currentIdentity: second.identity + renderedMobileHostedReviewEligibilityState({ + snapshot: loadSnapshot(older, { kind: 'ready', eligibility: eligibility() }), + key: newest, + shouldFetch: true }) - ).toBe(false) + ).toMatchObject({ kind: 'loading', eligibility: eligibility() }) }) it('fails closed after errors', () => { expect(eligibilityStateAfterMobileHostedReviewError()).toEqual({ kind: 'error' }) }) }) + +// #8411: what the hook returns is what paints. A fetch-imminent frame must not +// render as `idle` (hidden row) or the Create PR row pops in a frame later. +describe('rendered eligibility state', () => { + it('renders fetch-imminent idle as an in-flight load', () => { + const key = buildMobileHostedReviewEligibilityLoadKey({ + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: false + }) + expect( + renderedMobileHostedReviewEligibilityState({ + snapshot: loadSnapshot(key, { kind: 'idle' }), + key, + shouldFetch: true + }) + ).toEqual({ kind: 'loading', eligibility: null }) + }) + + it('passes resolved and refetch states through untouched', () => { + const key = buildMobileHostedReviewEligibilityLoadKey({ + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: false + }) + const ready = { kind: 'ready', eligibility: eligibility() } as const + const refetch = { kind: 'loading', eligibility: eligibility() } as const + const error = { kind: 'error' } as const + + for (const state of [ready, refetch, error]) { + expect( + renderedMobileHostedReviewEligibilityState({ + snapshot: loadSnapshot(key, state), + key, + shouldFetch: true + }) + ).toBe(state) + } + }) + + it('renders idle when a fetch is not possible, hiding stale snapshots in the same render', () => { + const key = buildMobileHostedReviewEligibilityLoadKey({ + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: false + }) + expect( + renderedMobileHostedReviewEligibilityState({ + snapshot: loadSnapshot(key, { kind: 'ready', eligibility: eligibility() }), + key, + shouldFetch: false + }) + ).toEqual({ kind: 'idle' }) + }) +}) + +describe('eligibility request ordering', () => { + let renderer: ReactTestRenderer | null = null + let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('does not let an older response overwrite the newest state', async () => { + type Response = { ok: true; result: HostedReviewCreationEligibility } + const requests: ReturnType>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + requests.push(request) + return request.promise + }) + } + const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' }) + const older = eligibility() + + function Harness({ dirty }: { dirty: boolean }): null { + renderedState = useMobileHostedReviewEligibility({ + client: client as never, + connState: 'connected', + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: dirty + }) + return null + } + + await act(async () => { + renderer = create(createElement(Harness, { dirty: false })) + await Promise.resolve() + }) + await act(async () => { + renderer?.update(createElement(Harness, { dirty: true })) + await Promise.resolve() + }) + expect(requests).toHaveLength(2) + + await act(async () => { + requests[1]!.resolve({ ok: true, result: newest }) + await Promise.resolve() + }) + await act(async () => { + requests[0]!.resolve({ ok: true, result: older }) + await Promise.resolve() + }) + + expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest }) + }) + + it('disables a ready snapshot in the render that changes its fetch key', async () => { + type Response = { ok: true; result: HostedReviewCreationEligibility } + const requests: ReturnType>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + requests.push(request) + return request.promise + }) + } + const ready = eligibility() + + function Harness({ dirty }: { dirty: boolean }): null { + renderedState = useMobileHostedReviewEligibility({ + client: client as never, + connState: 'connected', + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: dirty + }) + return null + } + + await act(async () => { + renderer = create(createElement(Harness, { dirty: false })) + await Promise.resolve() + }) + await act(async () => { + requests[0]!.resolve({ ok: true, result: ready }) + await Promise.resolve() + }) + expect(renderedState).toMatchObject({ kind: 'ready', eligibility: ready }) + + await act(async () => { + renderer?.update(createElement(Harness, { dirty: true })) + await Promise.resolve() + }) + expect(renderedState).toMatchObject({ kind: 'loading', eligibility: ready }) + }) + + it('invalidates a request when its hook instance unmounts', async () => { + type Response = { ok: true; result: HostedReviewCreationEligibility } + const requests: ReturnType>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + requests.push(request) + return request.promise + }) + } + const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' }) + + function Harness(): null { + renderedState = useMobileHostedReviewEligibility({ + client: client as never, + connState: 'connected', + hostId: 'host-1', + worktreeId: 'wt-1', + branch: 'feature', + hasUpstream: true, + ahead: 0, + behind: 0, + hasUncommittedChanges: false + }) + return null + } + + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + act(() => renderer?.unmount()) + renderer = null + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + expect(requests).toHaveLength(2) + + await act(async () => { + requests[1]!.resolve({ ok: true, result: newest }) + await Promise.resolve() + }) + await act(async () => { + requests[0]!.resolve({ ok: true, result: eligibility() }) + await Promise.resolve() + }) + + expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest }) + }) +}) diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts index ff5a98c43..e229d4953 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -11,6 +11,7 @@ import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action' export type MobileHostedReviewEligibilityLoaderInput = { client: RpcClient | null connState: ConnectionState + hostId: string worktreeId: string branch: string | null | undefined hasUpstream: boolean | undefined @@ -24,13 +25,19 @@ export type MobileHostedReviewEligibilityLoadKey = { fetch: string } +export type MobileHostedReviewEligibilityLoadSnapshot = { + key: MobileHostedReviewEligibilityLoadKey + state: MobileCreatePrEligibilityState +} + export function buildMobileHostedReviewEligibilityLoadKey( input: Omit ): MobileHostedReviewEligibilityLoadKey { const branch = input.branch ?? '' return { - identity: `${input.worktreeId}\0${branch}`, + identity: `${input.hostId}\0${input.worktreeId}\0${branch}`, fetch: [ + input.hostId, input.worktreeId, branch, String(input.hasUpstream ?? ''), @@ -47,25 +54,42 @@ export function shouldFetchMobileHostedReviewEligibility( return input.connState === 'connected' && input.client !== null && !!input.branch } -export function acceptsMobileHostedReviewEligibilityLoad(args: { - generation: number - currentGeneration: number - identity: string - currentIdentity: string -}): boolean { - return args.generation === args.currentGeneration && args.identity === args.currentIdentity -} - export function eligibilityStateAfterMobileHostedReviewError(): MobileCreatePrEligibilityState { return { kind: 'error' } } +export function renderedMobileHostedReviewEligibilityState(args: { + snapshot: MobileHostedReviewEligibilityLoadSnapshot + key: MobileHostedReviewEligibilityLoadKey + shouldFetch: boolean +}): MobileCreatePrEligibilityState { + if (!args.shouldFetch) { + return { kind: 'idle' } + } + const { snapshot, key } = args + if (snapshot.key.identity !== key.identity) { + return { kind: 'loading', eligibility: null } + } + const { state } = snapshot + if (snapshot.key.fetch !== key.fetch) { + return { + kind: 'loading', + eligibility: state.kind === 'ready' || state.kind === 'loading' ? state.eligibility : null + } + } + if (state.kind === 'idle') { + return { kind: 'loading', eligibility: null } + } + return state +} + export function useMobileHostedReviewEligibility( input: MobileHostedReviewEligibilityLoaderInput ): MobileCreatePrEligibilityState { const { client, connState, + hostId, worktreeId, branch, hasUpstream, @@ -74,11 +98,8 @@ export function useMobileHostedReviewEligibility( hasUncommittedChanges } = input const shouldFetch = shouldFetchMobileHostedReviewEligibility({ client, connState, branch }) - const [state, setState] = useState({ kind: 'idle' }) - const generationRef = useRef(0) - const currentIdentityRef = useRef('') - const lastResetIdentityRef = useRef('') const key = buildMobileHostedReviewEligibilityLoadKey({ + hostId, worktreeId, branch, hasUpstream, @@ -86,38 +107,35 @@ export function useMobileHostedReviewEligibility( behind, hasUncommittedChanges }) - - if (lastResetIdentityRef.current !== key.identity) { - lastResetIdentityRef.current = key.identity - setState({ kind: 'idle' }) - } - currentIdentityRef.current = key.identity + const [snapshot, setSnapshot] = useState({ + key: { identity: '', fetch: '' }, + state: { kind: 'idle' } + }) useEffect(() => { - const generation = generationRef.current + 1 - generationRef.current = generation - const isCurrent = () => - acceptsMobileHostedReviewEligibilityLoad({ - generation, - currentGeneration: generationRef.current, - identity: key.identity, - currentIdentity: currentIdentityRef.current - }) + let active = true if (!shouldFetch) { - if (isCurrent()) { - setState({ kind: 'idle' }) + setSnapshot({ key, state: { kind: 'idle' } }) + return () => { + active = false } - return } if (!client || !branch) { - return + return () => { + active = false + } } - setState((prev) => ({ - kind: 'loading', - eligibility: prev.kind === 'ready' ? prev.eligibility : null - })) + setSnapshot((previous) => { + const previousState = previous.state + const eligibility = + previous.key.identity === key.identity && + (previousState.kind === 'ready' || previousState.kind === 'loading') + ? previousState.eligibility + : null + return { key, state: { kind: 'loading', eligibility } } + }) const requestInput: MobileHostedReviewEligibilityInput = { branch, hasUncommittedChanges, @@ -127,20 +145,23 @@ export function useMobileHostedReviewEligibility( } void fetchMobileHostedReviewEligibility(client, worktreeId, requestInput) .then((eligibility: HostedReviewCreationEligibility | null) => { - if (!isCurrent()) { + if (!active) { return } if (!eligibility) { - setState({ kind: 'error' }) + setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() }) return } - setState({ kind: 'ready', eligibility }) + setSnapshot({ key, state: { kind: 'ready', eligibility } }) }) .catch(() => { - if (isCurrent()) { - setState(eligibilityStateAfterMobileHostedReviewError()) + if (active) { + setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() }) } }) + return () => { + active = false + } }, [ ahead, behind, @@ -149,6 +170,7 @@ export function useMobileHostedReviewEligibility( connState, hasUncommittedChanges, hasUpstream, + hostId, key.fetch, key.identity, shouldFetch, @@ -159,5 +181,9 @@ export function useMobileHostedReviewEligibility( // snapshot in the same render, before the effect posts `idle` — otherwise the // Create PR button could stay enabled for one paint after the worktree is // no longer fetchable. - return shouldFetch ? state : { kind: 'idle' } + return renderedMobileHostedReviewEligibilityState({ + snapshot, + key, + shouldFetch + }) } diff --git a/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts b/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts index 44655d147..49ec9bda8 100644 --- a/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts +++ b/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts @@ -6,6 +6,7 @@ import type { MobileGitStatusResult } from './mobile-git-status' type Params = { client: Parameters[0]['client'] connState: Parameters[0]['connState'] + hostId: string worktreeId: string status: MobileGitStatusResult | null hasUncommittedChanges: boolean @@ -16,6 +17,7 @@ type Params = { export function useMobileSourceControlCreatePrAction({ client, connState, + hostId, worktreeId, status, hasUncommittedChanges, @@ -26,6 +28,7 @@ export function useMobileSourceControlCreatePrAction({ const eligibilityState = useMobileHostedReviewEligibility({ client, connState, + hostId, worktreeId, branch: status?.branch, hasUpstream: upstream?.hasUpstream, diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index e50525f3c..1f541739d 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -209,6 +209,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara const createPrAction = useMobileSourceControlCreatePrAction({ client, connState, + hostId, worktreeId, status, hasUncommittedChanges: entries.length > 0,