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
9 changes: 7 additions & 2 deletions ui/src/message/MessagesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ export class MessagesStore {

@action
public removeSingle = async (message: IMessage) => {
if (!this.pendingDeletes.has(message.id)) {
// Claim the pending delete before awaiting the request. The entry is
// removed (and the undo snackbar closed) up front so that a second
// invocation — e.g. the browser firing both `pagehide` and
// `beforeunload` — can no longer pass the guard while the first
// request is still in flight. Doing this after the `await` used to
// send two DELETEs for the same id, the second one 404ing.
if (!this.cancelPendingDelete(message)) {
return;
}

Expand All @@ -131,7 +137,6 @@ export class MessagesStore {
if (this.exists(message.appid)) {
this.removeFromList(this.state[message.appid].messages, message);
}
this.cancelPendingDelete(message);
};

public sendMessage = async (
Expand Down
39 changes: 39 additions & 0 deletions ui/src/tests/jsdom-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Minimal DOM shim so unit tests can import UI source modules (which touch
// `window`/`document`) without pulling in a full jsdom dependency.
if (typeof (globalThis as {window?: unknown}).window === 'undefined') {
const win = {
config: {},
location: {
href: 'http://localhost/',
protocol: 'http:',
host: 'localhost',
origin: 'http://localhost',
},
navigator: {userAgent: 'vitest'},
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => true,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
localStorage: {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
},
};
const doc = {
createElement: () => ({
style: {},
setAttribute: () => undefined,
appendChild: () => undefined,
}),
addEventListener: () => undefined,
removeEventListener: () => undefined,
querySelector: () => null,
body: {appendChild: () => undefined, removeChild: () => undefined},
};
(globalThis as {window: unknown}).window = win;
(globalThis as {document: unknown}).document = doc;
}
87 changes: 87 additions & 0 deletions ui/src/tests/pendingDelete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Regression test for gotify/server#1068:
// "WebUI sends duplicate DELETE /message/{id} when leaving the page with
// pending deletes".
//
// Deleting a message in the WebUI opens an undo snackbar (`Messages.tsx`
// `deleteMessage` -> `enqueueSnackbar` + `addPendingDelete`). While that
// snackbar is still open, leaving the page must issue exactly ONE DELETE per
// message id.
//
// `reactions.ts:8-9` registers `messagesStore.executePendingDeletes` on BOTH
// `pagehide` and `beforeunload`, and browsers fire both when a page is left.
// `MessagesStore.removeSingle` checked `pendingDeletes.has(id)` *before*
// awaiting the request and only removed the entry *after* the await resolved,
// so both handlers passed the guard and each sent a DELETE; the second one
// 404s with "message does not exist".
import axios from 'axios';
import {afterEach, describe, expect, it, vi} from 'vitest';

vi.mock('notistack', () => ({closeSnackbar: () => undefined}));

const {MessagesStore} = await import('../message/MessagesStore');
import type {IApplication, IMessage} from '../types';

const deleteUrls = (): string[] => {
const calls = (axios.delete as unknown as {mock: {calls: unknown[][]}}).mock.calls;
return calls.map((call) => String(call[0]));
};

const app = (id: number): IApplication => ({
id,
name: `app-${id}`,
description: '',
image: '',
internal: false,
token: `token-${id}`,
sortKey: '',
defaultPriority: 0,
lastUsed: '2026-10-14T00:00:00Z',
createdAt: '2026-10-14T00:00:00Z',
});

const message = (id: number): IMessage => ({
id,
appid: 1,
message: `body-${id}`,
title: `title-${id}`,
priority: 1,
date: '2026-10-14T00:00:00Z',
});

const newStore = () =>
new MessagesStore({getItems: () => [app(1)], getByID: () => app(1)} as never, () => undefined);

describe('pending deletes on page leave (#1068)', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('sends exactly one DELETE per id when leaving the page with a pending undo', async () => {
vi.spyOn(axios, 'delete').mockResolvedValue({status: 200} as never);

const store = newStore();
store.addPendingDelete({key: 'undo' as never, message: message(150242)});

// A browser fires pagehide and beforeunload while the previous async
// handler is still in flight, so both run concurrently.
await Promise.all([store.executePendingDeletes(), store.executePendingDeletes()]);

expect(deleteUrls()).toEqual(['unsetmessage/150242']);
expect(axios.delete).toHaveBeenCalledTimes(1);
});

it('does not re-send a DELETE for a message already being deleted', async () => {
vi.spyOn(axios, 'delete').mockResolvedValue({status: 200} as never);

const store = newStore();
store.addPendingDelete({key: 'undo' as never, message: message(7)});

// The snackbar's `onExited` (Messages.tsx) and a page-leave event can
// both fire for the same message.
const leaving = store.executePendingDeletes();
const expired = store.removeSingle(message(7));
await Promise.all([leaving, expired]);

expect(axios.delete).toHaveBeenCalledTimes(1);
});
});
20 changes: 19 additions & 1 deletion ui/vitest.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import {defineConfig} from 'vitest/config';
import babel from '@rolldown/plugin-babel';

const timeout = process.env.CI === 'true' ? 60000 : 30000;
const timeout = globalThis.process.env.CI === 'true' ? 60000 : 30000;

// The UI source uses TC39 stage-3 decorators (`@observable`/`@action` and the
// `accessor` keyword). The production bundle compiles them via the babel
// plugin configured in vite.config.ts, but vitest's transform pipeline needs
// the same plugin in order to import those modules from tests.
export default defineConfig({
plugins: [
babel({
presets: [
{
preset: () => ({
plugins: [['@babel/plugin-proposal-decorators', {version: '2022-03'}]],
}),
rolldown: {filter: {code: '@'}},
},
],
}),
],
test: {
setupFiles: ['./src/tests/jsdom-shim.ts'],
testTimeout: timeout,
hookTimeout: timeout,
},
Expand Down
Loading