diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ab96708e832..6a24da39ed7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -247,11 +247,14 @@ "zustand": "5.0.12" }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.2", "@sentry/vite-plugin": "5.4.0", "@superset/typescript": "workspace:*", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.167.17", "@tanstack/router-plugin": "1.168.18", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.0", "@types/better-sqlite3": "7.6.13", "@types/bun": "1.3.14", "@types/culori": "4.0.1", diff --git a/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx b/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx new file mode 100644 index 00000000000..89073cc943b --- /dev/null +++ b/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx @@ -0,0 +1,165 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document — Redirect renders +// through a real TanStack router, which needs a DOM. Bun runs test files +// sequentially in one process and happy-dom's globals are process-wide, so +// we MUST unregister in afterAll (below) to restore the shared mock document +// for the other renderer suites — otherwise readonly DOM globals leak and +// break unrelated tests that assign e.g. globalThis.localStorage. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render } = await import("@testing-library/react"); +const { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} = await import("@tanstack/react-router"); +const React = await import("react"); +const { Redirect: TypedRedirect } = await import("./Redirect"); + +// The real Redirect types `to` against the app's registered route tree; this +// test drives a synthetic router with routes that don't exist there, so widen +// the signature. Runtime behavior is unchanged — this only relaxes the paths. +const Redirect = TypedRedirect as (props: { + to: string; + replace?: boolean; + search?: Record; +}) => React.ReactElement | null; + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * Build a router that renders `children` in the ALWAYS-MOUNTED root component + * (so a redirect element survives the navigation it triggers), plus inert + * destination routes. `navigations` counts every router.navigate call so a + * re-render loop shows up as a rising count. + */ +function setup(children: React.ReactNode) { + const rootRoute = createRootRoute({ + component: () => ( + <> + {children} + + + ), + }); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () =>
index
, + }); + const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/target", + component: () =>
target
, + }); + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/other", + component: () =>
other
, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + + let navigations = 0; + const realNavigate = router.navigate.bind(router); + router.navigate = ((opts: Parameters[0]) => { + navigations += 1; + return realNavigate(opts); + }) as typeof router.navigate; + + return { router, getNavigations: () => navigations }; +} + +describe("Redirect", () => { + test("navigates to the target once", async () => { + const { router } = setup(); + await act(async () => { + render(); + await router.latestLoadPromise; + }); + expect(router.state.location.pathname).toBe("/target"); + }); + + test("does not re-navigate when re-rendered with fresh props identity", async () => { + // The exact condition that loops TanStack's : a parent that + // re-renders and hands the redirect a new props object every time. + let forceRender: (() => void) | undefined; + function Parent() { + const [, setTick] = React.useState(0); + forceRender = () => setTick((n) => n + 1); + // New object literal each render → new props identity for Redirect. + return ; + } + + const { router, getNavigations } = setup(); + await act(async () => { + render(); + await router.latestLoadPromise; + }); + + const afterMount = getNavigations(); + expect(router.state.location.pathname).toBe("/target"); + + for (let i = 0; i < 10; i++) { + await act(async () => { + forceRender?.(); + await router.latestLoadPromise; + }); + } + + // The whole point: identity churn must not add navigations. + expect(getNavigations()).toBe(afterMount); + expect(router.state.location.pathname).toBe("/target"); + }); + + test("re-navigates when the resolved target actually changes", async () => { + let setTarget: ((to: "/target" | "/other") => void) | undefined; + function Parent() { + const [to, set] = React.useState<"/target" | "/other">("/target"); + setTarget = set; + return ; + } + + const { router, getNavigations } = setup(); + await act(async () => { + render(); + await router.latestLoadPromise; + }); + expect(router.state.location.pathname).toBe("/target"); + const afterFirst = getNavigations(); + + await act(async () => { + setTarget?.("/other"); + await router.latestLoadPromise; + }); + + expect(getNavigations()).toBeGreaterThan(afterFirst); + expect(router.state.location.pathname).toBe("/other"); + }); + + test("renders nothing", async () => { + const { router } = setup(); + let html = ""; + await act(async () => { + const { container } = render(); + await router.latestLoadPromise; + // target route renders
target
; Redirect itself emits nothing + html = container.innerHTML; + }); + expect(html).not.toContain("undefined"); + }); +}); diff --git a/apps/desktop/src/renderer/components/Redirect/Redirect.tsx b/apps/desktop/src/renderer/components/Redirect/Redirect.tsx new file mode 100644 index 00000000000..c537460f2b4 --- /dev/null +++ b/apps/desktop/src/renderer/components/Redirect/Redirect.tsx @@ -0,0 +1,38 @@ +import { + type NavigateOptions, + useNavigate, + useRouter, +} from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; + +/** + * Loop-proof declarative redirect. TanStack's re-navigates + * whenever its props OBJECT IDENTITY changes, so an inline element + * re-navigates on every parent re-render until React throws error #185 + * (#5729, SUPER-1814). This keys off the resolved target href instead: + * one navigation per distinct destination, safe to render inline with + * dynamic params/search. Prefer `beforeLoad: () => { throw redirect(...) }` + * when the target is static — that avoids render-time navigation entirely. + */ +export function Redirect(props: NavigateOptions) { + const router = useRouter(); + const navigate = useNavigate(); + const href = router.buildLocation(props).href; + const propsRef = useRef(props); + propsRef.current = props; + const lastHrefRef = useRef(null); + + useEffect(() => { + if (lastHrefRef.current === href) return; + navigate(propsRef.current).catch((error) => { + // A route-load/guard failure would otherwise be an unhandled + // rejection. Clear the marker so a later render can retry the + // same target rather than treating it as permanently done. + lastHrefRef.current = null; + console.error("[Redirect] navigation failed", { href, error }); + }); + lastHrefRef.current = href; + }, [href, navigate]); + + return null; +} diff --git a/apps/desktop/src/renderer/components/Redirect/index.ts b/apps/desktop/src/renderer/components/Redirect/index.ts new file mode 100644 index 00000000000..2605eb9f6f1 --- /dev/null +++ b/apps/desktop/src/renderer/components/Redirect/index.ts @@ -0,0 +1 @@ +export { Redirect } from "./Redirect"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx new file mode 100644 index 00000000000..052d580a863 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx @@ -0,0 +1,54 @@ +import { Button } from "@superset/ui/button"; +import type { ErrorComponentProps } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { HiExclamationTriangle } from "react-icons/hi2"; + +/** + * Content-pane error state for the dashboard. Rendered by the CatchBoundary + * around the dashboard Outlet so a crashing route unmounts only the content + * area — the sidebar, terminals, and window chrome stay alive (SUPER-1814 + * presented as "the whole app restarts" because errors used to bubble to the + * root boundary). The boundary resets on navigation, so the sidebar and the + * Go-home link both recover it. + */ +export function DashboardContentError({ error }: ErrorComponentProps) { + const message = + error instanceof Error ? error.message : String(error ?? "Unknown error"); + + useEffect(() => { + console.error("[dashboard] Content route error caught:", error); + void import("@sentry/electron/renderer") + .then((Sentry) => Sentry.captureException(error)) + .catch((reportError) => { + // Don't let a telemetry failure vanish silently — the fallback UI + // still renders, but we want the reporting gap to be observable. + console.error( + "[dashboard] failed to report content error to Sentry", + reportError, + ); + }); + }, [error]); + + return ( +
+
+ +
+
+

This view hit an error

+

+ {message} +

+
+
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/index.ts new file mode 100644 index 00000000000..b14f71e915d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/index.ts @@ -0,0 +1 @@ +export { DashboardContentError } from "./DashboardContentError"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index a8973ca923a..d795e2f806e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,12 +1,14 @@ import { + CatchBoundary, createFileRoute, - Navigate, Outlet, + useLocation, useMatchRoute, useNavigate, } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { CommandPaletteHost } from "renderer/commandPalette"; +import { Redirect } from "renderer/components/Redirect"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -26,17 +28,13 @@ import { } from "renderer/stores/workspace-sidebar-state"; import { AddRepositoryModals } from "./components/AddRepositoryModals"; import { CrossVersionMismatchState } from "./components/CrossVersionMismatchState"; +import { DashboardContentError } from "./components/DashboardContentError"; import { TopBar } from "./components/TopBar"; export const Route = createFileRoute("/_authenticated/_dashboard")({ component: DashboardLayout, }); -// Hoisted for stable props identity — re-navigates whenever its -// props object changes, so an inline element re-navigates on every re-render -// until React throws error #185 (see routes/page.tsx, #5729, SUPER-1814). -const newWorkspaceRedirect = ; - /** v1 only — v2 deletes go through the globally-mounted DeleteWorkspaceMount * (see delete-workspace-intent store). */ type DeleteTarget = { @@ -47,6 +45,7 @@ type DeleteTarget = { function DashboardLayout() { const navigate = useNavigate(); + const location = useLocation(); const openNewWorkspaceModal = useOpenNewWorkspaceModal(); const isV2CloudEnabled = useIsV2CloudEnabled(); const { workspaces: hostWorkspaces } = useHostWorkspaces(); @@ -230,12 +229,23 @@ function DashboardLayout() { // dead-end "pick a workspace" screen. v1 users keep the // static state — /new-workspace is a v2-only surface. isV2CloudEnabled ? ( - newWorkspaceRedirect + ) : ( ) ) : ( - + // Contain content-route crashes to this pane: without a + // boundary they bubble to the root and unmount the whole + // app, which reads as Superset restarting itself + // (SUPER-1814). Resets on navigation. + location.href} + errorComponent={DashboardContentError} + > + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx index 8684efc8b30..9a2b4af8800 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx @@ -1,10 +1,25 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { useMemo } from "react"; import { resolveProjectFilterParams } from "renderer/routes/_authenticated/_dashboard/components/ProjectFilter/project-filter-utils"; import { TasksView } from "./components/TasksView"; import { Route as TasksLayoutRoute } from "./layout"; export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({ + // Legacy PR-tab URLs redirect before anything renders — a render-time + // here re-navigated on every layout re-render (react #185). + beforeLoad: ({ search }) => { + if (search.type === "prs") { + throw redirect({ + to: "/pull-requests", + search: { + search: search.search, + projects: search.projects ?? search.project, + state: search.state, + }, + replace: true, + }); + } + }, component: TasksPage, }); @@ -24,25 +39,12 @@ function TasksPage() { () => resolveProjectFilterParams(projects, project, undefined), [projects, project], ); - if (type === "prs") { - return ( - - ); - } return ( here re-navigated on every layout re-render (react #185). export const Route = createFileRoute( "/_authenticated/_dashboard/tasks/pr/$prNumber/", )({ - component: LegacyPullRequestRedirect, + beforeLoad: ({ params, search }) => { + throw redirect({ + to: "/pull-requests/$prNumber", + params: { prNumber: params.prNumber }, + search: { + search: search.search, + project: search.project, + state: search.state, + }, + replace: true, + }); + }, }); - -function LegacyPullRequestRedirect() { - const { prNumber } = Route.useParams(); - const { search, project, state } = TasksLayoutRoute.useSearch(); - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index bacb3b2a741..01414b14746 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -3,7 +3,6 @@ import { Button } from "@superset/ui/button"; import { Spinner } from "@superset/ui/spinner"; import { createFileRoute, - Navigate, Outlet, useLocation, useNavigate, @@ -13,6 +12,7 @@ import { DndProvider } from "react-dnd"; import { HiOutlineWifi } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { Paywall } from "renderer/components/Paywall"; +import { Redirect } from "renderer/components/Redirect"; import { env } from "renderer/env.renderer"; import { useDelayElapsed } from "renderer/hooks/useDelayElapsed"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; @@ -58,12 +58,11 @@ export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, }); -// Hoisted for stable props identity — re-navigates every re-render otherwise (react error #185 loop, #5729) -const signInRedirect = ; +const signInRedirect = ; const createOrganizationRedirect = ( - + ); -const onboardingRedirect = ; +const onboardingRedirect = ; const SESSION_PENDING_TIMEOUT_MS = 15_000; diff --git a/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx index 2c985aa140b..7f19c5c9f53 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/onboarding/layout.tsx @@ -1,13 +1,13 @@ import { ChatServiceProvider } from "@superset/chat-legacy/client"; import { createFileRoute, - Navigate, Outlet, useLocation, useNavigate, } from "@tanstack/react-router"; import { useMemo } from "react"; import { createChatServiceIpcClient } from "renderer/components/Chat/utils/chat-service-client"; +import { Redirect } from "renderer/components/Redirect"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider"; @@ -17,8 +17,7 @@ export const Route = createFileRoute("/_authenticated/onboarding")({ component: OnboardingFlowLayout, }); -// Hoisted for stable props identity — re-navigates every re-render otherwise (react error #185 loop, #5729) -const rootRedirect = ; +const rootRedirect = ; const STEPS = [ { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/page.tsx index 907e8d184e1..18af596c5e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/page.tsx @@ -1,9 +1,7 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated/settings/")({ - component: SettingsPage, + beforeLoad: () => { + throw redirect({ to: "/settings/account", replace: true }); + }, }); - -function SettingsPage() { - return ; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx index e849a338bc6..a0b7b21d353 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/page.tsx @@ -1,29 +1,22 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; type LegacyPresetsSearch = { editPresetId?: string; presetId?: string; }; +// Presets have been merged into Terminal settings export const Route = createFileRoute("/_authenticated/settings/presets/")({ - component: PresetsRedirect, validateSearch: (search: Record): LegacyPresetsSearch => ({ editPresetId: typeof search.editPresetId === "string" ? search.editPresetId : undefined, presetId: typeof search.presetId === "string" ? search.presetId : undefined, }), + beforeLoad: ({ search }) => { + throw redirect({ + to: "/settings/terminal", + search: { editPresetId: search.editPresetId ?? search.presetId }, + replace: true, + }); + }, }); - -// Presets have been merged into Terminal settings -function PresetsRedirect() { - const { editPresetId, presetId } = Route.useSearch(); - return ( - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx index 9f317f8394c..98fa1c16325 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx @@ -1,6 +1,7 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; +import { Redirect } from "renderer/components/Redirect"; export const Route = createFileRoute( "/_authenticated/settings/project/$projectId/cloud/", @@ -12,9 +13,15 @@ function CloudSettingsIndex() { const { projectId } = Route.useParams(); const hasCloudAccess = useFeatureFlagEnabled(FEATURE_FLAGS.CLOUD_ACCESS); + // undefined = flags still loading; deciding then would bounce entitled + // users out of cloud settings. + if (hasCloudAccess === undefined) { + return null; + } + if (!hasCloudAccess) { return ( - re-navigates every re-render otherwise (react error #185 loop, #5729) -const signInRedirect = ; +const signInRedirect = ; const formSchema = z.object({ name: z.string().min(1, "Organization name is required").max(100), diff --git a/apps/desktop/src/renderer/routes/page.tsx b/apps/desktop/src/renderer/routes/page.tsx index e1d6d27415f..7eb2018831c 100644 --- a/apps/desktop/src/renderer/routes/page.tsx +++ b/apps/desktop/src/renderer/routes/page.tsx @@ -1,12 +1,10 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { Redirect } from "renderer/components/Redirect"; export const Route = createFileRoute("/")({ component: RootIndexPage, }); -// Hoisted for stable props identity — re-navigates every re-render otherwise (react error #185 loop, #5729) -const workspaceRedirect = ; - function RootIndexPage() { - return workspaceRedirect; + return ; } diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index 5e1f23af79f..45dd8adf09d 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -7,10 +7,11 @@ import { import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; import { Spinner } from "@superset/ui/spinner"; -import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; +import { Redirect } from "renderer/components/Redirect"; import { env } from "renderer/env.renderer"; import { useDelayElapsed } from "renderer/hooks/useDelayElapsed"; import { track } from "renderer/lib/analytics"; @@ -25,8 +26,7 @@ export const Route = createFileRoute("/sign-in/")({ const LAST_USED_METHOD_KEY = "superset-last-auth-method"; -// Hoisted for stable props identity — re-navigates every re-render otherwise (react error #185 loop, #5729) -const workspaceRedirect = ; +const workspaceRedirect = ; const SESSION_PENDING_TIMEOUT_MS = 15_000; diff --git a/biome.jsonc b/biome.jsonc index b88f2320a77..fdab375bea6 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -114,7 +114,11 @@ "options": { "paths": { "@superset/workspace-fs/host": "Renderer code must stay browser-compatible. Import Node filesystem helpers from the desktop server/host layer instead.", - "@superset/workspace-fs/server": "Renderer code must stay browser-compatible. Import Node filesystem helpers from the desktop server/host layer instead." + "@superset/workspace-fs/server": "Renderer code must stay browser-compatible. Import Node filesystem helpers from the desktop server/host layer instead.", + "@tanstack/react-router": { + "importNames": ["Navigate"], + "message": " re-navigates whenever its props object identity changes — inline usage loops into React error #185 (#5729, SUPER-1814). Use Redirect from renderer/components/Redirect, or `beforeLoad: () => { throw redirect(...) }` for static targets." + } }, "patterns": [ { diff --git a/bun.lock b/bun.lock index 66136ca65ec..4e31a02620b 100644 --- a/bun.lock +++ b/bun.lock @@ -325,11 +325,14 @@ "zustand": "5.0.12", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.2", "@sentry/vite-plugin": "5.4.0", "@superset/typescript": "workspace:*", "@tailwindcss/vite": "4.2.2", "@tanstack/router-cli": "1.167.17", "@tanstack/router-plugin": "1.168.18", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.0", "@types/better-sqlite3": "7.6.13", "@types/bun": "1.3.14", "@types/culori": "4.0.1",