fix(channels): keep client-only state across a channel-list refresh (#2095) (#2096)

Fixes #2095.

`loadChannels()` replaced the `channels` array with the server snapshot
and carried nothing across, and `mergeUserChannels()` only ever ran from
`init()`. So any refresh destroyed every field that exists only in the
tab.

**No race needed for the worst of it.** Change the region filter or
toggle show-encrypted with a PSK channel open, and:

- the **My Channels** section disappears — `renderChannelList()` derives
it as `channels.filter(c => c.userAdded === true)`, and the server
returns neither `userAdded` nor a `user:`-prefixed hash
- every unread badge resets to 0
- the user's own labels vanish
- `reconcileSelectionAfterChannelRefresh()` cannot find the `user:*`
hash in the snapshot, so it nulls `selectedHash`, sets `messages = []`,
rewrites the URL to `#/channels` and replaces the open conversation with
"Choose a channel from the sidebar"

## What this does

`mergeClientChannelState(fresh, prev)` mirrors
`mergeWsAppendedIntoRest()`, which already does exactly this job for
`messages` (#1498). Same shape: pure, takes both arrays as parameters,
returns a fresh array, never aliases or mutates an input.

It carries `unread`, `userAdded` and `userLabel` by hash, and keeps
`lastActivityMs` / `lastSender` / `lastMessage` / `messageCount` when a
WebSocket batch landed while the request was in flight and is therefore
newer than the snapshot. Those four move together: a sender without its
message reads as a different message.

`mergeUserChannels()` now runs inside `loadChannels()`, before the
render and before the reconcile, so all three call sites get it instead
of `init()` alone.

## What it deliberately does not do

**It does not resurrect a channel the snapshot left out.** A
region-filter change legitimately narrows the list, so carrying
survivors over would defeat the filter, which is a worse bug than the
one being fixed. The helper only enriches rows already present in the
fresh snapshot.

That leaves half of finding 2 in the issue unfixed: a channel pushed by
the WebSocket handler during the initial in-flight window is still
dropped. That one self-heals on the channel's next packet, and the
reverted preview line is overwritten by the next WS batch. Fixing it
properly needs a way to tell "dropped because the snapshot is stale"
from "dropped because the filter excludes it", which is a larger change
than this.

## Tests

`tests/unit/test-issue-2095-channels-client-state.js`, 11 cases,
registered in `test-all.sh`.

Part 1 exercises the helper directly. Part 2 drives the real
`loadChannels()` through the existing `_channelsLoadChannelsForTest`
hook with a stubbed `api()`, which is what proves the helper is wired in
rather than merely defined.

**Verified by mutation.** With the wiring removed from `loadChannels()`
but the helper left in place, the three reproduction cases fail with
exactly the reported symptoms:

```
FAIL  a refresh keeps the My Channels rows
      My Channels lost on refresh (got ["public1"])
FAIL  a refresh keeps unread badges
      the unread badge reset to 0 on refresh   undefined !== 7
FAIL  a refresh does not close an open PSK conversation
      the open PSK channel was deselected      + null  - 'user:MyPSK'
```

The fourth case, "a refresh still drops a channel the server filtered
out", stays green throughout, so the fix cannot be defeating the region
filter.

Full frontend suite green: `sh test-all.sh` exits 0.

## Not done

**No browser validation.** This is frontend JS covered by unit tests
that call the production function through its own hook, but I did not
run Playwright against a server, so I am not claiming a browser check I
did not do.

`init()` still calls `mergeUserChannels()` and `renderChannelList()`
after `loadChannels()` resolves, which is now redundant and costs one
extra full sidebar render per page load. Left alone deliberately: it is
idempotent, and the comment there records the regression it was added to
fix (`test-channel-issue-1111-e2e.js`, case 2). Worth removing
separately with that e2e test watched.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-10-03 12:55:39 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3b415fb007
commit 8933e2c703
3 changed files with 363 additions and 5 deletions
@@ -0,0 +1,279 @@
/**
* #2095 — loadChannels() replaced the channel array and discarded every
* client-only field on it.
*
* Two consequences, one root cause. The array assignment at loadChannels()
* carries nothing across, and mergeUserChannels() only ran from init(), so a
* region-filter change or the show-encrypted toggle destroyed the My Channels
* section, every unread badge, and the user's own labels — and evicted the
* selected channel if it was a PSK row, closing the open conversation.
*
* Part 1 tests the pure helper directly. Part 2 drives loadChannels() through
* its test hook with a stubbed api(), which is what proves the helper is
* actually wired in rather than merely present.
*
* Sandbox pattern copied from test-channels-merge-1498-unit.js.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
const noop = () => {};
const fakeEl = {
addEventListener: noop, removeEventListener: noop, querySelector: () => fakeEl,
querySelectorAll: () => [], classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null,
textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0,
};
const doc = {
readyState: 'complete', createElement: () => ({ ...fakeEl }), head: fakeEl, body: fakeEl,
getElementById: () => null, querySelector: () => null, querySelectorAll: () => [],
addEventListener: noop, removeEventListener: noop,
};
// Stored PSK keys the user added. mergeUserChannels() reads these, so they are
// what makes the My Channels section exist at all.
let storedKeys = {};
let storedLabels = {};
const ctx = {
window: { addEventListener: noop, removeEventListener: noop },
document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise,
Response: function () {}, Error, Number, String, Boolean, isNaN, parseInt, parseFloat,
setTimeout, clearTimeout, setInterval, clearInterval,
history: { replaceState: noop, pushState: noop },
location: { hash: '', href: '', pathname: '/' },
navigator: { userAgent: 'node' },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
RegionFilter: { getRegionParam: () => '', onChange: () => noop },
CLIENT_TTL: { channels: 15000 },
ChannelDecrypt: {
getStoredKeys: () => storedKeys,
getLabels: () => storedLabels,
},
truncate: (s) => s,
formatHashHex: (h) => String(h),
channelDisplayName: (c) => (c && (c.userLabel || c.name)) || '',
escapeHtml: (s) => String(s),
getSenderColor: () => '#000',
registerPage: noop,
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
};
// The server snapshot loadChannels() will receive. Reassigned per test.
let apiChannels = [];
ctx.api = () => Promise.resolve({ channels: apiChannels.map(c => ({ ...c })) });
vm.createContext(ctx);
try {
vm.runInContext(fs.readFileSync('public/channels.js', 'utf8'), ctx);
} catch (e) {
// The IIFE may throw on missing DOM further down; the hooks we need are
// exported before that point.
}
const merge = ctx.window._channelsMergeClientChannelStateForTest;
const loadChannels = ctx.window._channelsLoadChannelsForTest;
const setState = ctx.window._channelsSetStateForTest;
const getState = ctx.window._channelsGetStateForTest;
for (const [name, fn] of Object.entries({ merge, loadChannels, setState, getState })) {
if (typeof fn !== 'function') {
console.error(`FATAL: ${name} not exported by channels.js`);
process.exit(2);
}
}
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); console.log(` PASS ${name}`); passed++; }
catch (e) { console.log(` FAIL ${name}\n ${e.message}`); failed++; }
}
async function atest(name, fn) {
try { await fn(); console.log(` PASS ${name}`); passed++; }
catch (e) { console.log(` FAIL ${name}\n ${e.message}`); failed++; }
}
console.log('\n=== #2095 part 1: mergeClientChannelState() ===');
test('carries unread across the replacement', () => {
const prev = [{ hash: 'a', unread: 3 }, { hash: 'b', unread: 0 }];
const fresh = [{ hash: 'a' }, { hash: 'b' }];
const out = merge(fresh, prev);
assert.strictEqual(out[0].unread, 3, 'unread on a was dropped');
assert.strictEqual(out[1].unread, 0);
});
test('keeps WS-fresher activity fields when the snapshot is older', () => {
// The WS handler already applied a newer message before the REST snapshot
// landed. Reverting to the snapshot is the flicker in #2095 finding 2.
const prev = [{ hash: 'a', lastActivityMs: 2000, lastSender: 'ON8AR', lastMessage: 'new', messageCount: 8 }];
const fresh = [{ hash: 'a', lastActivityMs: 1000, lastSender: 'OLD', lastMessage: 'old', messageCount: 7 }];
const out = merge(fresh, prev);
assert.strictEqual(out[0].lastActivityMs, 2000);
assert.strictEqual(out[0].lastSender, 'ON8AR');
assert.strictEqual(out[0].lastMessage, 'new');
assert.strictEqual(out[0].messageCount, 8);
});
test('the server wins when its snapshot is the newer one', () => {
const prev = [{ hash: 'a', lastActivityMs: 1000, lastSender: 'STALE', lastMessage: 'stale', messageCount: 2 }];
const fresh = [{ hash: 'a', lastActivityMs: 5000, lastSender: 'FRESH', lastMessage: 'fresh', messageCount: 9 }];
const out = merge(fresh, prev);
assert.strictEqual(out[0].lastActivityMs, 5000);
assert.strictEqual(out[0].lastSender, 'FRESH');
assert.strictEqual(out[0].messageCount, 9);
});
test('does not resurrect a channel the snapshot left out', () => {
// A region-filter change legitimately narrows the list. Carrying survivors
// over would defeat the filter, which is why this helper only enriches rows
// that are already in the fresh snapshot.
const prev = [{ hash: 'a', unread: 1 }, { hash: 'gone', unread: 9 }];
const fresh = [{ hash: 'a' }];
const out = merge(fresh, prev);
assert.strictEqual(out.length, 1, 'a filtered-out channel came back');
assert.strictEqual(out[0].hash, 'a');
});
test('never aliases or mutates its inputs', () => {
const prev = [{ hash: 'a', unread: 4 }];
const fresh = [{ hash: 'a' }];
const out = merge(fresh, prev);
assert.notStrictEqual(out, fresh, 'returned the input array itself');
out[0].unread = 99;
assert.strictEqual(prev[0].unread, 4, 'mutating the result reached prev');
});
test('tolerates empty and missing inputs', () => {
// Not deepStrictEqual: an array built inside the vm realm has a different
// Array.prototype, which that assertion compares and rejects.
assert.strictEqual(merge([], []).length, 0);
assert.strictEqual(merge(null, null).length, 0);
assert.strictEqual(merge([{ hash: 'a' }], null).length, 1);
assert.strictEqual(merge([{ hash: 'a' }], undefined).length, 1);
});
test('ignores rows with no hash rather than matching them together', () => {
const prev = [{ unread: 5 }, { hash: '', unread: 6 }];
const fresh = [{ hash: 'a' }, { hash: '' }];
const out = merge(fresh, prev);
assert.ok(!out[0].unread, 'a hashless prev row leaked onto a real channel');
});
console.log('\n=== #2095 part 2: loadChannels() keeps client state ===');
(async function () {
await atest('a refresh keeps the My Channels rows', async () => {
// The bug: the user has a PSK channel, the server does not know it, and any
// refresh replaced the array with the server snapshot, so the row vanished.
storedKeys = { 'MyPSK': 'deadbeef' };
storedLabels = { 'MyPSK': 'Mijn kanaal' };
apiChannels = [{ hash: 'public1', name: 'public1', lastActivity: null }];
setState({ channels: [], messages: [], selectedHash: null });
await loadChannels(true);
const after = getState().channels;
const mine = after.filter(c => c.userAdded === true);
assert.strictEqual(mine.length, 1, `My Channels lost on refresh (got ${JSON.stringify(after.map(c => c.hash))})`);
assert.strictEqual(mine[0].hash, 'user:MyPSK');
assert.strictEqual(mine[0].userLabel, 'Mijn kanaal', 'the user label was dropped');
});
await atest('a refresh keeps unread badges', async () => {
storedKeys = {};
storedLabels = {};
apiChannels = [{ hash: 'public1', name: 'public1', lastActivity: null }];
setState({ channels: [{ hash: 'public1', name: 'public1', unread: 7 }], messages: [], selectedHash: null });
await loadChannels(true);
const ch = getState().channels.find(c => c.hash === 'public1');
assert.ok(ch, 'the channel disappeared');
assert.strictEqual(ch.unread, 7, 'the unread badge reset to 0 on refresh');
});
await atest('a refresh does not close an open PSK conversation', async () => {
// The worst of the two: reconcileSelectionAfterChannelRefresh() did not
// find the user:* hash in the server snapshot, so it nulled the selection,
// emptied messages and rewrote the URL.
storedKeys = { 'MyPSK': 'deadbeef' };
storedLabels = {};
apiChannels = [{ hash: 'public1', name: 'public1', lastActivity: null }];
setState({
channels: [{ hash: 'user:MyPSK', name: 'MyPSK', userAdded: true }],
messages: [{ text: 'hello' }],
selectedHash: 'user:MyPSK',
});
await loadChannels(true);
const st = getState();
assert.strictEqual(st.selectedHash, 'user:MyPSK', 'the open PSK channel was deselected');
assert.strictEqual(st.messages.length, 1, 'the open conversation was emptied');
});
await atest('a refresh keeps unread and activity on a PSK-only row', async () => {
// A user:* row is never in the server snapshot, so mergeClientChannelState
// has nothing to enrich; mergeUserChannels() rebuilds it from storage and
// must carry what the tab counted, or a live PSK message's badge and
// preview reset on every region change.
storedKeys = { 'MyPSK': 'deadbeef' };
storedLabels = {};
apiChannels = [{ hash: 'public1', name: 'public1', lastActivity: null }];
setState({
channels: [{
hash: 'user:MyPSK', name: 'MyPSK', userAdded: true, encrypted: true, unread: 4,
lastActivityMs: 9000, lastSender: 'ON8AR', lastMessage: 'decrypted text', messageCount: 3,
}],
messages: [], selectedHash: null,
});
await loadChannels(true);
const ch = getState().channels.find(c => c.hash === 'user:MyPSK');
assert.ok(ch, 'the PSK row disappeared');
assert.strictEqual(ch.unread, 4, 'the PSK unread badge reset on refresh');
assert.strictEqual(ch.lastActivityMs, 9000);
assert.strictEqual(ch.lastSender, 'ON8AR');
assert.strictEqual(ch.lastMessage, 'decrypted text', 'the PSK preview went back to the placeholder');
assert.strictEqual(ch.messageCount, 3);
});
await atest('a refresh does not keep a PSK row whose key was removed', async () => {
// Storage stays the source of truth for which PSK rows exist.
storedKeys = {};
storedLabels = {};
apiChannels = [{ hash: 'public1', name: 'public1', lastActivity: null }];
setState({
channels: [{ hash: 'user:Gone', name: 'Gone', userAdded: true, unread: 2 }],
messages: [], selectedHash: null,
});
await loadChannels(true);
const hashes = getState().channels.map(c => c.hash);
assert.ok(!hashes.includes('user:Gone'), `a removed key came back: ${JSON.stringify(hashes)}`);
});
await atest('a refresh still drops a channel the server filtered out', async () => {
// The fix must not defeat the region filter.
storedKeys = {};
storedLabels = {};
apiChannels = [{ hash: 'in-region', name: 'in-region', lastActivity: null }];
setState({
channels: [{ hash: 'in-region', name: 'in-region' }, { hash: 'out-of-region', name: 'out-of-region' }],
messages: [], selectedHash: null,
});
await loadChannels(true);
const hashes = getState().channels.map(c => c.hash);
assert.ok(!hashes.includes('out-of-region'), `the filter was defeated: ${JSON.stringify(hashes)}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();