Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/one-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ Source of truth: `BurrowService` in `lib/src/host/remote/service.ts`; `lib/src/h

**`OneTimeConnection` sits in the Remote control choices of Settings → Network's Phones section, enrolled or not** (`docs/specs/remote-network.md` -> "Settings → Network"); `OneTimeIndicator` sits in the Baseboard (`docs/specs/layout.md` -> "Baseboard").

- **The panel renders the service's state and owns only its busy and error.** **Closing Settings changes nothing**: the link keeps waiting.
- **Must render service state and accept busy/error updates only from the latest panel action.** **Closing Settings changes nothing**: the link keeps waiting.
- **Never open a link on a timer**: only New link replaces one (rationale).
- **Cancel and End send `oneTimeEnd`; Done sends the one that returns `ended` to `idle`. `ended {user-ended}` renders as `idle`** (rationale).
- **End copy is fixed per reason and level**, with a fallback for a reason a newer broker knows.
Expand Down
8 changes: 6 additions & 2 deletions lib/src/components/OneTimeConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function OneTimeConnection({ relayOrigin }: { relayOrigin: string }) {
const anyNetwork = policy !== null && phoneOnAnyNetwork(policy);
/** The action this panel has in flight: its answer, not an event, clears it. */
const [pending, setPending] = useState<'open' | 'end' | null>(null);
const actionSequence = useRef(0);
const [error, setError] = useState<string | null>(null);

// The Baseboard keeps the store subscribed for the window's life, so this is
Expand All @@ -135,11 +136,14 @@ export function OneTimeConnection({ relayOrigin }: { relayOrigin: string }) {
if (store.kind === 'unsupported') return null;

const run = (kind: 'open' | 'end', action: () => Promise<void>) => {
const mine = ++actionSequence.current;
setPending(kind);
setError(null);
void action()
.catch((caught: unknown) => setError(caught instanceof Error ? caught.message : String(caught)))
.finally(() => setPending(null));
.catch((caught: unknown) => {
if (mine === actionSequence.current) setError(caught instanceof Error ? caught.message : String(caught));
})
.finally(() => { if (mine === actionSequence.current) setPending(null); });
};
const open = () => run('open', openOneTime);
const end = () => run('end', endOneTime);
Expand Down
38 changes: 38 additions & 0 deletions lib/src/components/RemoteControlSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1864,6 +1864,44 @@ describe('One-time connection', () => {
expect(buttonLabelled('One-time connection')).toBeTruthy();
});

it.each([
{ retry: false, rejected: true },
{ retry: true, rejected: true },
{ retry: true, rejected: false },
])('ignores an obsolete open completion after Cancel: %j', async ({ retry, rejected }) => {
let finishOld: () => void = () => {};
let failOld: () => void = () => {};
let finishFresh: (state: OneTimeState) => void = () => {};
let attempts = 0;
const link = makeLink(async (cmd) => {
if (cmd === 'oneTimeOpen') {
if (attempts++ === 0) return new Promise((resolve, reject) => {
finishOld = () => resolve({ status: 'ended', reason: 'user-ended' });
failOld = () => reject(new Error('obsolete open failed'));
});
return new Promise((resolve) => (finishFresh = resolve));
}
if (cmd === 'oneTimeStatus') return { status: 'idle' };
return NOT_ENROLLED;
});
platform = { burrow: link };
await render();
await act(async () => buttonLabelled('One-time connection')!.click());
await act(async () => buttonLabelled('Cancel')!.click());
if (retry) await act(async () => buttonLabelled('One-time connection')!.click());
await act(async () => rejected ? failOld() : finishOld());
expect(text()).not.toContain('obsolete open failed');
if (retry) {
expect(text()).toContain('Getting a link\u2026');
expect(buttonLabelled('Cancel')).toBeTruthy();
await act(async () => finishFresh(oneTimeWaiting()));
await settleQrChunk();
expect(oneTimeCode()).toBeTruthy();
} else {
expect(buttonLabelled('One-time connection')!.disabled).toBe(false);
}
});

it('renders a refused open inline, under the button', async () => {
const service = oneTimeService(
{ status: 'idle' },
Expand Down
Loading