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
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,33 @@ describe('Feed watch and unwatch', () => {
expect(activity['feed']?.currentState.watch).toBe(true);
});

it(`stop watching prevents the feed from being refetched on reconnect`, async () => {
// a dedicated feed: the reconnect test above leaves an unawaited
// getOrCreate in flight on the shared one
const ownFeed = client.feed(feedGroup, crypto.randomUUID());
await ownFeed.getOrCreate({ watch: true });
expect(ownFeed.currentState.watch).toBe(true);

await ownFeed.stopWatching();
expect(ownFeed.currentState.watch).toBe(false);

const spy = vi.spyOn(ownFeed, 'getOrCreate');

client['eventDispatcher'].dispatch({
type: 'connection.changed',
online: false,
});
client['eventDispatcher'].dispatch({
type: 'connection.changed',
online: true,
});

expect(spy).not.toHaveBeenCalled();

spy.mockRestore();
await ownFeed.delete();
});

afterAll(async () => {
await feed.delete();
await client.disconnectUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FeedsClient } from '../../../feeds-client';
import { Feed } from '../../feed';
import { generateFeedResponse } from '../../../test-utils';
import { handleWatchStarted } from './handle-watch-started';

describe('handleWatchStarted', () => {
let feed: Feed;

beforeEach(() => {
const client = new FeedsClient('mock-api-key');
const feedResponse = generateFeedResponse({ id: 'main', group_id: 'user' });
feed = new Feed(
client,
feedResponse.group_id,
feedResponse.id,
feedResponse,
);
});

it('sets watch and leaves the stored config untouched by default', () => {
const config = { watch: false, limit: 20 };
feed.state.partialNext({ last_get_or_create_request_config: config });

handleWatchStarted.call(feed);

expect(feed.currentState.watch).toBe(true);
expect(feed.currentState.last_get_or_create_request_config).toBe(config);
});

it('restores the watch intent with setWatchIntent', () => {
const config = { watch: false, limit: 20 };
feed.state.partialNext({ last_get_or_create_request_config: config });

handleWatchStarted.call(feed, { setWatchIntent: true });

expect(feed.currentState.watch).toBe(true);
expect(feed.currentState.last_get_or_create_request_config).toEqual({
watch: true,
limit: 20,
});
expect(feed.currentState.last_get_or_create_request_config).not.toBe(
config,
);
expect(config.watch).toBe(false);
});

it('does not invent a config when none was stored', () => {
handleWatchStarted.call(feed, { setWatchIntent: true });

expect(feed.currentState.watch).toBe(true);
expect(feed.currentState.last_get_or_create_request_config).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import type { Feed } from '../../feed';

export function handleWatchStarted(this: Feed) {
this.state.partialNext({ watch: true });
export function handleWatchStarted(
this: Feed,
{
setWatchIntent = false,
}: {
/**
* Also restores the watch intent in `last_get_or_create_request_config`, so a
* feed that was explicitly stop-watched and is now watched again is re-fetched
* by `synchronize()` on the next reconnect. Only meaningful when a config is
* already stored — feeds watched solely through `queryFeeds` have none.
*/
setWatchIntent?: boolean;
} = {},
) {
if (!setWatchIntent) {
this.state.partialNext({ watch: true });
return;
}

this.state.next((currentState) => ({
...currentState,
watch: true,
last_get_or_create_request_config:
currentState.last_get_or_create_request_config && {
...currentState.last_get_or_create_request_config,
watch: true,
},
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FeedsClient } from '../../../feeds-client';
import { Feed } from '../../feed';
import { generateFeedResponse } from '../../../test-utils';
import { handleWatchStopped } from './handle-watch-stopped';

describe('handleWatchStopped', () => {
let feed: Feed;

beforeEach(() => {
const client = new FeedsClient('mock-api-key');
const feedResponse = generateFeedResponse({ id: 'main', group_id: 'user' });
feed = new Feed(
client,
feedResponse.group_id,
feedResponse.id,
feedResponse,
true,
);
});

describe('without clearWatchIntent (connection lost)', () => {
it('clears watch but keeps the watch intent so reconnect can recover', () => {
const config = { watch: true, limit: 20 };
feed.state.partialNext({ last_get_or_create_request_config: config });

handleWatchStopped.call(feed);

expect(feed.currentState.watch).toBe(false);
// same reference - the stored config is left completely untouched
expect(feed.currentState.last_get_or_create_request_config).toBe(config);
});
});

describe('with clearWatchIntent (explicit stopWatching)', () => {
it('clears watch and the stored watch intent', () => {
feed.state.partialNext({
last_get_or_create_request_config: { watch: true, limit: 20 },
});

handleWatchStopped.call(feed, { clearWatchIntent: true });

expect(feed.currentState.watch).toBe(false);
expect(feed.currentState.last_get_or_create_request_config).toEqual({
watch: false,
limit: 20,
});
});

it('produces a new last_get_or_create_request_config reference', () => {
const config = { watch: true, limit: 20 };
feed.state.partialNext({ last_get_or_create_request_config: config });

handleWatchStopped.call(feed, { clearWatchIntent: true });

expect(feed.currentState.last_get_or_create_request_config).not.toBe(
config,
);
// the caller's object must not be mutated in place
expect(config.watch).toBe(true);
});

it('leaves last_get_or_create_request_config undefined if it was never set', () => {
expect(
feed.currentState.last_get_or_create_request_config,
).toBeUndefined();

expect(() =>
handleWatchStopped.call(feed, { clearWatchIntent: true }),
).not.toThrow();

expect(feed.currentState.watch).toBe(false);
expect(
feed.currentState.last_get_or_create_request_config,
).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import type { Feed } from '../../feed';

export function handleWatchStopped(this: Feed) {
this.state.partialNext({ watch: false });
export function handleWatchStopped(
this: Feed,
{
clearWatchIntent = false,
}: {
/**
* Also clears the watch intent stored in `last_get_or_create_request_config`,
* so the feed is not re-fetched (and silently re-watched) by `synchronize()`
* on the next reconnect. Pass `true` only when the caller explicitly stopped
* watching — a lost connection must keep the intent so recovery can restore it.
*/
clearWatchIntent?: boolean;
} = {},
) {
if (!clearWatchIntent) {
this.state.partialNext({ watch: false });
return;
}

this.state.next((currentState) => ({
...currentState,
watch: false,
last_get_or_create_request_config:
currentState.last_get_or_create_request_config && {
...currentState.last_get_or_create_request_config,
watch: false,
},
}));
}
15 changes: 15 additions & 0 deletions packages/feeds-client/src/feed/feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ActivityResponse, GetOrCreateFeedResponse } from '../gen/models';
import { generateActivityResponse, generateFeedResponse } from '../test-utils';
import { clearQueuedFeeds } from '../utils/throttling';
import { StreamApiError, type StreamResponse } from '../common/types';
import { handleWatchStopped } from './event-handlers/watch/handle-watch-stopped';

describe('Feed derived state updates', () => {
let feed: Feed;
Expand Down Expand Up @@ -728,6 +729,20 @@ describe('synchronize retry', () => {
expect(getOrCreateSpy).not.toHaveBeenCalled();
});

it('should not synchronize after the watch intent is cleared', async () => {
feed.state.partialNext({
watch: true,
last_get_or_create_request_config: { watch: true, limit: 20 },
});

// what an explicit feed.stopWatching() does to the feed's state
handleWatchStopped.call(feed, { clearWatchIntent: true });

await feed.synchronize();

expect(getOrCreateSpy).not.toHaveBeenCalled();
});

it('should synchronize and retry on failure', async () => {
const mockResponse: StreamResponse<GetOrCreateFeedResponse> = {
activities: [],
Expand Down
Loading
Loading