From 1b727299716b00241e4614acd2bd93f38b4ac132 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 27 Aug 2026 13:43:16 +0800 Subject: [PATCH] fix(ui): stop the default-model picker spinning, keep instant reflection Settings > General > default model drove Astryx's Selector via the async `changeAction` prop, which holds the trigger's built-in optimistic busy state (a spinner) for the whole setDefaultModel + connection-refresh round trip. Switch ModelPicker to the synchronous `onChange` path so the trigger never spins. On that path Astryx no longer advances its own optimistic value, so the Settings row supplies the "reflect the pick immediately" half of #3827 itself via useOptimisticSelection: the pick shows the instant it is chosen and is cleared by a read barrier keyed on the connections read GENERATION, not a value or snapshot-reference compare. begin() shows the pick; settle() arms the barrier at the reads issued once the write is durable; only a read issued strictly after that (the caller's own refresh) clears it. So an in-flight pre-write read returning the old value cannot clear the pick; a concurrent external write or the prior value restored (A->B->A) resolves to authority; a refresh that lands no accepted read keeps the pick (the write already persisted it); a thrown write rolls back. Thread the committed connections read generation from the settings request authority down to the row. Drop the now-unused `loading` prop from ModelPicker. Cover the optimistic states with a packages/ui unit test. The no-spinner wiring is structural (ModelPicker has no changeAction/loading path) and can only be exercised faithfully in a browser; node:test cannot drive Astryx's transition/optimistic busy state, so no misleading unit assertion is added. Fixes #3827 Generated-by: Claude Code --- .../settings/general-settings-page.tsx | 50 ++++- .../settings/runtime-host-settings-target.tsx | 10 + .../settings/settings-request-authority.ts | 8 + .../renderer/settings/settings-surface.tsx | 18 ++ .../use-optimistic-selection.test.tsx | 195 ++++++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/model-picker.tsx | 10 +- packages/ui/src/use-optimistic-selection.ts | 101 +++++++++ 8 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/__tests__/use-optimistic-selection.test.tsx create mode 100644 packages/ui/src/use-optimistic-selection.ts diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 40e48cb63d..ff02285bf6 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -56,7 +56,7 @@ import { } from "@maka/ui"; import { ProviderBrandMark } from "./provider-brand-marks"; import { PasswordInput } from "./password-input"; -import { getConversationCopy } from '@maka/ui'; +import { getConversationCopy, useOptimisticSelection } from '@maka/ui'; import { settingsActionErrorMessage } from "./settings-error-copy"; import { useActionGuard, useKeyedActionGuard } from "./use-action-guard"; import { useOptimisticSettingsDraft } from "./use-optimistic-settings-draft"; @@ -66,6 +66,7 @@ import { getShellCopy } from "../locales/shell-copy.js"; import type { RuntimeHostSettingsConnectionsBridge } from './runtime-host-settings-bridge.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { + useOptionalRuntimeHostSettingsGenerationKey, useOptionalRuntimeHostSettingsTarget, useRuntimeHostSettingsTarget, } from './runtime-host-settings-target.js'; @@ -87,6 +88,8 @@ export function GeneralSettingsPage(props: { patch: Parameters[0], ): Promise; onRefreshConnections(): Promise; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; onRetryRuntimeHost(): Promise; }) { const host = useOptionalRuntimeHostSettingsTarget(); @@ -295,6 +298,8 @@ export function GeneralSettingsPage(props: { settingsInteractive={runtimeHostSettingsInteractive} showSettingsPlaceholder={showRuntimeHostSettingsPlaceholder} onRefresh={props.onRefreshConnections} + connectionsReadGeneration={props.connectionsReadGeneration} + getConnectionsReadGeneration={props.getConnectionsReadGeneration} permissionMode={props.settings.chatDefaults.permissionMode} thinkingLevel={props.settings.chatDefaults.thinkingLevel} onUpdate={props.onUpdate} @@ -492,6 +497,8 @@ function GeneralDefaultsCard(props: { settingsInteractive: boolean; showSettingsPlaceholder: boolean; onRefresh(): Promise; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; permissionMode: ChatDefaultPermissionMode; thinkingLevel?: ThinkingLevel; onUpdate( @@ -499,6 +506,7 @@ function GeneralDefaultsCard(props: { ): Promise; }) { const host = useOptionalRuntimeHostSettingsTarget(); + const runtimeHostGenerationKey = useOptionalRuntimeHostSettingsGenerationKey(); const locale = useUiLocale(); const copy = getSettingsPreferencesCopy(locale).general; // Level names come from the composer's own map — one vocabulary for the @@ -537,11 +545,41 @@ function GeneralDefaultsCard(props: { ? value : ""; }, [modelChoices, props.connections, props.defaultSlug]); + // Reflect the pick the instant it is chosen — the trigger uses the no-spin + // `onChange` path, so Astryx never advances its own optimistic value, and the + // sibling row would otherwise lag on the old model until the save round-trip. + // The pick is dropped by a read barrier keyed on the connections read + // GENERATION (not a value or snapshot-reference compare, both of which a + // read already in flight at pick time would trip): begin() shows it, settle() + // arms the barrier once the write is durable, and only a read issued after + // that (our own refresh) clears it. See useOptimisticSelection. + const modelSelection = useOptimisticSelection( + selectedValue, + props.connectionsReadGeneration, + ); + // Retire in-flight row state when the selected Host enters a new lifecycle + // generation. The interaction boundary already disables the row during + // revalidation, but it cannot reach this card's LOCAL state: without this a + // pick/save from the old epoch (whose IPC may never resolve) would linger on + // the new Host — a stale optimistic value, or `saving` stuck true leaving the + // row disabled and the action guard held. The card is kept mounted (the row + // retires by disabling, not remounting), so only the local state is cleared. + // `reset()` is safe mid-flight: guard holds are monotonic tokens, so a late + // release from the old save cannot strip a newer hold. + const { cancel: cancelPendingModel } = modelSelection; + useEffect(() => { + cancelPendingModel(); + setSaving(false); + setSavingPermissionMode(false); + setSavingThinkingLevel(false); + persistGuard.reset(); + }, [runtimeHostGenerationKey, cancelPendingModel, persistGuard]); async function persistDefault(nextValue: string) { if (!props.connectionsBridge || !props.connectionsInteractive) return; const releaseSave = persistGuard.begin("default-model"); if (!releaseSave) return; setSaving(true); + modelSelection.begin(nextValue); try { const parsed = parseModelChoiceValue(nextValue); await props.connectionsBridge.setDefaultModel( @@ -553,9 +591,16 @@ function GeneralDefaultsCard(props: { : null, ); if (!mountedRef.current) return; + // The write is durable; arm the barrier at the reads issued so far. Our + // refresh below issues a later read whose accepted snapshot clears the + // pick — a read already in flight (returning the pre-write value) cannot. + modelSelection.settle(props.getConnectionsReadGeneration()); await props.onRefresh(); } catch (error) { if (mountedRef.current) { + // The save threw: drop the optimistic pick so the trigger snaps back to + // the model that is actually persisted. + modelSelection.cancel(); toast.error( copy.saveDefaultModelFailed, settingsActionErrorMessage(error, locale), @@ -654,12 +699,11 @@ function GeneralDefaultsCard(props: { end={ } ariaLabel={copy.defaultModel} disabled={saving || !props.connectionsInteractive} - loading={saving} triggerClassName="settingsModelPickerTrigger" onValueChange={persistDefault} /> diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx b/apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx index b476f1fa0d..57d2a9431e 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx @@ -77,6 +77,16 @@ export function useRuntimeHostSettingsGenerationKey(): string { return target.generationKey; } +/** + * The current Host generation key, or `undefined` when no Host is selected. + * Use as a React `key` to retire a Host-owned subtree on an epoch change + * without the throw of {@link useRuntimeHostSettingsGenerationKey} in the + * Host-less/placeholder states some rows also render in. + */ +export function useOptionalRuntimeHostSettingsGenerationKey(): string | undefined { + return useContext(RuntimeHostSettingsTargetContext)?.generationKey; +} + /** * Retires only the Host-owned controller below this boundary when the selected * Runtime Host enters a new lifecycle generation. Parent route, draft, scroll, diff --git a/apps/desktop/src/renderer/settings/settings-request-authority.ts b/apps/desktop/src/renderer/settings/settings-request-authority.ts index 250779183c..8e4973b8b5 100644 --- a/apps/desktop/src/renderer/settings/settings-request-authority.ts +++ b/apps/desktop/src/renderer/settings/settings-request-authority.ts @@ -89,6 +89,14 @@ export function createSettingsRequestAuthority( return ticket(key, connectionsReadGeneration); }, + // The latest connections read generation issued so far. A read barrier + // captured here right after a write is exceeded only by reads issued + // afterwards (the caller's post-write refresh), never by ones already in + // flight — see useOptimisticSelection. + currentConnectionsReadGeneration(): number { + return connectionsReadGeneration; + }, + acceptsConnectionsRead(candidate: SettingsRequestTicket): boolean { return isCurrentTarget(candidate) && candidate.requestGeneration === connectionsReadGeneration; diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 9d261aea79..b527531eae 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -281,6 +281,12 @@ export function SettingsSurface(props: { ? snapshotCache.readRuntimeHostConnections(initialRuntimeHostKey) : undefined, )); + // The read generation that produced the connections snapshot currently shown. + // It advances only on an accepted read (see reloadConnections), so an + // optimistic default-model pick can be cleared strictly by a read issued + // after its write — see useOptimisticSelection / GeneralDefaultsCard. + const [committedConnectionsReadGeneration, setCommittedConnectionsReadGeneration] = + useState(0); const [runtimeHostCatalog, setRuntimeHostCatalog] = useState< SettingsResourceState >(() => createSettingsResourceState( @@ -525,6 +531,10 @@ export function SettingsSurface(props: { }; snapshotCache.commitRuntimeHostConnectionsRead(key, next); setRuntimeHostConnections(completeSettingsResourceLoad(key, next)); + // Publish the generation of the read that produced this snapshot so an + // optimistic default-model pick clears only on a read issued after its + // write (ticket is the accepted read here). + setCommittedConnectionsReadGeneration(ticket.requestGeneration); } catch (error) { if ( settingsModalMountedRef.current && @@ -979,6 +989,10 @@ export function SettingsSurface(props: { themePref={props.themePref} themePalette={props.themePalette} onRefreshConnections={reloadConnections} + connectionsReadGeneration={committedConnectionsReadGeneration} + getConnectionsReadGeneration={ + runtimeHostRequestAuthority.currentConnectionsReadGeneration + } onUpdateSettings={updateSettings} onReloadSettings={reloadRuntimeHostSettings} onReloadClientSettings={reloadClientSettings} @@ -1034,6 +1048,8 @@ function SettingsPageBody(props: { themePref: ThemePreference; themePalette: ThemePalette; onRefreshConnections(): Promise; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; onUpdateSettings(patch: Parameters[0]): Promise; onReloadSettings(): Promise; onReloadClientSettings(): Promise; @@ -1121,6 +1137,8 @@ function SettingsPageBody(props: { : undefined} onUpdate={props.onUpdateSettings} onRefreshConnections={props.onRefreshConnections} + connectionsReadGeneration={props.connectionsReadGeneration} + getConnectionsReadGeneration={props.getConnectionsReadGeneration} onRetryRuntimeHost={props.onRetryRuntimeHost} /> ); diff --git a/packages/ui/src/__tests__/use-optimistic-selection.test.tsx b/packages/ui/src/__tests__/use-optimistic-selection.test.tsx new file mode 100644 index 0000000000..1adc89dbfb --- /dev/null +++ b/packages/ui/src/__tests__/use-optimistic-selection.test.tsx @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The default-model row (Settings › 通用 › 默认模型) drives Astryx's Selector on + * the synchronous `onChange` path so the trigger never spins. On that path the + * Selector's own optimistic value never advances, so the row supplies the + * "reflect the pick immediately" half of the fix — this hook. + * + * The clear signal is a monotonic read GENERATION, and these pin why: a read + * already in flight when the user picks (returning the pre-write value) must + * NOT clear the pick, even though it commits a fresh snapshot afterwards. The + * hook separates begin (show) from settle (arm the barrier at the reads issued + * so far, once the write is durable); only a read issued past that floor — the + * caller's own post-write refresh — clears it. Covered here: instant show, + * in-flight read before settle, in-flight read at/under the floor, convergence + * to this pick / an external write / the prior value restored (A→B→A), + * refresh-that-lands-nothing keeping the pick, and cancel on a thrown write. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useOptimisticSelection, type OptimisticSelection } from '../use-optimistic-selection.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +interface Harness { + render(authoritative: string, committedGeneration: number): Promise; + begin(next: string): Promise; + settle(floor: number): Promise; + cancel(): Promise; + value(): string | null; +} + +async function mount(): Promise { + const { document, window } = parseHTML('
'); + const root = document.querySelector('#root'); + assert.ok(root); + Object.assign(globalThis, { + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const api: { current: OptimisticSelection | null } = { current: null }; + function Probe({ authoritative, committedGeneration }: { authoritative: string; committedGeneration: number }) { + const selection = useOptimisticSelection(authoritative, committedGeneration); + api.current = selection; + return ; + } + + mountedRoot = createRoot(root); + const el = () => root.querySelector('span'); + return { + async render(authoritative, committedGeneration) { + await act(() => + mountedRoot?.render(), + ); + }, + async begin(next) { + await act(() => api.current?.begin(next)); + }, + async settle(floor) { + await act(() => api.current?.settle(floor)); + }, + async cancel() { + await act(() => api.current?.cancel()); + }, + value: () => el()?.getAttribute('data-value') ?? null, + }; +} + +test('a pick shows immediately, before the write settles', async () => { + const h = await mount(); + await h.render('A', 5); + assert.equal(h.value(), 'A'); + + await h.begin('B'); + assert.equal(h.value(), 'B'); +}); + +test('an in-flight read that commits BEFORE settle does not clear the pick', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + + // A read that was in flight at pick time completes, committing the pre-write + // value A on a new generation. The barrier is not armed yet, so B stands. + await h.render('A', 6); + assert.equal(h.value(), 'B'); +}); + +test('an in-flight read at/under the floor does not clear after settle', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + // Write is durable; the latest read issued so far is generation 6. + await h.settle(6); + // That same in-flight read commits A at generation 6 (== floor) — pre-write, must not clear. + await h.render('A', 6); + assert.equal(h.value(), 'B'); +}); + +test("the post-write refresh (generation past the floor) clears to this pick", async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + await h.settle(6); + // Our refresh issued read 7 (> floor) and it accepted B. + await h.render('B', 7); + assert.equal(h.value(), 'B'); +}); + +test('a concurrent external write past the floor wins', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + await h.settle(6); + // The post-write read observed an external write to C. + await h.render('C', 7); + assert.equal(h.value(), 'C'); +}); + +test('A→B→A: authority restored to the pre-pick value still clears the pick', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + await h.settle(6); + // A post-write read (gen 7 > floor) reports the value is back to A. + await h.render('A', 7); + assert.equal(h.value(), 'A'); +}); + +test('a refresh that lands no new read keeps the pick (does not revert to stale)', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + await h.settle(6); + // Refresh failed/invalidated: no accepted read, generation unchanged. The + // write persisted B, so B must remain shown. + await h.render('A', 6); + assert.equal(h.value(), 'B'); +}); + +test('cancel rolls back to the authoritative value (write threw)', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B'); + assert.equal(h.value(), 'B'); + + await h.cancel(); + assert.equal(h.value(), 'A'); +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 247863794c..734d3dd19f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,6 +22,7 @@ export * from './assistant-stream.js'; export * from './chat-empty-hero.js'; export * from './chat-model-helpers.js'; export * from './use-mounted-ref.js'; +export * from './use-optimistic-selection.js'; export * from './components.js'; export type { ComposerProps } from './components.js'; export type { SandboxBoundaryPromptProps } from './sandbox-boundary-prompt.js'; diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx index df29709df4..6041b247a9 100644 --- a/packages/ui/src/model-picker.tsx +++ b/packages/ui/src/model-picker.tsx @@ -51,7 +51,6 @@ export interface ModelPickerProps { onValueChange(value: string): void | Promise; renderProviderMark?(type: ProviderType): ReactNode; disabled?: boolean; - loading?: boolean; /** * An ordinary option placed before the catalog for product values such as * “not set” or a current model that is no longer listed. Astryx search treats @@ -95,9 +94,14 @@ export function ModelPicker(props: ModelPickerProps) { size="md" placement="above" isDisabled={props.disabled} - isLoading={props.loading} className={props.triggerClassName} - changeAction={props.onValueChange} + // `onChange`, not `changeAction`: the async `changeAction` path wraps + // the caller's save in a transition and spins the trigger (via Astryx's + // built-in optimistic `isBusy`) for the whole round-trip. On the + // fire-and-forget `onChange` path the trigger never enters that busy + // state; the caller controls `value` from its own state (e.g. reflecting + // the pick optimistically) and the trigger simply follows it. + onChange={props.onValueChange} renderOption={(option: SelectorOptionData) => { const providerType = providerTypes.get(option.value); const providerMark = diff --git a/packages/ui/src/use-optimistic-selection.ts b/packages/ui/src/use-optimistic-selection.ts new file mode 100644 index 0000000000..b6af2d9ac0 --- /dev/null +++ b/packages/ui/src/use-optimistic-selection.ts @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface OptimisticSelection { + /** + * The value to render: the pending pick while its write is unconfirmed, + * otherwise the authoritative value. + */ + value: string; + /** Show a pick immediately. The read barrier is not armed yet — call `settle` once the write is durable. */ + begin(next: string): void; + /** + * Arm the read barrier at `floorGeneration`: the pending pick is dropped once + * `committedGeneration` (the read that produced the current authoritative + * value) advances *past* this floor — i.e. a read issued strictly after the + * write commits. Reads issued at or before the floor never clear it. + */ + settle(floorGeneration: number): void; + /** Drop the pending pick — e.g. the write threw — falling back to the authoritative value. */ + cancel(): void; +} + +/** + * Reflect a just-picked value instantly while its write is in flight, then + * defer to the authoritative value once a read that observed the write lands — + * without spinning, stranding a stale value, or reverting prematurely. + * + * The clearing signal is a monotonic read GENERATION, not a value compare and + * not a snapshot-reference change. Both of those are ambiguous: a snapshot ref + * only proves "some read completed", so a read that was already in flight when + * the pick happened (and returns the pre-write value) would clear the pick as + * soon as it commits, flashing new→old. This hook instead separates the two + * moments a caller knows about: + * + * 1. `begin(next)` — the user picked; show it immediately, barrier disarmed. + * 2. `settle(floor)` — the write is durable; arm the barrier at the read + * generation issued so far. The caller's own post-write refresh issues a + * read with a generation strictly greater than `floor`, so its commit + * clears the pick, while any read issued at/before the write (generation + * ≤ floor) does not. + * + * Consequences, all correct: an in-flight pre-write read committing between the + * pick and `settle` never clears (barrier disarmed); it also never clears after + * `settle` (its generation ≤ floor). The post-write refresh clears to whatever + * authority says — this pick, a concurrent external write, or the prior value + * restored (A→B→A). A refresh that lands no accepted read leaves + * `committedGeneration` unchanged, so the pick is kept (the write already + * persisted it). `cancel` covers a thrown write. + */ +export function useOptimisticSelection( + authoritative: string, + committedGeneration: number, +): OptimisticSelection { + const [pending, setPending] = useState(null); + // Read-generation floor armed by `settle`; null means the barrier is disarmed + // (a pick is shown but its write has not settled yet, so nothing clears it). + const floorRef = useRef(null); + + useEffect(() => { + if ( + pending !== null && + floorRef.current !== null && + committedGeneration > floorRef.current + ) { + floorRef.current = null; + setPending(null); + } + }, [pending, committedGeneration]); + + const begin = useCallback((next: string) => { + floorRef.current = null; + setPending(next); + }, []); + const settle = useCallback((floorGeneration: number) => { + floorRef.current = floorGeneration; + }, []); + const cancel = useCallback(() => { + floorRef.current = null; + setPending(null); + }, []); + + return { value: pending ?? authoritative, begin, settle, cancel }; +}