The file <input> had name="img", so new FormData(form) already included
the original uncompressed file under "img". handleSubmit then appended
the compressed version with the same key, and FormData.append doesn't
overwrite — every save uploaded two files to S3 (the second clobbering
the first under the same generated filename).
The submit button also only checked the full-size img state, not
smallImg. Both Compressor instances run in parallel, so Save could fire
before the thumbnail finished compressing — saving art with no
thumbnail, and (under slow MinIO on CI) making the e2e test flaky as
two serial S3 uploads pushed past the 5s toHaveURL timeout.
Non-participants (e.g. tournament organizers) viewing a tournament
match chat could see the chat auto-open correctly, but clicking the
back arrow to the room list left the chat invisible — the only way
back was to leave the route and return.
The match chat appears in chatContext.rooms via the SUBSCRIBE
response, which (unlike the initial-payload path that participants
go through) does not check room expiry on the skalop side. So an
organizer viewing an old match ends up with an expired room in
their rooms list. ChatView already handles this gracefully with a
read-only banner, but RoomList was filtering on
expiresAt > Date.now() and dropping it.
Cleanup in useChatRouteSync removes the room from chatContext.rooms
on navigation, so exempting the current route's chatCode from the
expiry filter only affects the page that subscribed to it.
upsertWidgets ran an unconditional insertInto("UserWidget").values([])
when the submitted widgets array was empty. Kysely emits
`insert into "UserWidget" () values ()` for an empty values array,
which SQLite rejects with `near ")": syntax error`, rolling back the
preceding delete in the same transaction. Saving an empty widget list
was therefore impossible. Skip the insert when the array is empty.
The /q (front page), /q/preparing, and /q/match/:id action handlers
were not wrapping their switch in try/catch the way /q/looking does.
When SQGroupRepository.createGroup, addMember, or createGroupFromPrevious
tripped the integrity check (member already in another non-INACTIVE
group, group too large, etc.) the SendouQError bubbled up unhandled
and React Router responded with 500 instead of letting the loaders
re-run with fresh state.
These errors are expected race conditions, e.g. a double-submit
where the second JOIN_QUEUE arrives after the first already created
the group. Returning null lets the client see the actual current
state instead of an error page, matching the behavior already in
q.looking.server.ts.
The SPECIAL branch of validatedAnyWeaponFromSearchParams only checked
nonDamagingSpecialWeaponIds membership, not whether the parsed id was
actually a valid SpecialWeaponId. A malformed URL like
?weapon=SPECIAL_9ap=0... made Number() return NaN, which fell through
to exampleMainWeaponIdWithSpecialWeaponId and hit assertUnreachable.
Error: Invariant failed: Empty placements not supported
I don't think it's possible for this to happen normally
but this fix provides more defense just in case
Regression introduced in b26f4ab2 (Migrate setHistoryByTeamId to
Kysely): the old raw SQL inner-joined through
TournamentMatchGameResultParticipant, which implicitly dropped
completed matches that had no game results (forfeits/walkovers).
The Kysely version fetches players via a jsonArrayFrom subquery,
so those matches leaked through and rendered as 0-0 on the team
page.