Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Type check
run: pnpm typecheck

- name: Unused dependency check
run: pnpm knip

- name: Unit tests
run: pnpm exec vitest run

Expand Down
15 changes: 15 additions & 0 deletions app/components/Counter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Counter } from "./Counter";

describe("Counter", () => {
it("increments when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);

expect(screen.getByRole("button", { name: /count: 0/i })).toBeInTheDocument();
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button", { name: /count: 1/i })).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions app/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useState } from "react";

// Example interactive component — demonstrates local state and a click handler.
// Its test (Counter.test.tsx) is the canonical `@testing-library/user-event`
// example: simulate a real click and assert the result.
export function Counter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
15 changes: 3 additions & 12 deletions app/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import app from "./index";

describe("api app", () => {
it("serves a mounted route", async () => {
const res = await app.request("/api/example");
it("serves the health check", async () => {
const res = await app.request("/api/health");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Hello from Hono" });
expect(await res.json()).toEqual({ status: "ok" });
});

it("returns a JSON 404 for unknown routes", async () => {
Expand All @@ -14,13 +14,4 @@ describe("api app", () => {
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({ error: "Not Found" });
});

it("validates request bodies (400 on missing field)", async () => {
const res = await app.request("/api/example", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
8 changes: 7 additions & 1 deletion app/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { exampleRoutes } from "./routes/example";
import { healthRoutes } from "./routes/health";

// All API routes are mounted under /api
// This app is handed off from TanStack Start's catch-all API route
const app = new Hono().basePath("/api");

// Mount route groups here
// Health check — a useful liveness probe. Keep it.
app.route("/health", healthRoutes);

// Example route demonstrating request validation with @hono/zod-validator.
// Don't need it? Run `node scripts/remove-example.mjs` to delete the route and
// drop the dependency (see docs/agents/api.md).
app.route("/example", exampleRoutes);

// Consistent JSON for unmatched routes instead of Hono's default text 404.
Expand Down
21 changes: 21 additions & 0 deletions app/server/routes/example.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import app from "../index";

// Tests for the optional example route. `node scripts/remove-example.mjs`
// deletes this file along with the route it covers.
describe("example route", () => {
it("responds on GET /api/example", async () => {
const res = await app.request("/api/example");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Hello from Hono" });
});

it("validates request bodies (400 on missing field)", async () => {
const res = await app.request("/api/example", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
5 changes: 5 additions & 0 deletions app/server/routes/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Hono } from "hono";

// Liveness probe — useful for uptime checks, load balancers, and deploy smoke
// tests. No dependencies and no DB access; always returns 200.
export const healthRoutes = new Hono().get("/", (c) => c.json({ status: "ok" }));
1 change: 1 addition & 0 deletions changelog.d/42-example-routes.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `[propagate]` Added a `GET /api/health` liveness route (kept in scaffolded projects) and made the request-validation example route optional: scaffold no longer strips it, and `node scripts/remove-example.mjs` deletes it plus drops `@hono/zod-validator` for projects that don't want it.
1 change: 1 addition & 0 deletions changelog.d/42-testing-example.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `[propagate]` Added an interactive `Counter` example component with a `@testing-library/user-event` test, demonstrating the full Testing Library + Vitest flow (render → real click → assert). Switched the jest-dom setup import to the Vitest-specific entry (`@testing-library/jest-dom/vitest`).
1 change: 1 addition & 0 deletions changelog.d/42-unused-deps.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `[propagate]` Added an unused-dependency CI check (`pnpm knip`, configured in `knip.json`) that fails when a declared dependency is imported nowhere — the guardrail that would have caught the previously-unused `@tanstack/react-query`. Documented in `docs/agents/dependencies.md`.
5 changes: 5 additions & 0 deletions docs/agents/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ For portable endpoints: webhooks, CRUD, anything consumable outside this fronten
`app/server/routes/example.ts` contains a working Hono route group (`exampleRoutes`).
Refer to it as the canonical pattern.

The app also mounts a `GET /api/health` liveness route
(`app/server/routes/health.ts`), kept by default. If you don't need the
validation example, run `node scripts/remove-example.mjs` to delete it and drop
`@hono/zod-validator`.

### Adding a new Hono route group

1. Create `app/server/routes/your-resource.ts`
Expand Down
23 changes: 23 additions & 0 deletions docs/agents/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,26 @@ Before adding a package:
5. **Dev dependencies stay dev.** Test utilities, type packages, and build tools go in `devDependencies`.

The hard rule from `AGENTS.md` applies: **no new dependencies without checking if the existing stack already covers the need.**

## Unused Dependency Check

CI runs [`knip`](https://knip.dev) via `pnpm knip` to fail the build when a
declared dependency is imported nowhere (or an import has no corresponding
dependency). This is the guardrail that would have caught `@tanstack/react-query`
sitting in the stack unused before it was wired up.

Config lives in `knip.json`. Three repo-specific settings keep false positives at zero:

- **Entry points** list the vinxi / TanStack Start entries (`app/client.tsx`,
`app/ssr.tsx`, `app/router.tsx`, `app/routes/**`, `app/server/index.ts`) and the
standalone scripts — knip can't infer the framework's entrypoints on its own.
- **`tailwindcss` is in `ignoreDependencies`** — it's consumed via
`@import "tailwindcss"` in `app/styles/app.css` and the `@tailwindcss/vite`
plugin, neither of which knip traces, so it would otherwise be a false "unused".
- **The drizzle plugin is disabled** (`"drizzle": false`) so knip doesn't execute
`drizzle.config.ts` (which throws without `DATABASE_URL`). `drizzle-kit` is still
detected via the `db:*` scripts.

Run it locally with `pnpm knip`. If knip flags a dependency you intend to keep
unused, add it to `ignoreDependencies` in `knip.json` and note why in this
section — don't silence the whole check.
16 changes: 16 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"app/client.tsx",
"app/ssr.tsx",
"app/router.tsx",
"app/routes/**/*.tsx",
"app/server/index.ts",
"app.config.ts",
"scripts/labels.mjs",
"scripts/remove-example.mjs"
],
"project": ["app/**/*.{ts,tsx}", "db/**/*.ts", "scripts/*.mjs"],
"ignoreDependencies": ["tailwindcss"],
"drizzle": false
}
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"changelog:check": "node scripts/changelog.mjs check",
"changelog:release": "node scripts/changelog.mjs release",
"typecheck": "tsc --noEmit",
"knip": "knip --include dependencies,unlisted",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
Expand Down Expand Up @@ -47,23 +48,24 @@
"zod": "^3.24.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.0",
"@biomejs/biome": "^1.9.0",
"@playwright/test": "^1.50.0",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-cli": "~1.114.3",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.5.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^2.1.0",
"drizzle-kit": "^0.30.0",
"jsdom": "^26.0.0",
"knip": "^6.20.0",
"lefthook": "^1.11.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite-tsconfig-paths": "^5.1.0",
"@tanstack/router-cli": "~1.114.3",
"lefthook": "^1.11.0",
"vitest": "^2.1.0"
},
"pnpm": {
Expand Down
Loading
Loading