Enable RR8 future flags: v8_trailingSlashAwareDataRequests & v8_viteEnvironmentApi

This commit is contained in:
Kalle
2026-06-12 20:52:02 +03:00
parent 89c7d566fc
commit 1859815366
29 changed files with 68 additions and 77 deletions

View File

@@ -20,11 +20,11 @@ import { userArtPage } from "~/utils/urls";
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
import { editArtSchema, newArtSchema } from "../art-schemas.server";
export const action: ActionFunction = async ({ request }) => {
export const action: ActionFunction = async ({ request, url }) => {
const user = requireUser();
requireRole("ARTIST");
const searchParams = new URL(request.url).searchParams;
const searchParams = url.searchParams;
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
// updating logic

View File

@@ -3,12 +3,10 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as ArtRepository from "../ArtRepository.server";
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
const artIdRaw = new URL(request.url).searchParams.get(
NEW_ART_EXISTING_SEARCH_PARAM_KEY,
);
const artIdRaw = url.searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
if (!artIdRaw) return { art: null, tags: await ArtRepository.findAllTags() };
const artId = Number(artIdRaw);

View File

@@ -4,7 +4,7 @@ import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import * as ArtRepository from "../ArtRepository.server";
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const cachedArts = await cachified({
key: "arts",
cache,
@@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
},
});
const filteredTagName = new URL(request.url).searchParams.get(
const filteredTagName = url.searchParams.get(
FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
);

View File

@@ -23,8 +23,7 @@ import type { AuthErrorCode } from "./errors";
import { authSessionStorage } from "./session.server";
import { getUser } from "./user.server";
export const callbackLoader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
export const callbackLoader: LoaderFunction = async ({ request, url }) => {
if (url.searchParams.get("error") === "access_denied") {
// The user denied the authentication request
// https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/
@@ -77,7 +76,7 @@ export const logInAction: ActionFunction = async ({ request }) => {
return await authenticator.authenticate("discord", request);
};
export const impersonateAction: ActionFunction = async ({ request }) => {
export const impersonateAction: ActionFunction = async ({ request, url }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = requireUser();
if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) {
@@ -85,7 +84,6 @@ export const impersonateAction: ActionFunction = async ({ request }) => {
}
if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
const url = new URL(request.url);
const targetId = Number(url.searchParams.get("id"));
if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
throw new Response("Forbidden", { status: 403 });
@@ -99,7 +97,6 @@ export const impersonateAction: ActionFunction = async ({ request }) => {
const realUserId = session.get(SESSION_KEY);
const url = new URL(request.url);
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));

View File

@@ -26,6 +26,7 @@ export function getUserContext(): UserContext {
export async function getUserFromRequest(
request: Request,
url: URL,
): Promise<AuthenticatedUser | undefined> {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
@@ -37,7 +38,6 @@ export async function getUserFromRequest(
if (!userId) return undefined;
if (userIsBanned(userId)) {
const url = new URL(request.url);
const isExemptPath =
url.pathname === SUSPENDED_PAGE ||
// needed for ban E2E tests

View File

@@ -5,6 +5,7 @@ import {
type MiddlewareArgs = {
request: Request;
url: URL;
context: unknown;
};
@@ -13,8 +14,8 @@ type MiddlewareFn = (
next: () => Promise<Response>,
) => Promise<Response>;
export const userMiddleware: MiddlewareFn = async ({ request }, next) => {
const user = await getUserFromRequest(request);
export const userMiddleware: MiddlewareFn = async ({ request, url }, next) => {
const user = await getUserFromRequest(request, url);
return userAsyncLocalStorage.run({ user }, () => next());
};

View File

@@ -15,7 +15,7 @@ import {
} from "../builds-schemas";
import { filterBuilds } from "../core/filter.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export const loader = async ({ request, params, url }: LoaderFunctionArgs) => {
const user = getUser();
const t = await i18next.getFixedT(request, ["weapons", "common"], {
lng: "en",
@@ -26,7 +26,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const limit = buildsLimitSearchParam.parse(url.searchParams.get("limit"));
const weaponName = t(`weapons:MAIN_${weaponId}`);

View File

@@ -11,12 +11,10 @@ import { requireRole } from "~/modules/permissions/guards.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { canEditCalendarEvent } from "../calendar-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
requireRole("CALENDAR_EVENT_ADDER");
const url = new URL(request.url);
const eventWithTournament = async (key: string) => {
const eventId = Number(url.searchParams.get(key));
const event = Number.isNaN(eventId)

View File

@@ -2,10 +2,9 @@ import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
requireUser();
const url = new URL(request.url);
const idsParam = url.searchParams.get("ids");
if (!idsParam) {

View File

@@ -22,14 +22,10 @@ import {
WEAPON_LEADERBOARD_MAX_SIZE,
} from "../leaderboards-constants";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = getUser();
const unvalidatedType = new URL(request.url).searchParams.get(
TYPE_SEARCH_PARAM_KEY,
);
const unvalidatedSeason = new URL(request.url).searchParams.get(
SEASON_SEARCH_PARAM_KEY,
);
const unvalidatedType = url.searchParams.get(TYPE_SEARCH_PARAM_KEY);
const unvalidatedSeason = url.searchParams.get(SEASON_SEARCH_PARAM_KEY);
const type =
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??

View File

@@ -20,7 +20,7 @@ import { frontPageSchema } from "../q-schemas.server";
import { userCanJoinQueueAt } from "../q-utils";
import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
export const action: ActionFunction = async ({ request }) => {
export const action: ActionFunction = async ({ request, url }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
@@ -56,9 +56,7 @@ export const action: ActionFunction = async ({ request }) => {
case "JOIN_TEAM": {
await validateCanJoinQ(user);
const code = new URL(request.url).searchParams.get(
JOIN_CODE_SEARCH_PARAM_KEY,
);
const code = url.searchParams.get(JOIN_CODE_SEARCH_PARAM_KEY);
const groupInvitedTo =
code && user ? SendouQ.findGroupByInviteCode(code) : undefined;

View File

@@ -7,11 +7,11 @@ import { SendouQ } from "../core/SendouQ.server";
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
const isPreview =
new URL(request.url).searchParams.get("preview") === "true" &&
url.searchParams.get("preview") === "true" &&
user.roles.includes("SUPPORTER");
const privateNotes = await PrivateUserNoteRepository.ownNotes(

View File

@@ -6,12 +6,10 @@ import { SendouQ } from "../core/SendouQ.server";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
import { sqRedirectIfNeeded } from "../q-utils.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = getUser();
const code = new URL(request.url).searchParams.get(
JOIN_CODE_SEARCH_PARAM_KEY,
);
const code = url.searchParams.get(JOIN_CODE_SEARCH_PARAM_KEY);
const ownGroup = user ? SendouQ.findOwnGroup(user.id) : undefined;

View File

@@ -7,7 +7,7 @@ import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { teamParamsSchema } from "../team-schemas.server";
export const action: ActionFunction = async ({ request, params }) => {
export const action: ActionFunction = async ({ params, url }) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
@@ -17,7 +17,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}),
);
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const inviteCode = url.searchParams.get("code") ?? "";
const realInviteCode = team.inviteCode!;
errorToastIfFalsy(

View File

@@ -9,7 +9,7 @@ import { TEAM } from "../team-constants";
import { teamParamsSchema } from "../team-schemas.server";
import { isTeamFull, isTeamMember } from "../team-utils";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
const user = requireUser();
const { customUrl } = teamParamsSchema.parse(params);
@@ -19,7 +19,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}),
);
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const inviteCode = url.searchParams.get("code") ?? "";
const realInviteCode = team.inviteCode!;
const teamCount = (await TeamRepository.teamsByMemberUserId(user.id)).length;

View File

@@ -3,11 +3,10 @@ import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import * as XRankPlacementRepository from "../XRankPlacementRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const availableMonthYears = await XRankPlacementRepository.monthYears();
const { month: latestMonth, year: latestYear } = availableMonthYears[0];
const url = new URL(request.url);
const mode = (() => {
const mode = url.searchParams.get("mode");
if (rankedModesShort.includes(mode as any)) {

View File

@@ -19,7 +19,7 @@ const auditSearchParamsSchema = z.object({
auditTeam: z.coerce.number().int().optional().catch(undefined),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export const loader = async ({ request, params, url }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
@@ -50,7 +50,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const pagesCount = Math.max(1, Math.ceil(totalCount / AUDIT_LOG_PAGE_SIZE));
redirectIfPageOutOfBounds({ request, page, pagesCount });
redirectIfPageOutOfBounds({ url, page, pagesCount });
return {
auditLog: {

View File

@@ -23,13 +23,12 @@ import {
requireSendouQParticipationIfNeeded,
} from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
export const action: ActionFunction = async ({ params, url }) => {
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const user = requireUser();
const url = new URL(request.url);
const inviteCode = url.searchParams.get("code");
invariant(inviteCode, "code is missing");

View File

@@ -1,8 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
export const loader = async ({ url }: LoaderFunctionArgs) => {
const inviteCode = url.searchParams.get("code");
const team = inviteCode

View File

@@ -15,9 +15,8 @@ const newBuildLoaderParamsSchema = z.object({
buildId: z.preprocess(actualNumber, id),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
const url = new URL(request.url);
const params = newBuildLoaderParamsSchema.safeParse(
Object.fromEntries(url.searchParams),

View File

@@ -15,7 +15,7 @@ import { userResultsPageSearchParamsSchema } from "../user-page-schemas";
export type UserResultsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export const loader = async ({ params, request, url }: LoaderFunctionArgs) => {
const parsedSearchParams = parseSafeSearchParams({
request,
schema: userResultsPageSearchParamsSchema,
@@ -35,7 +35,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
showHighlightsOnly = false;
}
const isChoosingHighlights = request.url.includes("/results/highlights");
const isChoosingHighlights = url.pathname.includes("/results/highlights");
if (isChoosingHighlights) {
showHighlightsOnly = false;
}
@@ -62,7 +62,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const pagesCount = Math.ceil(totalCount / RESULTS_PER_PAGE);
redirectIfPageOutOfBounds({ request, page, pagesCount });
redirectIfPageOutOfBounds({ url, page, pagesCount });
return {
results: {

View File

@@ -18,11 +18,11 @@ export type UserSeasonsPageLoaderData = NonNullable<
SerializeFrom<typeof loader>
>;
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
const loggedInUser = getUser();
const { identifier } = userParamsSchema.parse(params);
const parsedSearchParams = seasonsSearchParamsSchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
Object.fromEntries(url.searchParams),
);
const user = notFoundIfFalsy(

View File

@@ -9,7 +9,7 @@ import {
redirectIfPageOutOfBounds,
} from "~/utils/remix.server";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export const loader = async ({ params, request, url }: LoaderFunctionArgs) => {
const userId = notFoundIfFalsy(
await UserRepository.identifierToUserId(params.identifier!),
).id;
@@ -30,7 +30,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE));
redirectIfPageOutOfBounds({ request, page, pagesCount });
redirectIfPageOutOfBounds({ url, page, pagesCount });
return {
vods,

View File

@@ -10,10 +10,9 @@ const newVodLoaderParamsSchema = z.object({
vod: z.preprocess(actualNumber, id),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
const url = new URL(request.url);
const params = newVodLoaderParamsSchema.safeParse(
Object.fromEntries(url.searchParams),
);

View File

@@ -7,7 +7,7 @@ import * as VodRepository from "../VodRepository.server";
import { VODS_PAGE_BATCH_SIZE } from "../vods-constants";
import { vodsSearchParamsSchema } from "../vods-schemas";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const loader = async ({ request, url }: LoaderFunctionArgs) => {
const { page, ...filters } = parseSearchParams({
request,
schema: vodsSearchParamsSchema,
@@ -24,7 +24,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE));
redirectIfPageOutOfBounds({ request, page, pagesCount });
redirectIfPageOutOfBounds({ url, page, pagesCount });
return {
vods,

View File

@@ -87,7 +87,10 @@ export function wrappedAction<T extends z.ZodTypeAny>({
],
});
const userFromRequest = await getUserFromRequest(request);
const userFromRequest = await getUserFromRequest(
request,
new URL(request.url),
);
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
try {
@@ -137,7 +140,10 @@ export function wrappedLoader<T>({
],
});
const userFromRequest = await getUserFromRequest(request);
const userFromRequest = await getUserFromRequest(
request,
new URL(request.url),
);
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
try {

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { redirectIfPageOutOfBounds } from "./remix.server";
const buildRequest = (url: string) => new Request(url);
const buildUrl = (url: string) => new URL(url);
const captureRedirect = (fn: () => void) => {
try {
@@ -17,7 +17,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("does not redirect when page is within bounds", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest("https://sendou.ink/vods?page=2"),
url: buildUrl("https://sendou.ink/vods?page=2"),
page: 2,
pagesCount: 5,
}),
@@ -29,7 +29,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("does not redirect when page equals pagesCount", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest("https://sendou.ink/vods?page=5"),
url: buildUrl("https://sendou.ink/vods?page=5"),
page: 5,
pagesCount: 5,
}),
@@ -41,7 +41,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("redirects to last page when page exceeds pagesCount", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest("https://sendou.ink/vods?page=99"),
url: buildUrl("https://sendou.ink/vods?page=99"),
page: 99,
pagesCount: 5,
}),
@@ -54,7 +54,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("preserves other search params when redirecting", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest(
url: buildUrl(
"https://sendou.ink/vods?type=TOURNAMENT&page=99&mode=SZ",
),
page: 99,
@@ -74,7 +74,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("does not redirect on page 1 when pagesCount is 0 (empty results)", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest("https://sendou.ink/vods?page=1"),
url: buildUrl("https://sendou.ink/vods?page=1"),
page: 1,
pagesCount: 0,
}),
@@ -86,7 +86,7 @@ describe("redirectIfPageOutOfBounds()", () => {
it("redirects to page 1 when pagesCount is 0 and page exceeds 1", () => {
const response = captureRedirect(() =>
redirectIfPageOutOfBounds({
request: buildRequest("https://sendou.ink/vods?page=4"),
url: buildUrl("https://sendou.ink/vods?page=4"),
page: 4,
pagesCount: 0,
}),

View File

@@ -68,18 +68,17 @@ export function parseSearchParams<T extends z.ZodTypeAny>({
* to a minimum of 1 so empty result sets stay on page 1.
*/
export function redirectIfPageOutOfBounds({
request,
url,
page,
pagesCount,
}: {
request: Request;
url: URL;
page: number;
pagesCount: number;
}): void {
const safePagesCount = Math.max(1, pagesCount);
if (page <= safePagesCount) return;
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.searchParams);
searchParams.set("page", String(safePagesCount));
throw redirect(`${url.pathname}?${searchParams.toString()}`);

View File

@@ -8,6 +8,13 @@ export default {
routeDiscovery: { mode: "initial" },
future: {
v8_middleware: true,
v8_splitRouteModules: true,
// Disabled: passing the raw request makes relative redirects (e.g. the
// successToast/errorToast `redirect("?__success=...")` pattern) resolve
// against the `.data` URL of single-fetch requests, breaking navigation.
v8_passThroughRequests: false,
v8_trailingSlashAwareDataRequests: true,
v8_viteEnvironmentApi: true,
},
buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => {
await sentryOnBuildEnd({