engsys 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/core/agents/aaron.md +152 -0
- package/core/agents/bert.md +115 -0
- package/core/agents/isabelle.md +136 -0
- package/core/agents/jody.md +150 -0
- package/core/agents/leith.md +111 -0
- package/core/agents/marcelo.md +282 -0
- package/core/agents/melvin.md +101 -0
- package/core/agents/nyx.md +152 -0
- package/core/agents/otto.md +168 -0
- package/core/agents/patricia.md +283 -0
- package/core/commands/design-audit-local.md +155 -0
- package/core/commands/design-audit.md +235 -0
- package/core/commands/design-critique.md +96 -0
- package/core/commands/file-issue.md +22 -0
- package/core/commands/generate-project.md +45 -0
- package/core/commands/implement-issue.md +37 -0
- package/core/commands/implement-project.md +40 -0
- package/core/commands/naturalize.md +61 -0
- package/core/commands/pre-push.md +29 -0
- package/core/commands/prep-review-collect.md +130 -0
- package/core/commands/prep-review-finalize.md +121 -0
- package/core/commands/prep-review-publish.md +113 -0
- package/core/commands/prep-review.md +65 -0
- package/core/commands/project-closeout.md +25 -0
- package/core/skills/agentic-eval/SKILL.md +195 -0
- package/core/skills/chrome-devtools/SKILL.md +97 -0
- package/core/skills/code-review/SKILL.md +26 -0
- package/core/skills/gh-cli/SKILL.md +2202 -0
- package/core/skills/git-commit/SKILL.md +124 -0
- package/core/skills/git-workflow-agents/SKILL.md +462 -0
- package/core/skills/git-workflow-agents/reference.md +220 -0
- package/core/skills/github-actions/SKILL.md +190 -0
- package/core/skills/github-issues/SKILL.md +154 -0
- package/core/skills/llm-structured-outputs/SKILL.md +323 -0
- package/core/skills/llm-structured-outputs/references/provider-details.md +392 -0
- package/core/skills/pre-push/SKILL.md +115 -0
- package/core/skills/refactor/SKILL.md +645 -0
- package/core/skills/web-design-reviewer/SKILL.md +371 -0
- package/core/skills/webapp-testing/SKILL.md +127 -0
- package/core/skills/webapp-testing/test-helper.js +56 -0
- package/core/templates/CLAUDE.md.tmpl +98 -0
- package/core/templates/adr-template.md +67 -0
- package/core/templates/gh-issue-templates/bug.md +39 -0
- package/core/templates/gh-issue-templates/content.md +42 -0
- package/core/templates/gh-issue-templates/enhancement.md +36 -0
- package/core/templates/gh-issue-templates/feature.md +39 -0
- package/core/templates/gh-issue-templates/infrastructure.md +41 -0
- package/core/templates/post-edit-reminders.sh.tmpl +19 -0
- package/core/templates/settings.json.tmpl +90 -0
- package/core/templates/settings.local.json.tmpl +3 -0
- package/core/workflows/agent-implementation-workflow.md +346 -0
- package/core/workflows/generate-project.md +258 -0
- package/core/workflows/implement-project-workflow.md +190 -0
- package/core/workflows/issue-tracking.md +89 -0
- package/core/workflows/project-closeout-ceremony.md +77 -0
- package/core/workflows/review-workflow.md +266 -0
- package/engsys.config.example.yaml +46 -0
- package/install +202 -0
- package/lessons-library/README.md +80 -0
- package/lessons-library/async-callbacks-verify-liveness.md +15 -0
- package/lessons-library/change-isnt-done-until-every-surface-updated.md +15 -0
- package/lessons-library/claim-then-act-for-irreversible-ops.md +16 -0
- package/lessons-library/co-commit-entangled-work.md +15 -0
- package/lessons-library/dependabot-triage-playbook.md +17 -0
- package/lessons-library/deploy-by-digest-and-verify-the-running-revision.md +15 -0
- package/lessons-library/enforce-your-guarantee-at-your-boundary.md +16 -0
- package/lessons-library/gate-changes-on-measurement-not-vibes.md +15 -0
- package/lessons-library/iac-first-no-console-changes.md +15 -0
- package/lessons-library/independent-objective-review-gate.md +15 -0
- package/lessons-library/keep-an-immutable-source-of-truth.md +15 -0
- package/lessons-library/long-agent-runs-checkpoint-not-poll.md +15 -0
- package/lessons-library/model-identity-with-stable-ids-and-provenance.md +15 -0
- package/lessons-library/operator-choices-are-first-class.md +15 -0
- package/lessons-library/prefer-tool-enforced-structured-output.md +15 -0
- package/lessons-library/prove-causation-before-acting.md +15 -0
- package/lessons-library/re-read-state-before-acting.md +14 -0
- package/lessons-library/read-layer-tolerates-unbackfilled-rows.md +15 -0
- package/lessons-library/shell-safety-pipefail-and-validate-before-teardown.md +14 -0
- package/lessons-library/shift-correctness-left-and-distrust-false-greens.md +15 -0
- package/lessons-library/stray-control-bytes-hide-changes.md +14 -0
- package/lessons-library/tests-can-assert-the-bug.md +15 -0
- package/lessons-library/verify-ground-truth-not-reports.md +15 -0
- package/lessons-library/worktrees-need-bootstrap-from-origin-main.md +15 -0
- package/lib/commands.js +356 -0
- package/lib/generate-team-avatars.mjs +251 -0
- package/lib/manifest.js +155 -0
- package/lib/render.js +135 -0
- package/lib/selftest.js +90 -0
- package/lib/util.js +89 -0
- package/lib/yaml.js +156 -0
- package/optional-agents/gary.md +86 -0
- package/optional-agents/jos.md +136 -0
- package/optional-agents/sandy.md +101 -0
- package/optional-agents/steve.md +161 -0
- package/package.json +43 -0
- package/stacks/cloud/aws/claude.fragment.md +17 -0
- package/stacks/cloud/aws/settings.fragment.json +39 -0
- package/stacks/cloud/aws/skills/aws-deployment-preflight/SKILL.md +165 -0
- package/stacks/cloud/aws/skills/cloud-architecture-aws/SKILL.md +265 -0
- package/stacks/cloud/azure/claude.fragment.md +17 -0
- package/stacks/cloud/azure/settings.fragment.json +45 -0
- package/stacks/cloud/azure/skills/azure-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/azure/skills/cloud-architecture-azure/SKILL.md +211 -0
- package/stacks/cloud/cloudflare/claude.fragment.md +21 -0
- package/stacks/cloud/cloudflare/settings.fragment.json +31 -0
- package/stacks/cloud/cloudflare/skills/cloud-architecture-cloudflare/SKILL.md +294 -0
- package/stacks/cloud/cloudflare/skills/cloudflare-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/gcp/claude.fragment.md +17 -0
- package/stacks/cloud/gcp/settings.fragment.json +40 -0
- package/stacks/cloud/gcp/skills/cloud-architecture-gcp/SKILL.md +208 -0
- package/stacks/cloud/gcp/skills/gcp-deployment-preflight/SKILL.md +137 -0
- package/stacks/db/mongo/skills/mongo-conventions/SKILL.md +96 -0
- package/stacks/db/prisma/claude.fragment.md +49 -0
- package/stacks/db/prisma/skills/docker-database-package-copy/SKILL.md +44 -0
- package/stacks/db/prisma/skills/prisma-conventions/SKILL.md +37 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/SKILL.md +184 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/official-links.md +53 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/SKILL.md +197 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/official-links.md +45 -0
- package/stacks/iac/bicep/claude.fragment.md +14 -0
- package/stacks/iac/bicep/settings.fragment.json +20 -0
- package/stacks/iac/bicep/skills/iac-bicep/SKILL.md +113 -0
- package/stacks/iac/cdk/claude.fragment.md +14 -0
- package/stacks/iac/cdk/settings.fragment.json +23 -0
- package/stacks/iac/cdk/skills/iac-cdk/SKILL.md +104 -0
- package/stacks/iac/terraform/claude.fragment.md +13 -0
- package/stacks/iac/terraform/settings.fragment.json +25 -0
- package/stacks/iac/terraform/skills/iac-terraform/SKILL.md +93 -0
- package/stacks/iac/terraform/skills/terraform-conventions/SKILL.md +87 -0
- package/stacks/lang/kotlin/skills/android-testing/SKILL.md +263 -0
- package/stacks/lang/kotlin/skills/jetpack-compose/SKILL.md +264 -0
- package/stacks/lang/kotlin/skills/kotlin-coroutines/SKILL.md +329 -0
- package/stacks/lang/python/skills/python-conventions/SKILL.md +61 -0
- package/stacks/lang/shell/skills/shell-scripting/SKILL.md +110 -0
- package/stacks/lang/swift/skills/swift-concurrency/SKILL.md +423 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/approachable-concurrency.md +80 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/concurrency-patterns.md +233 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/swiftui-concurrency.md +187 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/synchronization-primitives.md +341 -0
- package/stacks/lang/swift/skills/swift-testing/SKILL.md +497 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-advanced.md +106 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-patterns.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/SKILL.md +334 -0
- package/stacks/lang/swift/skills/swiftdata/references/core-data-coexistence.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-advanced.md +975 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-queries.md +675 -0
- package/stacks/lang/swift/skills/swiftui-patterns/SKILL.md +371 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/architecture-patterns.md +486 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/deprecated-migration.md +1097 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/design-polish.md +780 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/platform-and-sharing.md +696 -0
- package/stacks/lang/typescript/skills/typescript-conventions/SKILL.md +91 -0
- package/stacks/platform/android/claude.fragment.md +40 -0
- package/stacks/platform/android/hooks/pre-push-gradle.sh +70 -0
- package/stacks/platform/android/settings.fragment.json +13 -0
- package/stacks/platform/android/skills/android-build-conventions/SKILL.md +247 -0
- package/stacks/platform/ios/claude.fragment.md +24 -0
- package/stacks/platform/ios/hooks/pre-push-xcodebuild.sh +82 -0
- package/stacks/platform/ios/settings.fragment.json +21 -0
- package/stacks/platform/ios/skills/xcodebuildmcp-simulator-logs/SKILL.md +76 -0
- package/stacks/platform/web/skills/frontend-testing/SKILL.md +246 -0
- package/stacks/platform/web/skills/react-conventions/SKILL.md +261 -0
- package/stacks/platform/web/skills/web-platform-conventions/SKILL.md +55 -0
- package/stacks/tooling/issue-tracker-github/claude.fragment.md +10 -0
- package/stacks/tooling/issue-tracker-github/settings.fragment.json +24 -0
- package/stacks/tooling/issue-tracker-github/skills/issue-tracker-github/SKILL.md +278 -0
- package/stacks/tooling/issue-tracker-linear/claude.fragment.md +17 -0
- package/stacks/tooling/issue-tracker-linear/settings.fragment.json +9 -0
- package/stacks/tooling/issue-tracker-linear/skills/issue-tracker-linear/SKILL.md +183 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: frontend-testing
|
|
3
|
+
description: Frontend test discipline for this repo — Vitest 4, Testing Library (RTL / React Testing Library), and Playwright E2E for React / Next.js apps. Use when writing or reviewing Vitest specs, vi.mock factories, fake-timer tests, RTL component tests, Radix portal tests, or Playwright E2E specs and their CI matrix wiring. Covers vi.hoisted mock-factory hoisting, keeping every vi.mock factory in sync, Vitest 4 pool config (fileParallelism), the fake-timers act() pattern, RTL accessible-query discipline, Radix-portal JSDOM limits, useLayoutEffect for test-observable accumulation, and Playwright spec-authoring footguns / CI-matrix registration / axe E2E.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Frontend Testing (Vitest + Testing Library + Playwright)
|
|
7
|
+
|
|
8
|
+
Applies to: unit/component specs (`*.spec.{ts,tsx}`) and Playwright E2E (`e2e/**/*.spec.ts`) in the repo's web frontends. Stack assumed: Vitest 4.x, `@testing-library/react`, the `testing-library` + `react-hooks` eslint plugins, Radix UI, Playwright in a CI matrix against booted services.
|
|
9
|
+
|
|
10
|
+
> Naturalize: confirm the Vitest version, the eslint test config, and the Playwright CI job/matrix in `CLAUDE.md`. Paths below (`services/dashboard/...`, `.github/workflows/...`) are illustrative.
|
|
11
|
+
|
|
12
|
+
This skill is the React/Vitest/Playwright-specific **mechanism**. The cross-project **principles** behind it — "a change isn't done until every surface is updated", "register checks or they're silent gaps", "shift correctness left and distrust false greens" — live in the engsys `lessons-library/`. Here we carry the concrete test-tooling shape.
|
|
13
|
+
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- [Vitest: mock factories](#vitest-mock-factories)
|
|
17
|
+
- [Vitest 4: pool config and gated suites](#vitest-4-pool-config-and-gated-suites)
|
|
18
|
+
- [Fake timers: the act() pattern](#fake-timers-the-act-pattern)
|
|
19
|
+
- [RTL: accessible queries, not container traversal](#rtl-accessible-queries-not-container-traversal)
|
|
20
|
+
- [RTL: double render and Radix portals](#rtl-double-render-and-radix-portals)
|
|
21
|
+
- [RTL: useLayoutEffect for test-observable accumulation](#rtl-uselayouteffect-for-test-observable-accumulation)
|
|
22
|
+
- [Playwright: register every spec in the CI matrix](#playwright-register-every-spec-in-the-ci-matrix)
|
|
23
|
+
- [Playwright: migrate the local DB before E2E](#playwright-migrate-the-local-db-before-e2e)
|
|
24
|
+
- [Playwright: assert containers for services not booted in CI](#playwright-assert-containers-for-services-not-booted-in-ci)
|
|
25
|
+
- [Playwright: spec-authoring footguns](#playwright-spec-authoring-footguns)
|
|
26
|
+
- [Playwright: strict mode and table rows](#playwright-strict-mode-and-table-rows)
|
|
27
|
+
- [Playwright: whole-document axe finds real a11y](#playwright-whole-document-axe-finds-real-a11y)
|
|
28
|
+
- [Playwright: copy / flow changes break E2E](#playwright-copy--flow-changes-break-e2e)
|
|
29
|
+
- [Review checklist](#review-checklist)
|
|
30
|
+
|
|
31
|
+
## Vitest: mock factories
|
|
32
|
+
|
|
33
|
+
**`vi.mock` factories are hoisted above `const`/`let`.** A spy declared with `const mockFn = vi.fn()` *before* a `vi.mock` factory that references it throws `Cannot access 'mockFn' before initialization` at runtime — the factory is hoisted above the declaration. Linters don't catch it. Wrap such spies in `vi.hoisted()`:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Wrong — ReferenceError at runtime
|
|
37
|
+
const mockCreateJob = vi.fn();
|
|
38
|
+
vi.mock("@/lib/api-client", () => ({ signalsExportApi: { createJob: mockCreateJob } }));
|
|
39
|
+
|
|
40
|
+
// Right — vi.hoisted runs before the factory
|
|
41
|
+
const { mockCreateJob } = vi.hoisted(() => ({ mockCreateJob: vi.fn() }));
|
|
42
|
+
vi.mock("@/lib/api-client", async (importOriginal) => {
|
|
43
|
+
const actual = await importOriginal<typeof import("@/lib/api-client")>();
|
|
44
|
+
return { ...actual, signalsExportApi: { createJob: mockCreateJob } };
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**When you add an export to a mocked module, update EVERY `vi.mock` factory of it — in the same commit.** A factory replaces the whole module; a missing new export is `undefined` (or throws `No "X" export is defined on the mock`), and *pre-existing* specs go red in code you only added an export to. Use `importOriginal` and spread `...actual` so real exports survive and you only override what you need.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
vi.mock("@app/database", async (importOriginal) => {
|
|
52
|
+
const actual = await importOriginal<typeof import("@app/database")>();
|
|
53
|
+
return { ...actual, prisma: prismaMock }; // keep the rest real
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Grep for mock factories of a path **before** running the suite, not after it fails:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
grep -rl 'vi.mock("@/lib/access"' --include='*.spec.*' services/dashboard
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
When a new export reads a model the prisma mock doesn't define, add that model returning the "no override" row (`findUnique: vi.fn().mockResolvedValue(null)`) so pre-change behavior holds. Run **every** consuming service's full suite after a cross-cutting shared-package change — the break is in the OLD specs.
|
|
64
|
+
|
|
65
|
+
## Vitest 4: pool config and gated suites
|
|
66
|
+
|
|
67
|
+
`poolOptions.forks.singleFork` was **removed in Vitest 4** — it's replaced by the top-level `fileParallelism`. CodeRabbit may cite v2/v3 doc URLs that still list `singleFork`; trust the installed package's types, not the doc URL.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// Vitest 4 — correct: all files run sequentially in one pool process
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
test: { pool: "forks", fileParallelism: false },
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `fileParallelism: false` for E2E specs that depend on ordered external-service state and for suites that manage their own concurrency. Verify the option exists in your install:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
grep -n "singleFork\|fileParallelism" node_modules/vitest/dist/chunks/*.d.ts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Separate configs for gated suites.** When a suite needs external infra (live DB, LLM key, docker stack), give it its own config so plain `pnpm test` never triggers it: `vitest.config.ts` (unit), `vitest.e2e.config.ts`, `vitest.ai-regression.config.ts`, with matching `test:e2e` / `test:ai-regression` scripts. Put the env gate (`if (!process.env.INGESTION_E2E) return`) in the spec so `pnpm test` skips the whole file rather than silently passing.
|
|
83
|
+
|
|
84
|
+
## Fake timers: the act() pattern
|
|
85
|
+
|
|
86
|
+
Advancing fake timers fires `setTimeout` callbacks synchronously, which call `setState` inside the hook. Without `act`, React queues but doesn't flush the update and the assertion sees stale state. But `testing-library/no-unnecessary-act` (error) flags any `act()` wrapping an RTL utility.
|
|
87
|
+
|
|
88
|
+
Resolution: import `act` from **`react`** (not `@testing-library/react`) and wrap **only** the `vi.advanceTimersByTime()` call — never an RTL utility in the same `act` scope.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { act } from "react"; // NOT @testing-library/react
|
|
92
|
+
import { renderHook } from "@testing-library/react";
|
|
93
|
+
|
|
94
|
+
function advanceTimers(ms: number) {
|
|
95
|
+
act(() => { vi.advanceTimersByTime(ms); }); // act wraps only the timer advance
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it("debounce fires after quiet period", () => {
|
|
99
|
+
const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 250), {
|
|
100
|
+
initialProps: { v: "" },
|
|
101
|
+
});
|
|
102
|
+
rerender({ v: "a" });
|
|
103
|
+
advanceTimers(250);
|
|
104
|
+
expect(result.current).toBe("a");
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Don't: `act(() => { rerender(...); vi.advanceTimersByTime(50); })` (lint error — rerender inside act), and don't advance timers without `act` (state not flushed).
|
|
109
|
+
|
|
110
|
+
## RTL: accessible queries, not container traversal
|
|
111
|
+
|
|
112
|
+
The `testing-library` eslint plugin (`no-container`, `no-node-access`, error) forbids `container.querySelector(...)` / `element.querySelectorAll(...)`. Use `screen.*` accessible queries:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
expect(screen.queryByTestId("my-element")).toBeNull(); // not container.querySelector
|
|
116
|
+
expect(screen.getAllByTestId("row").length).toBeGreaterThan(0); // not querySelectorAll
|
|
117
|
+
expect(screen.queryAllByRole("button")).toHaveLength(0); // not querySelectorAll("button,...")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Only exception: **security assertions** with no accessible query (checking no `<script>`/`iframe[srcdoc]` was injected by an XSS payload) — use `// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access` with a rationale comment. (`container.innerHTML` is property access, not traversal, and is allowed.)
|
|
121
|
+
|
|
122
|
+
## RTL: double render and Radix portals
|
|
123
|
+
|
|
124
|
+
**Two `render()` calls in one `it()` accumulate DOM** — RTL appends, both renders coexist, and `getByTestId` throws "Found multiple elements". RTL runs `cleanup()` after each test (`afterEach`), not between renders within one. Split into separate `it()` blocks, or call `cleanup()` between renders.
|
|
125
|
+
|
|
126
|
+
**Radix portal items are unreachable in JSDOM.** `DropdownMenu`/`Select`/`Popover`/`Tooltip`/`Dialog` content renders in a portal via pointer/popover APIs JSDOM doesn't implement — clicking the trigger never opens the content, so `getByTestId("the-item")` throws. Don't simulate the interaction; **test the invariant**:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Wrong — portal item never mounts in JSDOM
|
|
130
|
+
fireEvent.click(screen.getByTestId("sort-dropdown"));
|
|
131
|
+
fireEvent.click(screen.getByTestId("sort-option-movers")); // throws
|
|
132
|
+
|
|
133
|
+
// Right — assert the invariant (sort is client-side, never a query param)
|
|
134
|
+
it("sort param is never passed to useThemeGrid", () => {
|
|
135
|
+
renderTable({ sort: "volume" });
|
|
136
|
+
for (const call of mockUseThemeGrid.mock.calls) {
|
|
137
|
+
expect(call[0]).not.toHaveProperty("sort");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For "correct option selected", check the trigger's visible label / `aria-label`. The codebase pattern for unit-testing Radix Select is to mock the whole Select component. The real interaction is the job of Playwright E2E (real browser).
|
|
143
|
+
|
|
144
|
+
## RTL: useLayoutEffect for test-observable accumulation
|
|
145
|
+
|
|
146
|
+
When a component accumulates data across fetches (e.g. "Show more" appends rows), `useEffect` fires **asynchronously** and is not flushed by the `act()` that wraps `render()` — so `accumulated` stays empty and queries time out even though the mock data was synchronous. Use **`useLayoutEffect`** (fires synchronously inside RTL's `act`) with **`useReducer`** for correct multi-action sequencing:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
const [accumulated, dispatchAcc] = React.useReducer(accReducer, []);
|
|
150
|
+
React.useLayoutEffect(() => {
|
|
151
|
+
if (data?.data) dispatchAcc({ type: "merge", items: data.data });
|
|
152
|
+
}, [data?.data]);
|
|
153
|
+
|
|
154
|
+
// Guard reset against the spurious initial-mount fire (ordering wipes page 1 otherwise):
|
|
155
|
+
const prevIdRef = React.useRef(metricId);
|
|
156
|
+
React.useLayoutEffect(() => {
|
|
157
|
+
if (prevIdRef.current !== metricId) { prevIdRef.current = metricId; dispatchAcc({ type: "reset" }); }
|
|
158
|
+
}, [metricId]);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The ref-during-render alternative is `react-hooks/refs`-illegal; `useLayoutEffect + useReducer` is the lint-clean answer. Use it whenever an accumulating, RTL-tested component needs its accumulated state visible in assertions without `waitFor`.
|
|
162
|
+
|
|
163
|
+
## Playwright: register every spec in the CI matrix
|
|
164
|
+
|
|
165
|
+
A new Playwright spec that isn't in the CI matrix is theater — it passes locally forever and **never runs in CI**, a silent gap indistinguishable from coverage. Writing the spec **includes registering it in the matrix, in the same commit / AC**. Treat the matrix entry like an import: the spec doesn't exist until something runs it. Reviewers: any PR adding `e2e/*.spec.ts` must show a matching workflow/matrix diff (or a verified glob that picks it up).
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
ls services/dashboard/e2e/*.spec.ts | xargs -n1 basename
|
|
169
|
+
grep -ni "playwright" .github/workflows/services-ci.yml
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Playwright: migrate the local DB before E2E
|
|
173
|
+
|
|
174
|
+
After a migration-bearing phase merges, local Postgres lacks the new migrations — the next phase's E2E `beforeAll` seed throws Prisma `P2022` and the whole spec aborts (zero tests run), which looks like a flake but is an environment gap. Apply migrations locally first, and source env (`JWT_SECRET` etc.) before any local Playwright run:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pnpm db:migrate:local # prod-safe, localhost-hardcoded
|
|
178
|
+
set -a && source services/dashboard/.env.local && set +a
|
|
179
|
+
cd services/dashboard && pnpm playwright test e2e/<spec>.spec.ts --project=chromium
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A `PUSH_OVERRIDE`'d E2E is **not** verified — confirm the specific changed specs go green in CI (`gh pr checks <n> --watch`), not just the unit gate. An unverified E2E once hid a real Radix-Select feature bug because it was the only test exercising the flow.
|
|
183
|
+
|
|
184
|
+
## Playwright: assert containers for services not booted in CI
|
|
185
|
+
|
|
186
|
+
The CI Playwright stack boots only some services (e.g. api-gateway + dashboard, not Insights). For pages whose data is fetched client-side from a **not-booted** service, you can only assert the **container / loading state** that renders immediately on mount — **not** its data-dependent children, which spin forever.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
await expect(page.getByTestId("all-themes-grid")).toBeVisible(); // container — renders immediately
|
|
190
|
+
// NOT: await expect(page.getByTestId("theme-card")).toBeVisible(); // data from Insights — never loads in CI
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Before asserting data content: trace the hook → API call → proxy controller; if the route proxies to a not-booted service, assert container only and document why. When the UX *depends* on that service's data (a strip that renders from an embedded projection field), the spec's `page.route()` interception must supply that service's **full data contract** for the branch under test — not just the fields you happen to need locally — or CI stubs an incomplete fixture and the UX silently doesn't render. "Passes locally" is never the gate.
|
|
194
|
+
|
|
195
|
+
## Playwright: spec-authoring footguns
|
|
196
|
+
|
|
197
|
+
- **No self-nested locators.** After `const panel = page.getByTestId("widget-config-panel")`, a `panel.getByTestId("widget-config-panel")` searches *inside* panel and finds nothing. Query a **child** testid/role instead.
|
|
198
|
+
- **`domcontentloaded`, not `networkidle`, for routes with background retries.** Any route whose layout fires hooks to services that are down in CI never reaches `networkidle` (requests retry forever) and times out. Use `waitUntil: "domcontentloaded"`. Reserve `networkidle` for fully self-contained pages.
|
|
199
|
+
- **One-time setup in `beforeAll`, not `beforeEach`.** A ~60s `seedScenario()` in `beforeEach` blows the per-test budget. Use `beforeAll` for read-only suites; `beforeEach` only when each test needs fresh state. Sanity: `setupCost × testCount` under ~80s.
|
|
200
|
+
- **Hydrate before clicking.** A click on an SSR-rendered tab/control before React hydrates is a silent no-op (`toBeVisible`/`networkidle` don't prove hydration). Interact with a controlled input first (`fill`/`selectOption` auto-wait for actionability and double as a hydration gate), then click. Use `await expect(page).toHaveURL(/pattern/)` (auto-retries) — not synchronous `page.url()`, which reads before navigation commits.
|
|
201
|
+
- **`forceMount` panels that own cross-tab effects/state.** A custom `TabsContent` that `return null`s inactive panels unmounts effects/state a panel must keep while another tab is active — a product bug, not just a test issue. Keep it mounted via `hidden={!isSelected}`.
|
|
202
|
+
- **Stub the LANDING panel's on-mount fetches**, not only the panel under test — an unmocked background fetch on the initially-active panel can stall a later tab switch.
|
|
203
|
+
- **Exact matchers in negative assertions.** Broad regexes in absence checks match unintended controls (false failures) or miss renamed ones (silent false passes). Anchor: `/^confirm kpi$/i` or testids. Establish the page surface first so "absent" can't mean "never rendered".
|
|
204
|
+
|
|
205
|
+
## Playwright: strict mode and table rows
|
|
206
|
+
|
|
207
|
+
`page.getByText("Acme Corp").click()` violates strict mode — `getByText` matches every element whose subtree contains the text (`<tr>`, `<td>`, `<div>`, `<p>`). With `workers: 1` it's a hard error, not a flake. Target the row by role or testid:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
await page.getByRole("row", { name: /Acme Corp/i }).click(); // exactly one row
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Related: Radix tooltips render **two** `role="tooltip"` nodes — bare `getByRole("tooltip")` violates strict mode; use `.first()` or an exact name.
|
|
214
|
+
|
|
215
|
+
## Playwright: whole-document axe finds real a11y
|
|
216
|
+
|
|
217
|
+
Component-level `jest-axe` (mounted in isolation, JSDOM) **structurally cannot** see real CSS-token contrast as rendered, focus order, dialog trapping, opacity-dimmed text, or Radix's portal/aria id plumbing. A **whole-document** axe E2E (wcag2a+2aa) against the running app catches real shipped violations component-axe misses — `Badge variant="success"` white-on-green at 2.3:1, `text-muted-foreground/60` opacity-dimmed text, a custom `id` on a Radix `Dialog.Title` that overrides Radix's `aria-labelledby` wiring, `<label>` with no `htmlFor`/`id` pairing.
|
|
218
|
+
|
|
219
|
+
Treat a whole-document axe failure as a **real product bug** to fix at the source (design token / markup), not a reason to weaken the audit. Read the axe `fgColor`/`bgColor`/`message` and fix the **exact** element in the **state the seed renders** (it often flags a placeholder, not the snippet). Run the whole-page scan against the rebuilt bundle when introducing composite ARIA (treegrid/tablist/menu). When scoping a gate to a feature's subtree, exclude specific rule ids and file a tracking issue — don't drop whole rule categories. Never use `opacity-*` to soften text that must meet AA.
|
|
220
|
+
|
|
221
|
+
## Playwright: copy / flow changes break E2E
|
|
222
|
+
|
|
223
|
+
`pnpm test` runs **Vitest only** — E2E is a separate runner against the full stack. A green unit suite can sit alongside an E2E `getByRole('heading', { name: /old copy/i })` that times out. When a change touches headings/CTA labels/option names **or the interaction FLOW** (a new gating step, confirm dialog, required control, re-routed commit), grep the specs and update locators in the **same PR**:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
grep -rnE "Welcome to <App>|Get started|<old label>" <app>/e2e/
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Use substring regexes that dodge punctuation (`/set up your first workspace/i`). A renamed label is an API change to the test suite. Note derived strings reuse labels (a `${col.label} ${verb}` aria-label breaks too — grep every occurrence, not just the header). And remember separate Playwright surfaces run in their own CI job that the default precheck may NOT cover — grep them too.
|
|
230
|
+
|
|
231
|
+
## Review checklist
|
|
232
|
+
|
|
233
|
+
- [ ] Spies referenced in a `vi.mock` factory are wrapped in `vi.hoisted()`
|
|
234
|
+
- [ ] Every `vi.mock` factory of a module updated when an export is added (same commit; `importOriginal` + spread)
|
|
235
|
+
- [ ] Vitest 4 uses `fileParallelism: false` (not `poolOptions.forks.singleFork`); gated suites have their own config + spec-level env gate
|
|
236
|
+
- [ ] Fake-timer tests wrap only `vi.advanceTimersByTime` in `act` from `react`
|
|
237
|
+
- [ ] RTL uses `screen.*` queries; no `container.querySelector` (except justified security checks)
|
|
238
|
+
- [ ] No double `render()` per test; Radix portal tests assert the invariant, not the interaction
|
|
239
|
+
- [ ] Accumulate-across-fetches uses `useLayoutEffect` + `useReducer` to be test-observable
|
|
240
|
+
- [ ] Every new Playwright spec registered in the CI matrix in the same commit
|
|
241
|
+
- [ ] Local DB migrated + env sourced before local E2E; CI legs confirmed green (not just unit)
|
|
242
|
+
- [ ] Data assertions only for services booted in CI; container/loading otherwise; interceptions supply the full contract
|
|
243
|
+
- [ ] No self-nested locators; `domcontentloaded` for background-retry routes; `beforeAll` for read-only setup; hydrate before clicking; `forceMount` cross-tab panels
|
|
244
|
+
- [ ] Table-row clicks use `getByRole('row')`/testid, not `getByText`
|
|
245
|
+
- [ ] Whole-document axe run for new/changed UI; violations fixed at source
|
|
246
|
+
- [ ] Copy/flow changes: specs grepped and locators updated in the same PR (all Playwright surfaces)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-conventions
|
|
3
|
+
description: React + Next.js client conventions for this repo (React 19 / Next.js 16, React Query / TanStack Query, Radix UI, shadcn-style cards/dialogs). Use when writing or reviewing React components, hooks, optimistic cache patches, data-fetching code, dialogs, Radix Select usage, JSX, interactive cards/rows, or Next.js Edge Runtime / middleware code. Covers optimistic-cache completeness, fetch error states, destructive-dialog error handling, the React 19 set-state-in-effect rule, a Radix Select form footgun, a JSX-ternary-comment parse error, composite-interactive a11y, the Edge-Runtime/Prisma import boundary, and the shared API-helper convention.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# React + Next.js Client Conventions
|
|
7
|
+
|
|
8
|
+
Applies to: client components and hooks in the repo's web frontends. Stack assumed: React 19 / Next.js 16 (App Router), TanStack Query (React Query), Radix UI primitives, a shared `api` client helper, and a same-origin BFF/proxy. Test runner is Vitest + Testing Library (see the sibling `frontend-testing` skill).
|
|
9
|
+
|
|
10
|
+
> Naturalize: confirm framework versions, the query-client setup, and the shared API helper path in `CLAUDE.md`. Paths below (`services/dashboard/lib/...`) are illustrative.
|
|
11
|
+
|
|
12
|
+
These conventions are the React/Next.js-specific **mechanism**. The cross-project **principles** behind several of them — "a change isn't done until every surface is updated", "shift correctness left and distrust false greens" — live in the engsys `lessons-library/`. This skill carries the concrete React shape.
|
|
13
|
+
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- [Optimistic cache patches must be complete](#optimistic-cache-patches-must-be-complete)
|
|
17
|
+
- [Derived parent fields: inherit the server's rule](#derived-parent-fields-inherit-the-servers-rule)
|
|
18
|
+
- [Error states on data fetch](#error-states-on-data-fetch)
|
|
19
|
+
- [Destructive dialogs own their error + pending state](#destructive-dialogs-own-their-error--pending-state)
|
|
20
|
+
- [No setState inside useEffect (React 19)](#no-setstate-inside-useeffect-react-19)
|
|
21
|
+
- [Radix Select inside a form: guard the empty reset](#radix-select-inside-a-form-guard-the-empty-reset)
|
|
22
|
+
- [No JSX comment inside a ternary branch](#no-jsx-comment-inside-a-ternary-branch)
|
|
23
|
+
- [Composite interactive a11y](#composite-interactive-a11y)
|
|
24
|
+
- [Next.js Edge Runtime: no Node-only imports at module scope](#nextjs-edge-runtime-no-node-only-imports-at-module-scope)
|
|
25
|
+
- [Data fetching goes through the shared API helper](#data-fetching-goes-through-the-shared-api-helper)
|
|
26
|
+
- [Common mistakes](#common-mistakes)
|
|
27
|
+
- [Review checklist](#review-checklist)
|
|
28
|
+
|
|
29
|
+
## Optimistic cache patches must be complete
|
|
30
|
+
|
|
31
|
+
An optimistic React Query patch is a hand-written mirror of the server's write. It is correct only when it touches **every** surface the mutation touches. Five recurring incompleteness shapes:
|
|
32
|
+
|
|
33
|
+
1. **Update every projected/derived field.** For each field the server derives from child rows, mirror the exact server projection rule — ranking (`ACCEPTED` outranks `DECLINED`), last-pending checks, primary-vs-non-primary. Do not assume `result.status` is authoritative; check what the server would compute. A missed projection causes optimistic → server-returned flicker.
|
|
34
|
+
2. **Re-sort when the mutation changes a sort key.** If the patch updates a field the list sorts by, re-sort before returning: `[...nodes].sort(sortFn)`. Guard with `'sortOrder' in node` to skip unrelated updates.
|
|
35
|
+
3. **Refresh sibling derived caches.** Count/stats/rollup/badge caches that read the mutated collection go stale. Patch or invalidate them explicitly (e.g. pass `derivedKeys: [queryKeys.nodes.stats(scopeId)]`). Never assume an unrelated refetch will eventually fix it.
|
|
36
|
+
4. **Reconcile placeholders by a stable id.** Generate an `optimisticId` at `onMutate` time and thread it through to `onSuccess`. Match/remove the placeholder by `node.id === optimisticId` only — never by `label`, `index`, or any user-controlled field, or two concurrent creates with the same label silently drop one.
|
|
37
|
+
5. **Return new references — never mutate in place.** React Query uses reference equality to decide whether to notify subscribers. `arr.sort()` / `obj.field = x` mutate the cached object without notifying. Use `[...arr].sort(fn)` and `{ ...obj, field: x }`. When a mapper returns the original reference on a no-match, skip `setQueryData` entirely.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// Wrong — mutates the cached array, no sibling refresh, stale derived field
|
|
41
|
+
const node = cache.find((n) => n.id === id);
|
|
42
|
+
node.sortOrder = next; // in-place mutation: no notify
|
|
43
|
+
updated.sort(sortDepth1); // mutates the cached array
|
|
44
|
+
|
|
45
|
+
// Right — new references, re-sort a copy, refresh siblings
|
|
46
|
+
const patched = cache.map((n) => (n.id === id ? { ...n, sortOrder: next } : n));
|
|
47
|
+
const sorted = [...patched].sort(sortDepth1);
|
|
48
|
+
queryClient.setQueryData(listKey, sorted);
|
|
49
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.nodes.stats(scopeId) });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Cache-KEY completeness** is the same family: any new query parameter that changes the result set MUST be in the cache key, or two parameter values collide on one cached entry and serve wrong data. When adding a filter param, grep every queryKey/cache-key builder for that query's shape and add the param in the same commit.
|
|
53
|
+
|
|
54
|
+
**The full statement:** a mutation (or a new query param) touches three cache surfaces — (1) the patched/invalidated entries, (2) every sibling family that *displays* the entity (list, detail, rollup, badge/count), and (3) the cache KEY itself. Enumerate all three before declaring the cache handling done.
|
|
55
|
+
|
|
56
|
+
## Derived parent fields: inherit the server's rule
|
|
57
|
+
|
|
58
|
+
When a mutation on a child rolls up into a parent summary field, **re-derive the parent field from the full updated state, not from the mutation result alone.** Inherit the server's calc/precedence rule exactly; fall back to `result` only when there is no better signal.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Wrong — ignores an already-decided primary
|
|
62
|
+
const newStatus = remainingPending.length > 0 ? "PENDING" : result.status;
|
|
63
|
+
|
|
64
|
+
// Right — inherit the primary's decided status; fall back to result when none decided yet
|
|
65
|
+
const primaryDecidedStatus = rec.themeImpactId
|
|
66
|
+
? rec.decidedSuggestions?.find((s) => s.id === rec.themeImpactId)?.status
|
|
67
|
+
: undefined;
|
|
68
|
+
const newStatus =
|
|
69
|
+
remainingPending.length > 0 ? "PENDING" : (primaryDecidedStatus ?? result.status);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Apply identical derived-state logic to **both** the list cache and the detail cache (separate `setQueryData` calls) — copy/paste divergence here makes the card and the detail panel disagree. Grep the owning server service for the projection rule (e.g. an `IMPACT_STATUS_RANK`) and confirm the patch matches every branch.
|
|
73
|
+
|
|
74
|
+
## Error states on data fetch
|
|
75
|
+
|
|
76
|
+
Never silently `return null` on a fetch error for primary content — the user gets a blank section with no signal. Handle `isError` with user-visible UI. `null` is acceptable only for decorative tiles where the page still renders meaningfully.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
const { data, isError } = useSubscriptionInvoices();
|
|
80
|
+
|
|
81
|
+
// Decorative strip / tile — page still meaningful without it:
|
|
82
|
+
if (isError || !data) return null;
|
|
83
|
+
|
|
84
|
+
// Primary content card — show an error and a recovery hint:
|
|
85
|
+
if (isError) {
|
|
86
|
+
return (
|
|
87
|
+
<Card>
|
|
88
|
+
<CardHeader><CardTitle>Invoices</CardTitle></CardHeader>
|
|
89
|
+
<CardContent>
|
|
90
|
+
<p className="text-sm text-muted-foreground">
|
|
91
|
+
Could not load invoices. Please refresh the page to try again.
|
|
92
|
+
</p>
|
|
93
|
+
</CardContent>
|
|
94
|
+
</Card>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Anything user-actionable (invoices, past-due status, anything with a `refetch()`) gets the error card. Inline `text-destructive` error for action failures (e.g. a "Manage billing" portal-session failure).
|
|
100
|
+
|
|
101
|
+
## Destructive dialogs own their error + pending state
|
|
102
|
+
|
|
103
|
+
A confirm dialog for a destructive action (remove/suspend/delete) must own its own `isPending` and `error` state — not inherit them from the parent, and not route failures to a far-away toast that leaves the modal open with no explanation.
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
1. Dialog owns local `isPending` + `error`.
|
|
107
|
+
2. `onConfirm` is typed `() => Promise<void>` and **throws on failure**.
|
|
108
|
+
3. Inside the dialog: `setError(null)`, `setPending(true)`, `await onConfirm()`; on success close, on catch `setError(msg)` and do **not** close; `setPending(false)` in `finally`.
|
|
109
|
+
4. Render `error` inline; reset it when the dialog **closes** (`if (!next) setError(null)`).
|
|
110
|
+
5. The page-level handler throws instead of toasting — the dialog catches and surfaces it.
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
async function handleConfirm() {
|
|
114
|
+
setError(null);
|
|
115
|
+
setIsPending(true);
|
|
116
|
+
try {
|
|
117
|
+
await onConfirm(); // throws on failure
|
|
118
|
+
onOpenChange(false); // success closes the dialog
|
|
119
|
+
} catch (err) {
|
|
120
|
+
setError(errorMessage(err)); // failure stays open, shows inline
|
|
121
|
+
} finally {
|
|
122
|
+
setIsPending(false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## No setState inside useEffect (React 19)
|
|
128
|
+
|
|
129
|
+
The React 19 / Next.js 16 eslint config ratchets `react-hooks/set-state-in-effect` (and `react-hooks/cannot-access-refs-during-render`) to **error**. `useEffect(() => { if (!open) setForm(EMPTY) }, [open])` and the ref-reset-during-render workaround both fail and block the PR.
|
|
130
|
+
|
|
131
|
+
- **Reset-on-close → push into the close callback.** Wrap the parent's `onOpenChange` in a local `handleOpenChange(next)` that calls `setForm(EMPTY)` before delegating, and wire it to every close path (Radix Root `onOpenChange`, Cancel button, close-on-success).
|
|
132
|
+
- **Derived-from-prop → `useMemo` + controlled fallback** (`const effective = form.value || derivedDefault`) instead of writing state from an effect.
|
|
133
|
+
- **Filter-linked resets that must run on dependency change** → wrap the write in `startTransition` so it is scheduled as a non-urgent update outside the synchronous commit:
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { startTransition, useEffect } from "react";
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
startTransition(() => setShown(PAGE_SIZE));
|
|
139
|
+
}, [period, scopeId]);
|
|
140
|
+
```
|
|
141
|
+
- **When the reset legitimately fires mid-session** (e.g. on a `provider` change while still open) the close-callback can't cover it — use a targeted per-line `// eslint-disable-next-line react-hooks/set-state-in-effect` with a rationale comment. Every suppressed setter needs its own disable line.
|
|
142
|
+
|
|
143
|
+
Paired obligation: whenever you set a `saving`/`pending` flag before an async call, reset it (`setSaving(false)`) in **both** the success path and the open-reset — otherwise the confirm button stays disabled when the dialog reopens.
|
|
144
|
+
|
|
145
|
+
## Radix Select inside a form: guard the empty reset
|
|
146
|
+
|
|
147
|
+
A controlled Radix `<Select value={x} onValueChange={setter}>` **inside a `<form>`** with an **empty initial value** resets to `""` after the user picks an option. Radix renders an aria-hidden native `<select>` for form participation; when the content closes it remounts with no `<option>`s and fires a spurious `onValueChange("")`. The trigger may still show the right label (it reads props) while the underlying state is empty and the submit stays disabled.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
// Fixed — ignore the spurious empty-reset (safe when no option has an empty value)
|
|
151
|
+
<Select
|
|
152
|
+
value={form.unit}
|
|
153
|
+
onValueChange={(v) => {
|
|
154
|
+
if (v) patch({ unit: v }); // guard against Radix's empty remount event
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Only triggers with all three: inside a `<form>`, controlled, empty initial value. Initializing to a derived non-empty value avoids it entirely.
|
|
160
|
+
|
|
161
|
+
## No JSX comment inside a ternary branch
|
|
162
|
+
|
|
163
|
+
`{/* ... */}` is itself a JSX expression node. A ternary branch expects a **single** expression, so a comment node before the element makes a second child and the parser errors (`Expected '</', got 'ident'`).
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
// Wrong
|
|
167
|
+
{kind === "chip" ? (
|
|
168
|
+
{/* comment */}
|
|
169
|
+
<JudgmentChip bucket={bucket!} />
|
|
170
|
+
) : ...}
|
|
171
|
+
|
|
172
|
+
// Right — comment outside the ternary, or wrap the branch in a fragment
|
|
173
|
+
{kind === "chip" ? (
|
|
174
|
+
<>
|
|
175
|
+
{/* comment */}
|
|
176
|
+
<JudgmentChip bucket={bucket!} />
|
|
177
|
+
</>
|
|
178
|
+
) : ...}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Composite interactive a11y
|
|
182
|
+
|
|
183
|
+
Making a whole card/row clickable, or adding expand/collapse controls with ARIA relationships:
|
|
184
|
+
|
|
185
|
+
- **No nested interactives.** No `<a>`/`<button>` descendant of an `<a>`/`<button>` ancestor. If a card is a `<Link>`, inner CTAs become handler-based controls (`e.preventDefault(); e.stopPropagation(); router.push(...)`) — or restructure so the card link is an overlay sibling, not an ancestor.
|
|
186
|
+
- **`stopPropagation` on nested control clicks** so activating an inner control does not also activate the clickable container. Verify with both Enter and Space.
|
|
187
|
+
- **Gate ARIA relationships on the target's presence.** A trigger pointing `aria-controls` at a region that isn't rendered while collapsed dangles. Gate it: `aria-controls={expanded ? regionId : undefined}`. Every `aria-controls`/`aria-owns`/`aria-describedby` id must exist in the DOM while the attribute is present.
|
|
188
|
+
|
|
189
|
+
`aria-required-parent`-family violations only fire when the elements actually render, so seeded states can mask them in unit tests — confirm with whole-document axe E2E (see `frontend-testing`).
|
|
190
|
+
|
|
191
|
+
## Next.js Edge Runtime: no Node-only imports at module scope
|
|
192
|
+
|
|
193
|
+
Next.js middleware runs in the **Edge Runtime**, which can't load Node-only modules (Prisma, `fs`, native crypto). A *top-level* `import { prisma } from './prisma'` in any module the middleware imports runs at module-load time and crashes — even if `prisma` is only used inside one function.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Wrong — top-level import runs in Edge Runtime when middleware loads this module
|
|
197
|
+
import { prisma } from "./prisma";
|
|
198
|
+
export async function ensureSessionClaimed(id: string) {
|
|
199
|
+
return prisma.session.findUnique({ where: { id } });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Right — lazy import inside the function, which only runs in the Node runtime
|
|
203
|
+
export async function ensureSessionClaimed(id: string) {
|
|
204
|
+
const { getPrisma } = await import("./prisma"); // runs only when called from a Node route
|
|
205
|
+
const prisma = await getPrisma();
|
|
206
|
+
return prisma.session.findUnique({ where: { id } });
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Rule: any module reachable from `middleware.ts` must be Edge-safe at module scope — pure functions, constants, types only. Push Node-only access into lazy `await import(...)` calls inside functions that run only in the Node runtime (API routes).
|
|
211
|
+
|
|
212
|
+
## Data fetching goes through the shared API helper
|
|
213
|
+
|
|
214
|
+
Use the shared `api` helper (`api.get/post/put/delete`) for all client → gateway calls, never raw `fetch`. The helper provides 401 silent token-refresh + one retry, `X-Tenant-Id` injection, correlation-id forwarding, and centralized JSON error parsing. Raw `fetch` bypasses all of it and a 401 fails silently.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// Wrong — bypasses auth refresh + tenant headers
|
|
218
|
+
const res = await fetch("/api/proxy/api/v1/signals/export", {
|
|
219
|
+
method: "POST", credentials: "include", body: JSON.stringify(payload),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Right — the helper prepends /api/proxy and handles auth/headers/errors
|
|
223
|
+
const result = await api.post<SignalExportResult>("/api/v1/signals/export", payload);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Exceptions: anchor-click downloads (browser-native `Content-Disposition` streaming) and `rawGet()` when you need the raw `Response` (e.g. inspecting a 404 before throwing).
|
|
227
|
+
|
|
228
|
+
Use `async/await` for every new API-client method and React Query `queryFn` (`queryFn: async () => await api.get(...)`) — direct promise returns get flagged.
|
|
229
|
+
|
|
230
|
+
**Param parity:** when you add a query param or response field to the client types/hooks, implement it at the **owning backend service**, not just in the client. A param the client sends but the service ignores makes the UI look wired while the backend silently drops it. Trace every client API change through the gateway/proxy to the owning service and add coverage there in the same change.
|
|
231
|
+
|
|
232
|
+
## Common mistakes
|
|
233
|
+
|
|
234
|
+
1. Optimistic patch updates the surface you were looking at but misses a sibling rollup/badge cache.
|
|
235
|
+
2. Mutating a cached array/object in place (`arr.sort()`, `obj.x = y`) — no subscriber notify.
|
|
236
|
+
3. A new filter param reaches the query but not the cache key — values collide and serve wrong data.
|
|
237
|
+
4. Deriving a parent rollup from `result.status` instead of the server's precedence rule.
|
|
238
|
+
5. `return null` on `isError` for primary content — silent blank section.
|
|
239
|
+
6. Destructive dialog routes failures to a toast and stays open with no inline error.
|
|
240
|
+
7. `setState` inside `useEffect` for reset-on-close instead of the close callback.
|
|
241
|
+
8. Setting a `saving` flag without resetting it on the success path / open-reset.
|
|
242
|
+
9. Unguarded `onValueChange` on a controlled empty-start Radix Select inside a `<form>`.
|
|
243
|
+
10. `{/* comment */}` directly inside a JSX ternary branch.
|
|
244
|
+
11. Nested anchors/buttons, or `aria-controls` pointing at a not-yet-rendered region.
|
|
245
|
+
12. Top-level Prisma/Node import in a module the Edge middleware loads.
|
|
246
|
+
13. Raw `fetch` instead of the `api` helper; or extending client types without the owning service.
|
|
247
|
+
|
|
248
|
+
## Review checklist
|
|
249
|
+
|
|
250
|
+
- [ ] Optimistic patch updates every derived field, re-sorts on sort-key change, refreshes sibling caches, reconciles by stable id, returns new references
|
|
251
|
+
- [ ] New query param is in the cache KEY, not just the query
|
|
252
|
+
- [ ] Parent rollup field re-derived with the server's precedence rule, on list AND detail caches
|
|
253
|
+
- [ ] `isError` shows visible UI for primary content; `null` only for decorative tiles
|
|
254
|
+
- [ ] Destructive dialog owns `isPending`+`error`; `onConfirm` throws; errors inline; reset on close
|
|
255
|
+
- [ ] No `setState` in `useEffect` (close callback / `useMemo` / `startTransition` / justified suppression)
|
|
256
|
+
- [ ] `saving` flags reset on success and on open-reset
|
|
257
|
+
- [ ] Controlled empty-start Radix Select in a form guards `onValueChange` against `""`
|
|
258
|
+
- [ ] No JSX comment inside a ternary branch
|
|
259
|
+
- [ ] No nested interactives; `stopPropagation` on inner controls; `aria-controls` gated on presence
|
|
260
|
+
- [ ] No Node-only imports at module scope in any Edge-middleware-reachable module
|
|
261
|
+
- [ ] All gateway calls use the `api` helper with `async/await`; new params implemented at the owning service
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-platform-conventions
|
|
3
|
+
description: Web platform conventions for this repo — Content-Security-Policy discipline and (for Next.js 16+) the proxy.ts-not-middleware convention. Use when editing CSP headers, adding a third-party SDK / analytics / external API origin, touching request-edge code (middleware/proxy), or wiring auth/redirects/headers on a web frontend.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Web Platform Conventions
|
|
7
|
+
|
|
8
|
+
Applies to: the web frontend(s) in this repo. Mostly framework-agnostic; the proxy.ts section is Next.js-specific.
|
|
9
|
+
|
|
10
|
+
> Naturalize: confirm the framework/version and where the CSP is built in `CLAUDE.md`. The defaults below assume a same-origin BFF/proxy architecture.
|
|
11
|
+
|
|
12
|
+
## Content-Security-Policy is a security-critical surface
|
|
13
|
+
|
|
14
|
+
Treat the CSP as code that gates every network origin the page may reach. **Any new XHR/fetch origin, script host, or asset host must be added explicitly.** Never relax a directive to a wildcard to "make it work."
|
|
15
|
+
|
|
16
|
+
### `connect-src` policy
|
|
17
|
+
|
|
18
|
+
- **Production: `'self'` only** — no `https:` wildcard, no `data:` / `blob:` / `javascript:`. Browser-initiated API calls should go through a same-origin BFF/proxy (e.g. `/api/proxy/*`), so `'self'` covers them.
|
|
19
|
+
- Auth flows that navigate the browser to another origin (OAuth login/callback) use `window.location` navigations, which are governed by `form-action` / navigation directives, **not** `connect-src`.
|
|
20
|
+
- Development may add `ws:` / `wss:` for the dev server's hot-reload.
|
|
21
|
+
|
|
22
|
+
### Before adding a new third-party SDK / analytics / external API
|
|
23
|
+
|
|
24
|
+
1. Add the **explicit origin** to `connect-src` (e.g. `https://browser.sentry-cdn.com`). Do NOT relax to `https:`.
|
|
25
|
+
2. If the SDK loads a script, also add the script host to `script-src`.
|
|
26
|
+
3. Add a test asserting both that the new origin is present **and** that the old strictness is preserved (no wildcard regression).
|
|
27
|
+
4. Smoke-test in dev — open DevTools console and look for CSP violation reports on the affected path.
|
|
28
|
+
|
|
29
|
+
### Sensible directive defaults
|
|
30
|
+
|
|
31
|
+
| Directive | Sources | Notes |
|
|
32
|
+
| ----------------- | --------------------------------------------------- | ------------------------------------------------------ |
|
|
33
|
+
| `default-src` | `'self'` | Fallback for everything not listed. |
|
|
34
|
+
| `connect-src` | `'self'` (+ `ws:` `wss:` in dev) | All XHRs same-origin via BFF. |
|
|
35
|
+
| `script-src` | `'self'`, per-request nonce, `'strict-dynamic'` | Add explicit CDN hosts; avoid `'unsafe-inline'` in prod. |
|
|
36
|
+
| `style-src` | `'self'`, `'unsafe-inline'` (if the CSS framework requires it) | Tailwind/Next often need inline styles. |
|
|
37
|
+
| `img-src` | `'self'`, `data:`, your asset/storage host | |
|
|
38
|
+
| `frame-ancestors` | `'none'` | Plus `X-Frame-Options: DENY` belt-and-suspenders. |
|
|
39
|
+
| `object-src` | `'none'` | No Flash, no PDF embed. |
|
|
40
|
+
| `base-uri` | `'self'` | Blocks `<base>` injection. |
|
|
41
|
+
| `form-action` | `'self'` | All forms post same-origin. |
|
|
42
|
+
|
|
43
|
+
Prefer a per-request nonce + `'strict-dynamic'` over host allowlists for scripts where the framework supports it.
|
|
44
|
+
|
|
45
|
+
## Next.js 16+: `proxy.ts`, not `middleware.ts`
|
|
46
|
+
|
|
47
|
+
Next.js 16 **replaced `middleware.ts` with `proxy.ts`**. Having both present is a build error ("use ./proxy.ts only").
|
|
48
|
+
|
|
49
|
+
**Rule:** on Next.js 16+, there is no `middleware.ts` anywhere in the service. Auth, redirects, headers, request rewriting, and the CSP header all go through `proxy.ts`.
|
|
50
|
+
|
|
51
|
+
Background: <https://nextjs.org/docs/messages/middleware-to-proxy>.
|
|
52
|
+
|
|
53
|
+
## Logging
|
|
54
|
+
|
|
55
|
+
Use the project logger, not `console.*`, on web frontends — so logs are structured, domain-scoped, and routable. Check `CLAUDE.md` for the logger import and the domain/level conventions.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## Issue tracking
|
|
2
|
+
|
|
3
|
+
- **Active tracker: GitHub Issues + Projects.** Agents use the `issue-tracker-github`
|
|
4
|
+
skill for all issue and board operations (create/list/get/update/comment/close issue;
|
|
5
|
+
create/add-to/query board; set board field; link PR).
|
|
6
|
+
- PRs and CI stay on GitHub via `gh`. A merged PR closes its work item through the
|
|
7
|
+
`Closes #<n>` convention (one keyword per line).
|
|
8
|
+
|
|
9
|
+
<!-- naturalize: confirm the repo (<owner>/<repo>) and the GitHub Project number + owner
|
|
10
|
+
(user/org) that hold the board, plus the Phase/Priority/Owner field option values. -->
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(gh issue create:*)",
|
|
5
|
+
"Bash(gh issue list:*)",
|
|
6
|
+
"Bash(gh issue view:*)",
|
|
7
|
+
"Bash(gh issue edit:*)",
|
|
8
|
+
"Bash(gh issue comment:*)",
|
|
9
|
+
"Bash(gh issue close:*)",
|
|
10
|
+
"Bash(gh issue status:*)",
|
|
11
|
+
"Bash(gh project list:*)",
|
|
12
|
+
"Bash(gh project view:*)",
|
|
13
|
+
"Bash(gh project create:*)",
|
|
14
|
+
"Bash(gh project field-list:*)",
|
|
15
|
+
"Bash(gh project field-create:*)",
|
|
16
|
+
"Bash(gh project item-list:*)",
|
|
17
|
+
"Bash(gh project item-add:*)",
|
|
18
|
+
"Bash(gh project item-edit:*)",
|
|
19
|
+
"Bash(gh api graphql:*)",
|
|
20
|
+
"Bash(gh pr create:*)"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"mcpServers": {}
|
|
24
|
+
}
|