Skip to content

Commit ae85d9f

Browse files
authored
Feat/dashboard wallets loading error pnpm (#588)
* feat: prefetch wallets on sidebar hover Warms the /dashboard/wallets route chunk and its API data as soon as the user hovers or focuses the Wallets sidebar link, via a new deduped wallets prefetch cache (src/lib/walletsPrefetchCache.ts) that useWallets() now reuses on initial mount instead of firing a duplicate request. Adds Vitest/RTL coverage for the hover/focus prefetch behavior and the cache's dedupe/error-recovery logic. * feat: add route-level loading.tsx for dashboard Adds src/app/dashboard/loading.tsx so Next.js shows an accessible skeleton (reusing the existing Skeleton primitive) as the Suspense fallback while any /dashboard/** route segment is loading, instead of a blank screen. Includes Vitest/RTL coverage and a README (diff is under 150 lines) documenting the change and a manual test checklist. * feat: add ErrorBoundary around dashboard pages Adds src/app/dashboard/error.tsx (Next.js segment error boundary) plus a reusable DashboardErrorBoundary component that logs the error and renders the existing ErrorState primitive with a retry action. Because it lives alongside the dashboard layout, the sidebar/topbar stay mounted on error so navigation isn't lost. Includes Vitest/RTL coverage and a README (diff is under 150 lines) with a manual test checklist. * chore: keep pnpm lockfile as the only package lock Enforces pnpm as the sole installer via: a packageManager field + engines block in package.json, a preinstall guard script that fails npm/yarn installs before they can generate a lockfile, .npmrc's package-manager-strict/engine-strict, .gitignore rules for yarn.lock/npm-shrinkwrap.json, and a new CI step that fails the build if a non-pnpm lockfile is present. Includes a Vitest check and a README (diff is under 150 lines) with a manual test checklist.
1 parent 0c258ba commit ae85d9f

19 files changed

Lines changed: 523 additions & 2 deletions

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ jobs:
3030
node-version: "22"
3131
cache: "pnpm"
3232

33+
- name: Verify pnpm lockfile is the only lockfile
34+
run: |
35+
if [ -f package-lock.json ] || [ -f yarn.lock ] || [ -f npm-shrinkwrap.json ]; then
36+
echo "::error::Found a non-pnpm lockfile. This repo only tracks pnpm-lock.yaml."
37+
exit 1
38+
fi
39+
3340
- name: Install dependencies
3441
run: pnpm install --frozen-lockfile
3542

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# dependencies
44
/node_modules
55
/package-lock.json
6+
/npm-shrinkwrap.json
7+
/yarn.lock
68
/.pnp
79
.pnp.*
810
.yarn/*
@@ -11,6 +13,8 @@
1113
!.yarn/releases
1214
!.yarn/versions
1315

16+
# pnpm-lock.yaml is the only package lockfile this repo tracks.
17+
1418
# testing
1519
/coverage
1620
/playwright-report

.npmrc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Enforce the "packageManager" field in package.json so corepack refuses
2+
# to run this project with the wrong pnpm version.
3+
package-manager-strict=true
4+
5+
# Fail installs when the current Node/pnpm doesn't satisfy "engines".
6+
engine-strict=true

ERROR_BOUNDARY_README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# ErrorBoundary around dashboard pages
2+
3+
## What was implemented
4+
5+
- `src/components/dashboard/DashboardErrorBoundary.tsx` — a client
6+
component that logs the caught error (`console.error`, same pattern as
7+
the existing root-level `GlobalErrorBoundary`) and renders the existing
8+
`ErrorState` UI primitive (`src/components/ui/ErrorState.tsx`) with a
9+
"Try again" action wired to Next's `reset()`.
10+
- `src/app/dashboard/error.tsx` — the Next.js App Router segment error
11+
file. Next automatically wraps `src/app/dashboard/layout.tsx`'s children
12+
in an error boundary that renders this component whenever a rendering
13+
error is thrown anywhere under `/dashboard/**` that isn't already caught
14+
by a more specific `error.tsx`.
15+
- `src/components/dashboard/__tests__/DashboardErrorBoundary.test.tsx`
16+
covers: error message rendering, the retry button invoking `reset()`, the
17+
fallback copy when `error.message` is empty, and that the error is logged.
18+
19+
## Why this is scoped correctly
20+
21+
Because `error.tsx` lives in the same segment as `layout.tsx`
22+
(`src/app/dashboard/`), the `DashboardLayout` (sidebar + topbar) stays
23+
mounted when a page throws - only the page content area is replaced with
24+
the error UI. This avoids the "No regressions in closely related dashboard
25+
navigation" failure mode: the sidebar's links (including the wallets
26+
prefetch-on-hover behavior) keep working even while one page is in an error
27+
state, so the user can navigate away without a full reload.
28+
29+
This complements, rather than duplicates, the existing root-level
30+
`src/app/error.tsx` + `GlobalErrorBoundary`, which still catches errors
31+
thrown outside of `/dashboard` (or inside `layout.tsx`/`RootLayout` itself).
32+
33+
## Manual verification checklist
34+
35+
- [ ] Temporarily `throw new Error("test")` inside a dashboard page body,
36+
confirm the dashboard error UI renders with sidebar still visible.
37+
- [ ] Click "Try again" and confirm `reset()` re-renders the segment.
38+
- [ ] Check on a narrow mobile viewport (375px) - error card doesn't
39+
overflow horizontally.
40+
- [ ] Confirm the error is logged to the console for observability.

PNPM_LOCKFILE_README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Keep pnpm lockfile as the only package lock
2+
3+
## What was implemented
4+
5+
- `package.json` — added `"packageManager": "pnpm@9.15.4"` (enables
6+
Corepack to enforce the exact pnpm version) and an `engines` block that
7+
fails npm/yarn with an explicit `please-use-pnpm` message if someone tries
8+
to install with them directly. Added a `preinstall` script that runs
9+
`scripts/verify-pnpm.js`.
10+
- `scripts/verify-pnpm.js` — inspects `npm_config_user_agent` (set by every
11+
package manager) at install time and hard-fails with instructions if the
12+
installer isn't pnpm. This is the first line of defense - it fires before
13+
any dependency resolution happens, so a `npm install` never gets far
14+
enough to generate a `package-lock.json`.
15+
- `.npmrc` — sets `package-manager-strict=true` (Corepack enforces the
16+
`packageManager` field) and `engine-strict=true`.
17+
- `.gitignore` — explicitly ignores `/yarn.lock` and `/npm-shrinkwrap.json`
18+
in addition to the pre-existing `/package-lock.json` rule, so an
19+
accidental lockfile from another package manager can never be committed.
20+
- `.github/workflows/ci.yml` — added a "Verify pnpm lockfile is the only
21+
lockfile" step that fails the build if `package-lock.json`, `yarn.lock`,
22+
or `npm-shrinkwrap.json` exist in the repo, before `pnpm install
23+
--frozen-lockfile` runs.
24+
- `src/lib/__tests__/pnpmLockfile.test.ts` — Vitest coverage asserting
25+
`pnpm-lock.yaml` exists, that no competing lockfiles exist, and that
26+
`package.json` declares a `pnpm@` `packageManager`.
27+
28+
## Why
29+
30+
Multiple lockfiles (e.g. a stray `package-lock.json` committed by someone
31+
running plain `npm install`) cause dependency resolution to silently drift
32+
between contributors/CI and can reintroduce vulnerable or duplicate
33+
transitive versions that `pnpm-lock.yaml` had already deduped/pinned. This
34+
change makes pnpm the only supported installer at three layers: local
35+
install-time (`preinstall` script + Corepack), source control
36+
(`.gitignore`), and CI (explicit lockfile check + `--frozen-lockfile`).
37+
38+
## Manual verification checklist
39+
40+
- [ ] Run `npm install` locally - it should fail immediately with the
41+
"This repository only supports pnpm" message.
42+
- [ ] Run `pnpm install` - it should proceed normally.
43+
- [ ] Confirm CI's new "Verify pnpm lockfile is the only lockfile" step
44+
passes on a clean checkout.
45+
- [ ] `pnpm test -- pnpmLockfile` passes locally.

ROUTE_LOADING_README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Route-level loading.tsx for /dashboard
2+
3+
## What was implemented
4+
5+
- `src/app/dashboard/loading.tsx` — a Next.js App Router segment loading
6+
file that Next automatically wraps around `src/app/dashboard/layout.tsx`'s
7+
children in a `<Suspense>` boundary. It renders while a dashboard route
8+
segment (and any nested segment without its own `loading.tsx`, e.g.
9+
`/dashboard/users`) is being fetched/streamed in on navigation.
10+
- Reuses the existing `Skeleton` primitive (`src/components/ui/Skeleton.tsx`)
11+
so the placeholder matches the visual language already used elsewhere
12+
(e.g. `src/app/demo/dashboard/loading.tsx`, `WalletTableSkeleton`).
13+
- Marked with `role="status"`, `aria-busy="true"`, `aria-live="polite"`, and a
14+
screen-reader-only label so assistive tech announces the loading state
15+
(matches the accessibility pattern already used in `WalletDetailSkeleton`).
16+
- `src/app/dashboard/__tests__/loading.test.tsx` — Vitest/RTL coverage
17+
asserting the accessible status region and that skeleton placeholders are
18+
rendered instead of empty content.
19+
20+
## Why this doesn't affect existing pages
21+
22+
Pages like `/dashboard/wallets` already manage their own client-side
23+
loading/empty/error states via `useWallets()`. The new `loading.tsx` only
24+
governs the Next.js navigation-time Suspense fallback (i.e. the moment
25+
between clicking a sidebar link and the new route's JS/RSC payload
26+
resolving) - it does not replace or conflict with in-page data-fetching
27+
states.
28+
29+
## Manual verification checklist
30+
31+
- [ ] Throttle network in devtools, navigate between `/dashboard` and a
32+
nested route (e.g. `/dashboard/users`) - skeleton briefly appears.
33+
- [ ] Confirm sidebar/topbar remain visible during the loading state (layout
34+
stays mounted since `loading.tsx` sits below `layout.tsx`).
35+
- [ ] Check on a narrow mobile viewport (375px) - skeleton layout doesn't
36+
overflow horizontally.
37+
- [ ] Verify with a screen reader that the loading state is announced.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"node": ">=22"
77
},
88
"scripts": {
9+
"preinstall": "node ./scripts/verify-pnpm.js",
910
"dev": "next dev",
1011
"build": "next build",
1112
"start": "next start",

scripts/verify-pnpm.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Fails fast if this project is being installed with npm or yarn.
4+
* pnpm-lock.yaml is the single source of truth for dependency
5+
* resolution here - a stray package-lock.json/yarn.lock causes
6+
* dependency drift between contributors and CI.
7+
*/
8+
9+
const userAgent = process.env.npm_config_user_agent || "";
10+
11+
if (!userAgent.startsWith("pnpm")) {
12+
console.error("\n ⛔ This repository only supports pnpm.\n");
13+
console.error(
14+
` Detected package manager: ${userAgent.split("/")[0] || "unknown"}`,
15+
);
16+
console.error(" Please run: pnpm install\n");
17+
console.error(
18+
" If pnpm isn't installed: corepack enable && corepack prepare pnpm@9.15.4 --activate\n",
19+
);
20+
process.exit(1);
21+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import DashboardRouteLoading from "../loading";
4+
5+
describe("DashboardRouteLoading", () => {
6+
it("renders an accessible busy status region", () => {
7+
render(<DashboardRouteLoading />);
8+
9+
const status = screen.getByRole("status", { name: /loading dashboard/i });
10+
expect(status).toHaveAttribute("aria-busy", "true");
11+
expect(status).toHaveAttribute("aria-live", "polite");
12+
});
13+
14+
it("renders skeleton placeholders instead of empty content", () => {
15+
render(<DashboardRouteLoading />);
16+
17+
expect(screen.getAllByTestId("skeleton").length).toBeGreaterThan(0);
18+
});
19+
});

src/app/dashboard/error.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use client";
2+
3+
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary";
4+
5+
export default function DashboardError({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
return <DashboardErrorBoundary error={error} reset={reset} />;
13+
}

0 commit comments

Comments
 (0)