diff --git a/.oxlintrc.json b/.oxlintrc.json
index ad87fec..2726285 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -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",
diff --git a/src/client/features/ai-search/PromptExplorerPage.tsx b/src/client/features/ai-search/PromptExplorerPage.tsx
index dac2447..6be5345 100644
--- a/src/client/features/ai-search/PromptExplorerPage.tsx
+++ b/src/client/features/ai-search/PromptExplorerPage.tsx
@@ -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("|");
diff --git a/src/client/features/audit/results/IssuesView.tsx b/src/client/features/audit/results/IssuesView.tsx
index d507be3..1ef9df2 100644
--- a/src/client/features/audit/results/IssuesView.tsx
+++ b/src/client/features/audit/results/IssuesView.tsx
@@ -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,
diff --git a/src/client/features/billing/BillingFeatureBreakdown.tsx b/src/client/features/billing/BillingFeatureBreakdown.tsx
index 5f7cdfc..2ad4f75 100644
--- a/src/client/features/billing/BillingFeatureBreakdown.tsx
+++ b/src/client/features/billing/BillingFeatureBreakdown.tsx
@@ -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() {
diff --git a/src/client/features/dashboard/DashboardPage.tsx b/src/client/features/dashboard/DashboardPage.tsx
index d559e92..6c26e1b 100644
--- a/src/client/features/dashboard/DashboardPage.tsx
+++ b/src/client/features/dashboard/DashboardPage.tsx
@@ -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: (
+
+ ),
+ },
+ ]),
+ {
+ key: "gsc",
+ hasData: gscConnected,
+ node: ,
+ },
+ ...(ga4Connected || !activation.ga4.cardDismissedAt
+ ? [
+ {
+ key: "ga4",
+ hasData: ga4Connected,
+ node: ,
+ },
+ ]
+ : []),
+ {
+ key: "audit",
+ hasData: overview?.audit != null,
+ node: (
+
+ ),
+ },
+ ...(showBacklinks
+ ? [
+ {
+ key: "backlinks",
+ hasData: overview?.backlinks != null || refreshMutation.isPending,
+ node: (
+
+ ),
+ },
+ ]
+ : []),
+ ];
+
return (
@@ -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. */}
- {[
- // 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: (
-
- ),
- },
- ]),
- {
- key: "gsc",
- hasData: gscConnected,
- node:
,
- },
- ...(ga4Connected || !activation.ga4.cardDismissedAt
- ? [
- {
- key: "ga4",
- hasData: ga4Connected,
- node: (
-
- ),
- },
- ]
- : []),
- {
- key: "audit",
- hasData: overview?.audit != null,
- node: (
-
- ),
- },
- ...(showBacklinks
- ? [
- {
- key: "backlinks",
- hasData:
- overview?.backlinks != null || refreshMutation.isPending,
- node: (
-
- ),
- },
- ]
- : []),
- ]
- .toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
- .map((card) => (
+ {sort(cards, (a, b) => Number(b.hasData) - Number(a.hasData)).map(
+ (card) => (
{card.node}
- ))}
+ ),
+ )}
diff --git a/src/client/features/keywords/hooks/useKeywordFiltering.test.ts b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts
index 5e1c778..9fb6973 100644
--- a/src/client/features/keywords/hooks/useKeywordFiltering.test.ts
+++ b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts
@@ -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)", () => {
diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx
index eb0554b..1dc7bfb 100644
--- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx
+++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx
@@ -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];
diff --git a/src/client/features/projects/ProjectSwitcher.tsx b/src/client/features/projects/ProjectSwitcher.tsx
index 42453a6..afceeff 100644
--- a/src/client/features/projects/ProjectSwitcher.tsx
+++ b/src/client/features/projects/ProjectSwitcher.tsx
@@ -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
diff --git a/src/client/features/rank-tracking/KeywordTrendModal.tsx b/src/client/features/rank-tracking/KeywordTrendModal.tsx
index 1db67b8..df2111f 100644
--- a/src/client/features/rank-tracking/KeywordTrendModal.tsx
+++ b/src/client/features/rank-tracking/KeywordTrendModal.tsx
@@ -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 {
diff --git a/src/client/features/rank-tracking/RankTrackingFilters.logic.ts b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts
index 865a1a0..3bfd2ea 100644
--- a/src/client/features/rank-tracking/RankTrackingFilters.logic.ts
+++ b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts
@@ -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 };
}
diff --git a/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
index 4898402..f0de564 100644
--- a/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
+++ b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
@@ -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 };
}
diff --git a/src/client/hooks/usePromptExplorerSearchHistory.ts b/src/client/hooks/usePromptExplorerSearchHistory.ts
index 0174e78..a6df3b2 100644
--- a/src/client/hooks/usePromptExplorerSearchHistory.ts
+++ b/src/client/hooks/usePromptExplorerSearchHistory.ts
@@ -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]);
}
diff --git a/src/db/schema-parity.test.ts b/src/db/schema-parity.test.ts
index aa01940..0338732 100644
--- a/src/db/schema-parity.test.ts
+++ b/src/db/schema-parity.test.ts
@@ -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;
diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx
index d7cceeb..7156d3f 100644
--- a/src/routes/_project/p/$projectId/saved.tsx
+++ b/src/routes/_project/p/$projectId/saved.tsx
@@ -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]);
diff --git a/src/server/auth/workspace-merge.ts b/src/server/auth/workspace-merge.ts
index 75c03f8..734a654 100644
--- a/src/server/auth/workspace-merge.ts
+++ b/src/server/auth/workspace-merge.ts
@@ -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
);
}
diff --git a/src/server/features/ai-search/services/brandLookup.ts b/src/server/features/ai-search/services/brandLookup.ts
index 6ec3e9a..2a7b2b5 100644
--- a/src/server/features/ai-search/services/brandLookup.ts
+++ b/src/server/features/ai-search/services/brandLookup.ts
@@ -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
diff --git a/src/server/features/ai-search/services/citedSources.test.ts b/src/server/features/ai-search/services/citedSources.test.ts
index dfcc5eb..01e7f0a 100644
--- a/src/server/features/ai-search/services/citedSources.test.ts
+++ b/src/server/features/ai-search/services/citedSources.test.ts
@@ -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", () => {
diff --git a/src/server/features/dashboard/services/DashboardService.ts b/src/server/features/dashboard/services/DashboardService.ts
index 5497fa4..2acff01 100644
--- a/src/server/features/dashboard/services/DashboardService.ts
+++ b/src/server/features/dashboard/services/DashboardService.ts
@@ -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,
diff --git a/src/server/features/gsc/searchPerformanceReport.ts b/src/server/features/gsc/searchPerformanceReport.ts
index 28e956a..5c841c1 100644
--- a/src/server/features/gsc/searchPerformanceReport.ts
+++ b/src/server/features/gsc/searchPerformanceReport.ts
@@ -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
diff --git a/src/server/features/onboarding/onboardingChatTools.ts b/src/server/features/onboarding/onboardingChatTools.ts
index 626c1bd..787f5b9 100644
--- a/src/server/features/onboarding/onboardingChatTools.ts
+++ b/src/server/features/onboarding/onboardingChatTools.ts
@@ -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) {
diff --git a/src/server/features/onboarding/onboardingMarketTools.ts b/src/server/features/onboarding/onboardingMarketTools.ts
index 2a12e2c..54ebfd0 100644
--- a/src/server/features/onboarding/onboardingMarketTools.ts
+++ b/src/server/features/onboarding/onboardingMarketTools.ts
@@ -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,
diff --git a/src/server/features/sam/samSkills.ts b/src/server/features/sam/samSkills.ts
index 81e9433..4f99f35 100644
--- a/src/server/features/sam/samSkills.ts
+++ b/src/server/features/sam/samSkills.ts
@@ -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",
diff --git a/src/server/mcp/tools/dataforseo-research-tools.ts b/src/server/mcp/tools/dataforseo-research-tools.ts
index 5afc071..72b2208 100644
--- a/src/server/mcp/tools/dataforseo-research-tools.ts
+++ b/src/server/mcp/tools/dataforseo-research-tools.ts
@@ -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 = "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;
diff --git a/src/server/mcp/tools/local-seo-tools.test.ts b/src/server/mcp/tools/local-seo-tools.test.ts
index 84c1816..35fd649 100644
--- a/src/server/mcp/tools/local-seo-tools.test.ts
+++ b/src/server/mcp/tools/local-seo-tools.test.ts
@@ -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({
diff --git a/src/server/mcp/tools/site-audit-tools.ts b/src/server/mcp/tools/site-audit-tools.ts
index 8c58e1d..728551a 100644
--- a/src/server/mcp/tools/site-audit-tools.ts
+++ b/src/server/mcp/tools/site-audit-tools.ts
@@ -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) => {
diff --git a/src/shared/keyword-locations.test.ts b/src/shared/keyword-locations.test.ts
index 1689db6..2a8d9fd 100644
--- a/src/shared/keyword-locations.test.ts
+++ b/src/shared/keyword-locations.test.ts
@@ -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);
});
diff --git a/tsconfig.json b/tsconfig.json
index 7867337..f18a0eb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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,