fix(mobile): keep source-control layout steady while Create PR eligibility loads (#11467)
* 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
This commit is contained in:
parent
5e258a9447
commit
c6d2180417
|
|
@ -13,6 +13,7 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
|
|||
return null
|
||||
}
|
||||
const enabled = !action.disabled
|
||||
const copy = action.hint ?? action.label
|
||||
return (
|
||||
<View style={styles.createPrBlock}>
|
||||
<Pressable
|
||||
|
|
@ -36,15 +37,17 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
|
|||
strokeWidth={2.2}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.createPrButtonText, !enabled && styles.createPrButtonTextDisabled]}>
|
||||
{action.label}
|
||||
<Text
|
||||
style={[
|
||||
styles.createPrButtonText,
|
||||
!enabled && styles.createPrButtonTextDisabled,
|
||||
action.hint && styles.createPrButtonHint
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{copy}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{action.hint ? (
|
||||
<Text style={styles.createPrHint} numberOfLines={2}>
|
||||
{action.hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HostedReviewCreationEligibility> = {}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HostedReviewCreationBlockedReason>([
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {}
|
||||
): HostedReviewCreationEligibility {
|
||||
return {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'main',
|
||||
title: 'feature',
|
||||
body: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((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<typeof deferred<Response>>[] = []
|
||||
const client = {
|
||||
sendRequest: vi.fn(() => {
|
||||
const request = deferred<Response>()
|
||||
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<typeof deferred<Response>>[] = []
|
||||
const client = {
|
||||
sendRequest: vi.fn(() => {
|
||||
const request = deferred<Response>()
|
||||
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<typeof deferred<Response>>[] = []
|
||||
const client = {
|
||||
sendRequest: vi.fn(() => {
|
||||
const request = deferred<Response>()
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<MobileHostedReviewEligibilityLoaderInput, 'client' | 'connState'>
|
||||
): 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<MobileCreatePrEligibilityState>({ 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<MobileHostedReviewEligibilityLoadSnapshot>({
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { MobileGitStatusResult } from './mobile-git-status'
|
|||
type Params = {
|
||||
client: Parameters<typeof useMobileHostedReviewEligibility>[0]['client']
|
||||
connState: Parameters<typeof useMobileHostedReviewEligibility>[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,
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
|||
const createPrAction = useMobileSourceControlCreatePrAction({
|
||||
client,
|
||||
connState,
|
||||
hostId,
|
||||
worktreeId,
|
||||
status,
|
||||
hasUncommittedChanges: entries.length > 0,
|
||||
|
|
|
|||
Loading…
Reference in New Issue