Blank page on older browsers: replace ES2023 array methods with Remeda (#535)

This commit is contained in:
Ben Senescu 2026-08-26 10:02:03 -04:00 committed by GitHub
parent 8db0dd3246
commit b631715112
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 197 additions and 154 deletions

View File

@ -17,6 +17,8 @@
"rules": {
"react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off",
// Flags mutating .sort(); its suggested .toSorted() fix doesn't compile
// here (lib is ES2022) fix with Remeda's sort/sortBy instead.
"unicorn/no-array-sort": "error",
"typescript/no-explicit-any": "error",
"typescript/consistent-type-imports": "error",

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { identity, sortBy } from "remeda";
import {
AlertCircle,
ArrowLeft,
@ -90,7 +91,7 @@ function PromptExplorerPageInner({
"prompt-explorer",
projectId,
trimmedPrompt,
urlState.models.toSorted().join(","),
sortBy(urlState.models, identity()).join(","),
urlState.webSearch,
urlState.webSearchCountryCode,
urlState.highlightBrand.trim(),
@ -132,7 +133,7 @@ function PromptExplorerPageInner({
const key = [
trimmedPrompt,
urlState.highlightBrand.trim(),
urlState.models.toSorted().join(","),
sortBy(urlState.models, identity()).join(","),
urlState.webSearch,
urlState.webSearchCountryCode,
].join("|");

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { ChevronRight } from "lucide-react";
import { sort } from "remeda";
import {
getIssueDescriptor,
ISSUE_SEVERITY_ORDER,
@ -68,7 +69,8 @@ function groupIssues(issues: AuditIssueRow[]): IssueGroup[] {
group.issues.push(issue);
}
return Array.from(groups.values()).toSorted(
return sort(
Array.from(groups.values()),
(a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
b.issues.length - a.issues.length,

View File

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { sort } from "remeda";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
@ -124,13 +125,15 @@ export function getBillingFeatureBreakdownRows(
creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value);
}
return [...creditsByLabel.entries()]
.map(([label, credits]) => ({
label,
usd: autumnSeoDataCreditsToUsd(credits),
}))
.filter((row) => row.usd > 0)
.toSorted((a, b) => b.usd - a.usd);
return sort(
[...creditsByLabel.entries()]
.map(([label, credits]) => ({
label,
usd: autumnSeoDataCreditsToUsd(credits),
}))
.filter((row) => row.usd > 0),
(a, b) => b.usd - a.usd,
);
}
export function BillingFeatureBreakdown() {

View File

@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { sort } from "remeda";
import { captureClientEvent } from "@/client/lib/posthog";
import {
computeNextStep,
@ -301,6 +302,61 @@ export function DashboardPage({ projectId }: { projectId: string }) {
const gscConnected = activation.gsc.connected;
const ga4Connected = activation.ga4.connected;
// Array order is the within-bucket order after the data-first sort below:
// the MCP pitch leads the setup cards.
const cards = [
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
? []
: [
{
key: "mcp",
hasData: false,
node: (
<McpConnectCard projectId={projectId} activation={activation} />
),
},
]),
{
key: "gsc",
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: <Ga4Card projectId={projectId} connected={ga4Connected} />,
},
]
: []),
{
key: "audit",
hasData: overview?.audit != null,
node: (
<AuditHealthCard
projectId={projectId}
audit={overview?.audit ?? null}
/>
),
},
...(showBacklinks
? [
{
key: "backlinks",
hasData: overview?.backlinks != null || refreshMutation.isPending,
node: (
<BacklinkPulseCard
projectId={projectId}
backlinks={overview?.backlinks ?? null}
refreshing={refreshMutation.isPending}
/>
),
},
]
: []),
];
return (
<div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
<div className="mx-auto flex max-w-5xl flex-col gap-5">
@ -313,70 +369,11 @@ export function DashboardPage({ projectId }: { projectId: string }) {
{/* Every card is half width on large screens (only the checklist spans).
Cards with data render before setup pitches and empty states. */}
<div className="grid items-start gap-5 lg:grid-cols-2">
{[
// Array order is the within-bucket order after the data-first sort:
// the MCP pitch leads the setup cards.
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
? []
: [
{
key: "mcp",
hasData: false,
node: (
<McpConnectCard
projectId={projectId}
activation={activation}
/>
),
},
]),
{
key: "gsc",
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: (
<Ga4Card projectId={projectId} connected={ga4Connected} />
),
},
]
: []),
{
key: "audit",
hasData: overview?.audit != null,
node: (
<AuditHealthCard
projectId={projectId}
audit={overview?.audit ?? null}
/>
),
},
...(showBacklinks
? [
{
key: "backlinks",
hasData:
overview?.backlinks != null || refreshMutation.isPending,
node: (
<BacklinkPulseCard
projectId={projectId}
backlinks={overview?.backlinks ?? null}
refreshing={refreshMutation.isPending}
/>
),
},
]
: []),
]
.toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
.map((card) => (
{sort(cards, (a, b) => Number(b.hasData) - Number(a.hasData)).map(
(card) => (
<div key={card.key}>{card.node}</div>
))}
),
)}
</div>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { describe, expect, it } from "vitest";
import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords";
import {
@ -85,10 +86,12 @@ describe("applyKeywordFiltersAndSort — intent filtering", () => {
it("keeps rows matching any of multiple selected intents", () => {
const result = filter(rows, { intents: "transactional,commercial" });
expect(result.map((r) => r.keyword).toSorted()).toEqual([
"best running shoes",
"buy running shoes",
]);
expect(
sortBy(
result.map((r) => r.keyword),
identity(),
),
).toEqual(["best running shoes", "buy running shoes"]);
});
it("combines the intent filter with other filters (AND)", () => {

View File

@ -8,6 +8,7 @@ import {
Sheet,
SlidersHorizontal,
} from "lucide-react";
import { sortBy } from "remeda";
import {
downloadKeywordResearchCsv,
KEYWORD_RESEARCH_HEADERS,
@ -56,9 +57,7 @@ const MONTH_SHORT_LABELS = [
function formatTrendRangeLabel(trend: KeywordResearchRow["trend"]): string {
if (trend.length === 0) return "Last 12 available months";
const sorted = trend.toSorted(
(a, b) => a.year * 100 + a.month - (b.year * 100 + b.month),
);
const sorted = sortBy(trend, (item) => item.year * 100 + item.month);
const last12 = sorted.slice(-12);
const start = last12[0];
const end = last12[last12.length - 1];

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { Link, useRouter } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { findLast } from "remeda";
import {
Check,
ChevronsUpDown,
@ -92,7 +93,8 @@ export function ProjectSwitcher({
// project, so they fall back to their section. Filtering on the path
// template rather than match.params matters: the router gives every match
// the location's full param set, so params can't tell layers apart.
const stayable = router.state.matches.findLast(
const stayable = findLast(
router.state.matches,
(match) =>
match.fullPath.includes("$projectId") &&
match.fullPath

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { Copy, Download, Loader2 } from "lucide-react";
import { reverse, sortBy } from "remeda";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/client/components/Modal";
@ -363,7 +364,7 @@ function buildChartData(
row[p.device] = p.position === null ? serpDepth : p.position;
byTime.set(ts, row);
}
return [...byTime.values()].toSorted((a, b) => a.checkedAt - b.checkedAt);
return sortBy([...byTime.values()], (row) => row.checkedAt);
}
interface HistoryRow {
@ -393,7 +394,7 @@ function buildHistoryRows(points: RankKeywordHistoryPoint[]): HistoryRow[] {
});
prevByDevice.set(p.device, p.position);
}
return rows.toReversed();
return reverse(rows);
}
function slugify(value: string): string {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel } from "@/shared/rank-tracking";
import type {
@ -106,10 +107,13 @@ export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): {
);
}
const locations = Array.from(locationMap, ([code, label]) => ({
value: String(code),
label,
})).toSorted((a, b) => a.label.localeCompare(b.label));
const locations = sort(
Array.from(locationMap, ([code, label]) => ({
value: String(code),
label,
})),
(a, b) => a.label.localeCompare(b.label),
);
return { devices, locations };
}

View File

@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import { sort } from "remeda";
import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking";
/**
@ -131,9 +132,10 @@ function buildMatrix(cells: RankPositionMatrixCell[]): {
}
byRun.set(c.runId, c.position);
}
const runs = [...runMap.entries()]
.map(([runId, checkedAt]) => ({ runId, checkedAt }))
.toSorted((a, b) => a.checkedAt.localeCompare(b.checkedAt));
const runs = sort(
[...runMap.entries()].map(([runId, checkedAt]) => ({ runId, checkedAt })),
(a, b) => a.checkedAt.localeCompare(b.checkedAt),
);
return { runs, cellByKeyword };
}

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { z } from "zod";
import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory";
import {
@ -21,8 +22,8 @@ export type PromptExplorerSearchHistoryItem = PromptExplorerSearchBody & {
function sameModels(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = a.toSorted();
const sortedB = b.toSorted();
const sortedA = sortBy(a, identity());
const sortedB = sortBy(b, identity());
return sortedA.every((model, index) => model === sortedB[index]);
}

View File

@ -3,6 +3,7 @@ import { join } from "node:path";
import { getTableColumns, getTableName, is, Table } from "drizzle-orm";
import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core";
import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core";
import { sort } from "remeda";
import { describe, expect, it } from "vitest";
import * as sqliteApp from "./app.schema";
import * as sqliteProjectContext from "./project-context.schema";
@ -32,7 +33,7 @@ import * as pgTelemetry from "./pg/telemetry.schema";
type Dialect = "sqlite" | "pg";
const sortStrings = (values: string[]) =>
values.toSorted((a, b) => a.localeCompare(b));
sort(values, (a, b) => a.localeCompare(b));
function asStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;

View File

@ -1,4 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
// Aliased: `SavedKeywordsPage` has a local `sort` const (the saved-keyword
// sort key) that would otherwise shadow this import at the call site.
import { sort as sortArray } from "remeda";
import {
keepPreviousData,
useMutation,
@ -130,7 +133,7 @@ function SavedKeywordsPage() {
if (!map.has(tag.id)) map.set(tag.id, tag);
}
}
return [...map.values()].toSorted((a, b) =>
return sortArray([...map.values()], (a, b) =>
a.normalizedName.localeCompare(b.normalizedName),
);
}, [selectedRows]);

View File

@ -1,5 +1,6 @@
import { env } from "cloudflare:workers";
import { and, eq, inArray, isNull, like } from "drizzle-orm";
import { firstBy, identity } from "remeda";
import { db } from "@/db";
import { runBatch } from "@/db/runBatch";
import { getAuthMode } from "@/lib/auth-mode";
@ -18,8 +19,10 @@ import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization";
// chronological for these).
function earliest(values: (string | null)[]) {
return (
values.filter((value): value is string => value !== null).toSorted()[0] ??
null
firstBy(
values.filter((value): value is string => value !== null),
identity(),
) ?? null
);
}

View File

@ -1,4 +1,5 @@
import { waitUntil } from "cloudflare:workers";
import { identity, sortBy } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo";
import {
@ -74,10 +75,10 @@ export async function getBrandLookup(
// are canonical detected values too, so equivalent casing/order shares one
// paid cache entry.
targetValue: detected.value.toLowerCase(),
competitors: competitorGroups
.map((g) => g.detected.value.toLowerCase())
.toSorted()
.join("|"),
competitors: sortBy(
competitorGroups.map((g) => g.detected.value.toLowerCase()),
identity(),
).join("|"),
locationCode: input.locationCode,
languageCode: input.languageCode,
// Scope changes both the provider call (include_subdomains) and the

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { describe, expect, it } from "vitest";
import { deriveCitedSources } from "./citedSources";
import type {
@ -56,10 +57,12 @@ describe("deriveCitedSources", () => {
mentions: 9,
capturedVolume: 9000,
});
expect(sources[0].keywords.map((k) => k.question).toSorted()).toEqual([
"best seo tools",
"cheap seo",
]);
expect(
sortBy(
sources[0].keywords.map((k) => k.question),
identity(),
),
).toEqual(["best seo tools", "cheap seo"]);
});
it("dedupes sampled prompt examples and derives domains from urls", () => {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
@ -169,17 +170,15 @@ async function getAuditSummary(
const typeRows = await getIssueTypePageCountsForAudit(audit.id);
const severityRank = { critical: 0, warning: 1, info: 2 };
const sorted = typeRows
.map((row) => ({
const sorted = sort(
typeRows.map((row) => ({
issueType: row.issueType,
severity: row.severity,
count: row.pages,
}))
.toSorted(
(a, b) =>
severityRank[a.severity] - severityRank[b.severity] ||
b.count - a.count,
);
})),
(a, b) =>
severityRank[a.severity] - severityRank[b.severity] || b.count - a.count,
);
return {
status: audit.status,

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient";
/**
@ -111,14 +112,14 @@ export function buildStrikingDistanceRows(
});
}
return Array.from(topPageByQuery.values())
.filter(
return sort(
Array.from(topPageByQuery.values()).filter(
(row) =>
row.position >= STRIKING_DISTANCE_MIN_POSITION &&
row.position <= STRIKING_DISTANCE_MAX_POSITION,
)
.toSorted((a, b) => b.impressions - a.impressions)
.slice(0, limit);
),
(a, b) => b.impressions - a.impressions,
).slice(0, limit);
}
/** The same-length period immediately before [startDate, endDate], for the

View File

@ -1,4 +1,5 @@
import { tool, type ToolSet } from "ai";
import { sort } from "remeda";
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { MAX_PAGES, readPages, readSite } from "@/server/lib/scrape";
@ -225,17 +226,20 @@ function coreSiteTools(ctx: ToolContext): ToolSet {
"onboarding",
);
const keywords = researchResult.rows
// Keep only keywords with a real volume — the strategy table shows
// volume + KD, so a null-volume row can't be grounded.
.filter((row) => row.searchVolume != null)
.toSorted((a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0))
.map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
}));
// Keep only keywords with a real volume — the strategy table shows
// volume + KD, so a null-volume row can't be grounded.
const withVolume = researchResult.rows.filter(
(row) => row.searchVolume != null,
);
const keywords = sort(
withVolume,
(a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0),
).map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
}));
return { available: keywords.length > 0, keywords };
} catch (error) {

View File

@ -1,4 +1,5 @@
import { tool, type ToolSet } from "ai";
import { sort } from "remeda";
import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
@ -132,9 +133,10 @@ export function marketTools(ctx: ToolContext): ToolSet {
limit: 50,
creditFeature: "onboarding",
});
const top = competitors
.filter((c) => !isSameDomain(c.domain))
.toSorted((a, b) => (b.etv ?? 0) - (a.etv ?? 0))
const top = sort(
competitors.filter((c) => !isSameDomain(c.domain)),
(a, b) => (b.etv ?? 0) - (a.etv ?? 0),
)
.slice(0, 10)
.map((c) => ({
domain: c.domain ?? null,

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import type { SkillSource } from "agents/skills";
@ -75,10 +76,12 @@ let cachedSource: SkillSource | undefined;
export function buildSamSkillSource(): SkillSource {
if (cachedSource) return cachedSource;
const skills = Object.entries(skillFiles)
.map(([path, raw]) => parseSkill(path, raw))
.filter((skill): skill is SamSkill => skill !== null)
.toSorted((a, b) => a.name.localeCompare(b.name));
const skills = sort(
Object.entries(skillFiles)
.map(([path, raw]) => parseSkill(path, raw))
.filter((skill): skill is SamSkill => skill !== null),
(a, b) => a.name.localeCompare(b.name),
);
return (cachedSource = {
id: "openseo-public-skills",

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines */
import { sort } from "remeda";
import { z } from "zod";
import {
createDataforseoClient,
@ -556,7 +557,7 @@ function sortCompetitors(
? "etv"
: "visibility";
const direction = sortBy === "avg_position" ? 1 : -1;
return items.toSorted((a, b) => {
return sort(items, (a, b) => {
const aValue = typeof a[field] === "number" ? a[field] : 0;
const bValue = typeof b[field] === "number" ? b[field] : 0;
return (aValue - bValue) * direction;
@ -590,7 +591,7 @@ function sortKeywordMetricRows(
rows: McpKeywordMetricRow[],
sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume",
) {
return rows.toSorted((a, b) => {
return sort(rows, (a, b) => {
const aValue = a[sortBy];
const bValue = b[sortBy];
const aNum = typeof aValue === "number" ? aValue : 0;

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- every local-SEO tool is covered in this one spec, matching local-seo-tools.ts */
import { sort } from "remeda";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
import {
@ -39,7 +41,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
const toolContext = makeToolContext();
const byText = (a: string, b: string) => a.localeCompare(b);
const sorted = (values: string[]) => sort(values, (a, b) => a.localeCompare(b));
beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue({
@ -252,11 +254,9 @@ describe("get_local_rank_grid", () => {
// from the spacing (13z here) so each point's viewport spans its neighbours
// instead of hiding businesses one grid step east or west.
expect(
local.mock.calls
.map(([input]) => input.locationCoordinate)
.toSorted(byText),
sorted(local.mock.calls.map(([input]) => input.locationCoordinate)),
).toEqual(
[
sorted([
"40.0180874,-74.0234532,13z",
"40.0180874,-74,13z",
"40.0180874,-73.9765468,13z",
@ -266,7 +266,7 @@ describe("get_local_rank_grid", () => {
"39.9819126,-74.0234532,13z",
"39.9819126,-74,13z",
"39.9819126,-73.9765468,13z",
].toSorted(byText),
]),
);
expect(local).toHaveBeenCalledWith(
expect.objectContaining({

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { z } from "zod";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditService } from "@/server/features/audit/services/AuditService";
@ -251,7 +252,8 @@ export const getAuditIssuesTool = {
issueType: args.issueType,
});
// Severity-first so truncation drops info rows, never critical ones.
const rows = unsorted.toSorted(
const rows = sort(
unsorted,
(a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
a.issueType.localeCompare(b.issueType),
@ -261,8 +263,8 @@ export const getAuditIssuesTool = {
for (const row of rows) {
counts.set(row.issueType, (counts.get(row.issueType) ?? 0) + 1);
}
const summary = Array.from(counts.entries())
.map(([issueType, count]) => {
const summary = sort(
Array.from(counts.entries()).map(([issueType, count]) => {
const descriptor = getIssueDescriptor(issueType);
return {
issueType,
@ -270,12 +272,11 @@ export const getAuditIssuesTool = {
severity: descriptor?.severity ?? "info",
count,
};
})
.toSorted(
(a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
b.count - a.count,
);
}),
(a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
b.count - a.count,
);
const limit = args.limit ?? 200;
const issues = rows.slice(0, limit).map((row) => {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { describe, expect, it } from "vitest";
import {
LABS_LOCATION_OPTIONS,
@ -63,7 +64,7 @@ describe("keyword locations", () => {
it("keeps the picker sorted alphabetically with unique codes", () => {
const labels = LOCATION_OPTIONS.map((option) => option.label);
expect(labels).toEqual(labels.toSorted((a, b) => a.localeCompare(b)));
expect(labels).toEqual(sort(labels, (a, b) => a.localeCompare(b)));
const codes = LOCATION_OPTIONS.map((option) => option.code);
expect(new Set(codes).size).toBe(codes.length);
});

View File

@ -7,7 +7,10 @@
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
// ES2022 is the browser-support floor: ES2023 array methods (toSorted,
// toReversed, findLast, ...) crash Chromium <110. Don't bump this to fix a
// missing-method error use Remeda's sort/sortBy/findLast instead.
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,