fix(web): clear pre-existing typecheck errors (36 → 0)

Sweep covering several unrelated cohorts:

- Delete dead `NewDeviceDialog.tsx` (commented-out in App.tsx, imports point at
  non-existent `@components/PageComponents/Connect/*`).
- Add `better-result` to sdk-react deps so `useChat`/`useDirectChat`/
  `useFavoriteNode`/`useIgnoreNode` resolve.
- Fix `test-utils.tsx`: route at `routeTree.gen.ts` was never generated;
  re-export the existing `routeTree` constant from `routes.tsx` and pass
  the missing router context + DeviceWrapper deviceId.
- Pre-existing `noUncheckedIndexedAccess` violations in `pskSchema.test.ts`,
  `x25519.ts`, `Table/index.tsx` + test, `useCopyToClipboard` (Timeout vs
  number) — non-null asserts where the index is guaranteed by the
  surrounding code, plus a defensive `if (!cell) return 0` in the table sort.
- Immer `WritableDraft<T>` mismatches against SDK's `MeshDevice`/`Device`
  classes — cast through `Draft<X>` where the inferred recursive draft type
  can't represent SDK class internals.
- `deviceStore.mock` updated for the `connectionPhase`/`connectionId` +
  setters added when the connection lifecycle was extracted.
- `HeatmapLayer` accepts the `isVisible` prop everyone else's layer accepts.
- `SNRTooltipProps.to` widened to `string | undefined` (single-node hover
  has no `to`).
- Zod resolver returns `Record<string, never>` for the error-path values
  shape that react-hook-form expects.
- Mock `NodeInfo` in `useFilterNode.test.ts` gains the `isMuted` proto
  field that landed upstream.
This commit is contained in:
Dan Ditomaso
2026-04-27 20:00:36 -04:00
parent 56c983c3d0
commit c160df5f2d
17 changed files with 44 additions and 203 deletions

View File

@@ -22,14 +22,7 @@ export function App() {
const device = getDevice(selectedDeviceId);
return (
// <ThemeProvider defaultTheme="system" storageKey="theme">
<ErrorBoundary FallbackComponent={ErrorPage}>
{/* <NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
/> */}
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}>

View File

@@ -1,169 +0,0 @@
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
export interface TabElementProps {
closeDialog: () => void;
}
export interface TabManifest {
id: "HTTP" | "BLE" | "Serial";
label: string;
element: React.FC<TabElementProps>;
isDisabled: boolean;
}
export interface NewDeviceProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
tabId: "HTTP" | "BLE" | "Serial";
}
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) {
return null;
}
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
? "Web Bluetooth"
: tabId === "Serial" && browserFeatures.includes("Web Serial")
? "Web Serial"
: undefined;
return (
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<div className="text-sm text-white">
{needsFeature && (
<Trans
i18nKey={errors[needsFeature].i18nKey}
components={[
<Link
key="0"
href={errors[needsFeature].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>,
]}
/>
)}
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={
browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"
}
components={{
"0": (
<Link
href={errors["Secure Context"].href}
className="underline hover:text-slate-200"
/>
),
}}
/>
)}
</div>
</div>
</div>
</div>
);
};
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
id: "HTTP",
label: t("newDeviceDialog.tabHttp"),
element: HTTP,
isDisabled: false,
},
{
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") || unsupported.includes("Secure Context"),
},
{
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") || unsupported.includes("Secure Context"),
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="HTTP">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{tab.id !== "HTTP" && tab.isDisabled ? (
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
) : (
<tab.element closeDialog={() => onOpenChange(false)} />
)}
</fieldset>
</TabsContent>
))}
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,5 +1,6 @@
import type {
FieldError,
FieldErrors,
FieldValues,
Resolver,
ResolverOptions,
@@ -49,8 +50,8 @@ export function createZodResolver<T extends FieldValues>(
}
return {
values: {} as T,
errors,
values: {} as Record<string, never>,
errors: errors as FieldErrors<T>,
};
};
}

View File

@@ -11,9 +11,11 @@ export interface HeatmapLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
mode: HeatmapMode;
isVisible: boolean;
}
export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) => {
export const HeatmapLayer = ({ id, filteredNodes, mode, isVisible }: HeatmapLayerProps) => {
if (!isVisible) return null;
const data: FeatureCollection = useMemo(() => {
const features: Feature[] = filteredNodes
.filter((node) => hasPos(node.position))

View File

@@ -31,7 +31,7 @@ export interface SNRTooltipProps {
pos: { x: number; y: number };
snr: number;
from: string;
to: string;
to: string | undefined;
}
type NeighborPlus = Protobuf.Mesh.Neighbor & {

View File

@@ -13,6 +13,7 @@ function createMockNode(): Protobuf.Mesh.NodeInfo {
viaMqtt: false,
isFavorite: true,
isIgnored: false,
isMuted: false,
hopsAway: 2,
isKeyManuallyVerified: false,
user: {

View File

@@ -112,17 +112,17 @@ describe("Generic Table", () => {
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]);
// Click "Short Name" to sort asc
fireEvent.click(columnHeaders[0]);
fireEvent.click(columnHeaders[0]!);
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]);
// Click "Short Name" again to sort desc
fireEvent.click(columnHeaders[0]);
fireEvent.click(columnHeaders[0]!);
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]);
// Click "Connection" to sort by hops asc
fireEvent.click(columnHeaders[2]);
fireEvent.click(columnHeaders[2]!);
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]);
});

View File

@@ -64,6 +64,7 @@ export const Table = ({ headings, rows }: TableProps) => {
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
if (!aCell || !bCell) return 0;
let aValue: string | number;
let bValue: string | number;

View File

@@ -6,7 +6,7 @@ interface UseCopyToClipboardProps {
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {

View File

@@ -16,6 +16,8 @@ export const mockDeviceStore: Device = {
id: 0,
myNodeNum: 123456,
status: 5 as const,
connectionPhase: "disconnected",
connectionId: null,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
@@ -50,6 +52,8 @@ export const mockDeviceStore: Device = {
neighborInfo: new Map(),
setStatus: vi.fn(),
setConnectionPhase: vi.fn(),
setConnectionId: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),

View File

@@ -2,7 +2,7 @@ import { create, toBinary } from "@bufbuild/protobuf";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/sdk";
import { produce } from "immer";
import { type Draft, produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
import type {
@@ -476,7 +476,7 @@ function deviceFactory(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.connection = connection;
device.connection = connection as unknown as Draft<MeshDevice>;
}
}),
);
@@ -616,7 +616,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (set, ge
const device = deviceFactory(id, get, set);
set(
produce<PrivateDeviceState>((draft) => {
draft.devices = new Map(draft.devices).set(id, device);
draft.devices = new Map(draft.devices).set(id, device as unknown as Draft<Device>);
// Enforce retention limit
evictOldestEntries(draft.devices, DEVICESTORE_RETENTION_NUM);
@@ -737,7 +737,7 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
);
}
}
draft.devices = rebuilt;
draft.devices = rebuilt as unknown as Map<number, Draft<Device>>;
}),
);
},

View File

@@ -5,9 +5,9 @@ export function getX25519PrivateKey(): Uint8Array {
// scalar clamping for curve25519, according to
// https://www.rfc-editor.org/rfc/rfc7748#section-5
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
key[0]! &= 248;
key[31]! &= 127;
key[31]! |= 64;
return key;
}

View File

@@ -146,7 +146,7 @@ const connectionsRoute = createRoute({
component: Connections,
});
const routeTree = rootRoute.addChildren([
export const routeTree = rootRoute.addChildren([
indexRoute,
messagesRoute,
messagesWithParamsRoute,

View File

@@ -4,7 +4,7 @@ import type { ReactElement } from "react";
import "@app/i18n-config.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { routeTree } from "../routeTree.gen.ts";
import { routeTree } from "../routes.tsx";
const Providers = () => {
const memoryHistory = createMemoryHistory({
@@ -14,10 +14,14 @@ const Providers = () => {
const router = createRouter({
routeTree,
history: memoryHistory,
context: {
stores: {} as never,
i18n: {} as never,
},
});
return (
<DeviceWrapper>
<DeviceWrapper deviceId={0}>
<RouterProvider router={router} />
</DeviceWrapper>
);

View File

@@ -19,7 +19,7 @@ describe("stringSchema", () => {
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
expect(result.error.issues[0]!.message).toBe(msgs.length);
}
});
@@ -28,7 +28,7 @@ describe("stringSchema", () => {
const result = stringSchema().safeParse("not_base64!");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
expect(result.error.issues[0]!.message).toBe(msgs.format);
}
});
@@ -37,7 +37,7 @@ describe("stringSchema", () => {
const result = stringSchema().safeParse("");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
expect(result.error.issues[0]!.message).toBe(msgs.required);
}
});
@@ -75,7 +75,7 @@ describe("stringSchema", () => {
const result = stringSchema().safeParse(valid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
expect(result.error.issues[0]!.message).toBe(msgs.format);
}
});
@@ -91,7 +91,7 @@ describe("stringSchema", () => {
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
expect(result.error.issues[0]!.message).toBe(msgs.length);
}
});
@@ -108,7 +108,7 @@ describe("stringSchema", () => {
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
expect(result.error.issues[0]!.message).toBe(msgs.length);
}
});
@@ -123,7 +123,7 @@ describe("stringSchema", () => {
const result = bytesSchema().safeParse(new Uint8Array(0));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
expect(result.error.issues[0]!.message).toBe(msgs.required);
}
});
@@ -167,7 +167,7 @@ describe("stringSchema", () => {
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
expect(result.error.issues[0]!.message).toBe(msgs.length);
}
});
});

View File

@@ -56,7 +56,8 @@
"test": "vitest run"
},
"dependencies": {
"@meshtastic/sdk": "workspace:*"
"@meshtastic/sdk": "workspace:*",
"better-result": "^1.0.0"
},
"peerDependencies": {
"react": "^18 || ^19"

3
pnpm-lock.yaml generated
View File

@@ -343,6 +343,9 @@ importers:
'@meshtastic/sdk':
specifier: workspace:*
version: link:../sdk
better-result:
specifier: ^1.0.0
version: 1.0.1
devDependencies:
'@testing-library/react':
specifier: ^16.0.0