Skip to content
Open
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
110 changes: 110 additions & 0 deletions app/src/store/__tests__/accountsSlice.rehydrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Issue #1379 — embedded-app tabs (Slack/Discord/WhatsApp/...) showing
* "is taking longer than expected" overlay immediately after reopening
* the desktop app was caused by Redux-persist replaying the previous
* session's transient `Account.status` (`timeout` / `loading` / ...)
* before the new webview spawn had even started. The fix is a
* `REHYDRATE` extraReducer that flips any non-`closed` status to
* `closed` so the next session begins from a fresh load state.
*/
import { REHYDRATE } from 'redux-persist';
import { describe, expect, it } from 'vitest';

import type { Account, AccountsState, AccountStatus } from '../../types/accounts';
import reducer from '../accountsSlice';

function makeAccount(overrides: Partial<Account> = {}): Account {
return {
id: 'acct-1',
provider: 'slack',
label: 'Slack',
createdAt: '2026-01-01T00:00:00Z',
status: 'pending',
...overrides,
};
}

function seedState(accounts: Account[]): AccountsState {
const state: AccountsState = {
accounts: {},
order: [],
activeAccountId: accounts[0]?.id ?? null,
lastActiveAccountId: accounts[0]?.id ?? null,
messages: {},
unread: {},
logs: {},
};
for (const acct of accounts) {
state.accounts[acct.id] = acct;
state.order.push(acct.id);
state.messages[acct.id] = [];
state.unread[acct.id] = 0;
state.logs[acct.id] = [];
}
return state;
}

function rehydrate(state: AccountsState, key = 'accounts') {
return reducer(state, { type: REHYDRATE, key, payload: state } as unknown as {
type: typeof REHYDRATE;
});
}

describe('accountsSlice REHYDRATE — issue #1379', () => {
const TRANSIENT: AccountStatus[] = ['pending', 'loading', 'timeout', 'open', 'error'];

it.each(TRANSIENT)('resets `%s` status to `closed` so stale overlays do not replay', status => {
const before = seedState([makeAccount({ status, lastError: 'stale' })]);
const after = rehydrate(before);
expect(after.accounts['acct-1']?.status).toBe('closed');
expect(after.accounts['acct-1']?.lastError).toBeUndefined();
});

it('leaves accounts already in `closed` untouched', () => {
const before = seedState([makeAccount({ status: 'closed' })]);
const after = rehydrate(before);
expect(after.accounts['acct-1']?.status).toBe('closed');
});

it('resets every account in the directory, not just the active one', () => {
const before = seedState([
makeAccount({ id: 'acct-slack', provider: 'slack', status: 'timeout' }),
makeAccount({ id: 'acct-discord', provider: 'discord', status: 'loading' }),
makeAccount({ id: 'acct-tg', provider: 'telegram', status: 'closed' }),
]);
const after = rehydrate(before);
expect(after.accounts['acct-slack']?.status).toBe('closed');
expect(after.accounts['acct-discord']?.status).toBe('closed');
expect(after.accounts['acct-tg']?.status).toBe('closed');
});

it('preserves the persisted account directory, order, and MRU pointer', () => {
const before = seedState([
makeAccount({ id: 'acct-slack', provider: 'slack', status: 'timeout', label: 'Work Slack' }),
makeAccount({ id: 'acct-discord', provider: 'discord', status: 'open' }),
]);
before.activeAccountId = 'acct-slack';
before.lastActiveAccountId = 'acct-discord';

const after = rehydrate(before);
expect(after.order).toEqual(['acct-slack', 'acct-discord']);
expect(after.activeAccountId).toBe('acct-slack');
expect(after.lastActiveAccountId).toBe('acct-discord');
expect(after.accounts['acct-slack']?.label).toBe('Work Slack');
expect(after.accounts['acct-slack']?.provider).toBe('slack');
expect(after.accounts['acct-discord']?.provider).toBe('discord');
});

it('ignores REHYDRATE actions for other persist keys', () => {
const before = seedState([makeAccount({ status: 'timeout' })]);
const after = rehydrate(before, 'notifications');
expect(after.accounts['acct-1']?.status).toBe('timeout');
});

it('is a no-op when no accounts are persisted', () => {
const before = seedState([]);
const after = rehydrate(before);
expect(after.accounts).toEqual({});
expect(after.order).toEqual([]);
});
});
42 changes: 42 additions & 0 deletions app/src/store/accountsSlice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import debug from 'debug';
import { REHYDRATE } from 'redux-persist';

import type {
Account,
Expand All @@ -9,6 +11,21 @@ import type {
} from '../types/accounts';
import { resetUserScopedState } from './resetActions';

const log = debug('accounts:rehydrate');

// Statuses that describe a *live* webview session, not durable account
// state. Persisting any of these across an app restart would mean the
// next session paints stale UI (a spinner for a webview that no longer
// exists, or — issue #1379 — a "taking longer than expected" overlay
// before the new session has even tried to load).
const TRANSIENT_ACCOUNT_STATUSES: ReadonlySet<AccountStatus> = new Set([
'pending',
'loading',
'timeout',
'open',
'error',
]);

const MAX_MESSAGES_PER_ACCOUNT = 200;
const MAX_LOG_LINES_PER_ACCOUNT = 100;

Expand Down Expand Up @@ -130,6 +147,31 @@ const accountsSlice = createSlice({
},
extraReducers: builder => {
builder.addCase(resetUserScopedState, () => initialState);
// Issue #1379 — every account's webview is destroyed when the app
// closes, so any non-`closed` status persisted from the previous
// session is stale. Reset transient statuses on rehydrate so the
// next session starts from a fresh load state instead of replaying
// last session's `timeout` / `loading` / `pending` overlay before
// the new webview spawn has even started.
builder.addCase(REHYDRATE, (state, action) => {
const rehydrateAction = action as {
type: typeof REHYDRATE;
key: string;
payload?: Partial<AccountsState>;
};
if (rehydrateAction.key !== 'accounts') return;
const reset: Array<{ id: string; previous: AccountStatus }> = [];
for (const acct of Object.values(state.accounts)) {
if (TRANSIENT_ACCOUNT_STATUSES.has(acct.status)) {
reset.push({ id: acct.id, previous: acct.status });
acct.status = 'closed';
acct.lastError = undefined;
}
}
if (reset.length > 0) {
log('reset %d transient account status(es) on rehydrate: %o', reset.length, reset);
}
});
},
});

Expand Down
Loading