fix(reaction): await toggle handler to prevent null payload on refresh

The toggle mutation was fire-and-forget, calling the handler without awaiting.
This detached the write from the request lifecycle — when a pod drained, the
write could be dropped before completion, causing the mutation to return null
and reactions to not survive a page refresh.

The handler manages slow work (rewards, notifications) internally and must be
awaited for those effects and the data write to complete before the response
is sent. Replace the fire-and-forget pattern with a direct await.

Add regression test confirming the mutation returns the handler result and
propagates handler errors to the caller.
This commit is contained in:
Luis Rojas
2026-08-31 15:30:53 -04:00
parent 44e0598b90
commit 1e810875c8
2 changed files with 74 additions and 7 deletions
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TokenScope } from '~/shared/constants/token-scope.constants';
import type * as ReactionController from '~/server/controllers/reaction.controller';
/**
* `reaction.toggle` must AWAIT and RETURN the handler. A prior "fire-and-forget" wiring
* (call the handler, return void) detached the toggle write from the request — the mutation
* resolved to `undefined` (a null payload over superjson) and the write could be dropped on
* pod drain, so a reaction did not survive a refresh. See civitai#868kyuk3w.
*/
const { toggleReactionHandler } = vi.hoisted(() => ({
toggleReactionHandler: vi.fn(async () => 'created' as const),
}));
vi.mock('~/server/controllers/reaction.controller', async (importOriginal) => ({
...(await importOriginal<typeof ReactionController>()),
toggleReactionHandler,
}));
import { reactionRouter } from '../reaction.router';
const user = {
id: 5,
isModerator: false,
tier: 'free',
username: 'reactor',
onboarding: 0x1f,
muted: false,
};
function caller() {
return reactionRouter.createCaller({
acceptableOrigin: true,
user,
apiKeyId: null,
tokenScope: TokenScope.Full,
req: { headers: {} } as never,
res: { setHeader: () => undefined } as never,
cache: { edgeTTL: 0 },
features: {} as never,
track: undefined,
} as never);
}
const input = { entityType: 'image', entityId: 1, reaction: 'Like' } as const;
beforeEach(() => {
vi.clearAllMocks();
});
describe('reaction.toggle — awaited, not fire-and-forget', () => {
it('returns the handler result rather than undefined', async () => {
const result = await caller().toggle(input);
// The regression returned `undefined` here (the reported null payload).
expect(result).toBe('created');
expect(toggleReactionHandler).toHaveBeenCalledTimes(1);
const arg = toggleReactionHandler.mock.calls[0]?.[0] as { input?: typeof input };
expect(arg?.input).toMatchObject({ entityType: 'image', entityId: 1, reaction: 'Like' });
});
it('propagates a handler rejection instead of swallowing it', async () => {
toggleReactionHandler.mockRejectedValueOnce(new Error('write failed'));
// Detaching the handler made a failed write invisible to the caller; awaiting surfaces it.
await expect(caller().toggle(input)).rejects.toThrow('write failed');
});
});
+4 -7
View File
@@ -2,7 +2,6 @@ import { toggleReactionHandler } from './../controllers/reaction.controller';
import { toggleReactionSchema, reactionRateLimits } from './../schema/reaction.schema';
import { router, guardedProcedure } from '~/server/trpc';
import { rateLimit } from '~/server/middleware.trpc';
import { handleLogError } from '~/server/utils/errorHandling';
import { TokenScope } from '~/shared/constants/token-scope.constants';
export const reactionRouter = router({
@@ -10,10 +9,8 @@ export const reactionRouter = router({
.meta({ requiredScope: TokenScope.SocialWrite })
.input(toggleReactionSchema)
.use(rateLimit(reactionRateLimits))
.mutation(({ ctx, input }) => {
// Fire-and-forget: frontend already does optimistic updates via Zustand
// and ignores the response value entirely (no onSuccess/onError callbacks).
// Auth + rate limit middleware have already run at this point.
toggleReactionHandler({ ctx, input }).catch(handleLogError);
}),
// Must stay awaited: the handler backgrounds its slow work (rewards, notifications)
// internally. Detaching the whole handler dropped the toggle write on pod drain and
// returned a null payload.
.mutation(toggleReactionHandler),
});