Skip to content

Latest commit

 

History

History
279 lines (195 loc) · 21.2 KB

CLAUDE.md

File metadata and controls

279 lines (195 loc) · 21.2 KB

Embedded React SDK

React component library for Gusto's Embedded Payroll product. Built with TypeScript, React, react-hook-form, TanStack Query, Zod, Vite, Vitest, Playwright, i18next, and Storybook.

Commands

npm run build          # Build the SDK
npm run test -- --run  # Run tests once and exit (omitting --run starts watch mode, which won't exit)
npm run storybook      # Start Storybook on port 6006
npm run e2e:serve      # Start E2E test app
npm run test:e2e       # Run E2E tests (requires gws-flows + ZenPayroll running)
npm run dev:setup      # Link SDK into gws-flows for local development
npm run i18n:generate  # Generate translation types
npm run derive         # Regenerate all derived files (runs build first; standalone)
                       # Prerequisite: cd typedoc && npm ci (if typedoc deps not yet installed)

SDK Dev App

To get started, first run npm install, then for a dev SDK build:

npm run sdk-app                # Dev build with HMR

Or for a production SDK build:

npm run sdk-app-prod           # Production build (uses built dist/)

See sdk-app/README.md for all available commands and environment options.

Code Style

  • Write self-explanatory code; avoid comments unless they are TSDoc on exported symbols, TODO/FIXME for temporary workarounds, or legal notices. Every new export needs TSDoc before the work is done — use /tsdoc for standards guidance.
  • Use descriptive variable/function names that eliminate the need for comments
  • NEVER use !important in CSS/SCSS files — use proper CSS specificity instead
  • Do not include @use imports in .module.scss for modules globally available via Vite (e.g., @/styles/Helpers is auto-injected)
  • Avoid useMemo, useCallback, or React.memo unless there's a demonstrated or obvious performance problem. Memoizing a deep object that's recreated every render fails its own equality check on re-render — it buys nothing and adds a maintenance trap.
  • Avoid as SomeType casts unless there's clearly no alternative — every cast bypasses TypeScript's safety checks. Prefer a type guard, discriminated union narrowing, or a generic instead.
  • Don't use ! non-null assertions, in source or tests. They hide a missing check. Narrow with a type guard or an explicit check instead.

Documentation

Files under docs/ are partner-facing. They document the public SDK API for engineers integrating it into their own app. When writing or editing them:

  • Don't refer to "partners" in third person; the reader is the partner. Write neutrally or in second person ("supply the value at submit time", not "partners supply the value at submit time").
  • Don't speculate about the integrator's app or workflow ("captured on a previous step", "in your onboarding wizard", etc.). Describe what the API does and how to use it, not why someone might want it — there are many valid reasons.
  • Code samples must compile against the published SDK surface only. No @/ import aliases, no internal helpers.

CLAUDE.md, source comments, and other internal docs can reference "partners" and our team freely — they're written for SDK maintainers.

SDK Architecture

ComponentsContext Pattern

All UI rendering goes through useComponentContext(). Never import UI primitives directly:

const Components = useComponentContext()
return <Components.TextInput {...props} />

Field Components for react-hook-form

Use Field components from src/components/Common/Fields/ inside FormProvider. For inputs outside react-hook-form, use UI primitives from useComponentContext() directly.

No react-hook-form internals in SDK components (non-negotiable). Components must consume form hooks only through their documented surface (data, status, actions, errorHandling, form.Fields, etc.) — never raw useForm, useWatch, setValue, watch, getValues, trigger, register, or hookResult.form.hookFormInternals.formMethods.*. Each such use is a sign the hook is missing functionality; move the logic into the hook instead. (AdminProfile.tsx's screen-local startDate useForm is a documented historical exception, not a template.) New or migrated partner-facing use*Form hooks must follow the scaffolding in .claude/commands/create-hook.md and the playbook in .claude/skills/migrate-sdk-component-to-hooks/SKILL.md.

Component Organization

src/components/
├── Common/         # Shared UI primitives and Field components
│   ├── Fields/     # Form-connected Field components (use for forms)
│   └── UI/         # Low-level UI components (via ComponentsContext)
├── Company/        # Company domain features
├── Employee/       # Employee domain features
├── Contractor/     # Contractor domain features
├── Payroll/        # Payroll domain features
└── Flow/           # Multi-step flow orchestration

API Layer (@gusto/embedded-api)

All API calls go through the Gusto Embedded API — imported via the version-agnostic @gusto/embedded-api alias, declared in package.json as npm:@gusto/embedded-api-v-<date> and currently pinned to @gusto/embedded-api-v-2026-06-15. Import specifiers must use the alias (enforced by the sdk-conventions/use-embedded-api-alias lint rule), so a version bump only updates the alias target plus API_VERSION rather than editing import paths.

Import paths:

  • @gusto/embedded-api/react-query/<operation> — React Query hooks
  • @gusto/embedded-api/models/components/<name> — Entity types
  • @gusto/embedded-api/models/operations/<name> — Request/response types
  • @gusto/embedded-api/models/errors/<name> — Error types

Hook naming: use<Resource><Action>Suspense (queries), use<Resource><Action>Mutation (mutations)

Auto-invalidation on mutation success

The QueryClient produced by createSdkQueryClient (in src/contexts/ApiProvider/createSdkQueryClient.ts) sets a global mutation default: on any successful mutation under the [API_QUERY_NAMESPACE] key — the resolved package name @gusto/embedded-api-v-2026-06-15, not the @gusto/embedded-api alias, since TanStack keys off the real package — it invalidates every SDK query. Hand-written keys must read API_QUERY_NAMESPACE (enforced by the sdk-conventions/no-literal-api-query-namespace lint rule), never the dated string directly. Both ApiProvider (production) and GustoTestProvider (tests) use this factory, so the behavior is identical in both environments.

Implications when writing SDK code:

  • Do not call queryClient.invalidateQueries(...) after a successful @gusto/embedded-api mutation. It's redundant — the global onSuccess already invalidated the entire SDK namespace. Just await mutateAsync(...) and the next render's queries refetch automatically.
  • This is why usePaymentMethodList, useEmployeeCompensation, etc. don't manually invalidate after their delete/update mutations.
  • If a partner brings their own QueryClient to ApiProvider, the defaults are not applied to it — they're responsible for matching the contract if they want this behavior. Don't paper over that with manual invalidation in hooks; treat it as their responsibility.
  • If you need to invalidate more narrowly (e.g. you only want one query to refetch, not the whole namespace), that's a code smell — most likely the global invalidate is already doing what you want.

Provider Stack

GustoProvider → ComponentsProvider → ThemeProvider → LocaleProvider / I18nextProvider → ApiProvider → {children}

i18n

All user-facing text uses i18next. Run npm run i18n:generate after changing translations. Use the useTranslation hook.

Only hand-edit src/i18n/en/**.json (the source strings). Never hand-write or edit translations under src/i18n/es_US/** (or any other non-en locale) — those files are populated exclusively by the Lokalise sync (Pull Translations workflow, PRs branched lokalise-update-<hash> and labeled lokalise-translation). A CI check (.github/workflows/check-manual-translations.yaml) fails any other PR that touches src/i18n/es_US/**. Add the new key only to en; the translation lands in other locales on the next Lokalise sync.

Partner hooks (composeErrorHandler / composeSubmitHandler)

Exported headless hooks build errorHandling with composeErrorHandler (not a React hook). For multi-form screens, composeSubmitHandler coordinates validation + ordered submits and returns { handleSubmit, errorHandling } aggregated across those forms. The result plugs back into composeErrorHandler when partners need extra @gusto/embedded-api queries or screen-level submit state in the same error surface.

Date handling

The API models calendar dates without a time component (birthdays, effective dates, deadlines) as plain YYYY-MM-DD strings. Two raw JS APIs silently corrupt them by conflating UTC and local time — this caused real off-by-one bugs across the SDK (see #2429, #2430):

  • new Date("YYYY-MM-DD") parses the string as UTC midnight. Formatting or reading it back with local date components (.getMonth(), date.toLocaleDateString(), a default Intl.DateTimeFormat with no timeZone) rolls the displayed day back by one in any timezone behind UTC (all of the US).
  • someLocalDate.toISOString() converts a local-midnight Date (e.g. from a DatePickerField) to UTC, which shifts the serialized day back by one in any timezone ahead of UTC (most of Europe, Asia, Africa, Australia).

Never call new Date(dateOnlyString) or date.toISOString().split('T')[0] on a date-only value. Always go through src/helpers/dateFormatting.ts: normalizeToDate() to parse a YYYY-MM-DD string into a Date, formatDateToStringDate() to serialize a Date back to YYYY-MM-DD, or the locale-aware wrappers via useDateFormatter() for display. This applies equally to one-off zod preprocessors/transforms on a Date field (e.g. coerceToISODate in partner-hook-utils/form/preprocessors.ts) — route through the same helpers rather than reimplementing with toISOString().

// BAD — new Date(str) parses as UTC midnight; toISOString() re-converts to UTC.
// Both silently shift the calendar day by one in some timezone.
const date = new Date(existingRecord.effectiveDate)
const wireValue = pickedDate.toISOString().split('T')[0]

// GOOD — routes through the timezone-safe helpers used everywhere else in the SDK.
import { normalizeToDate, formatDateToStringDate } from '@/helpers/dateFormatting'
const date = normalizeToDate(existingRecord.effectiveDate)
const wireValue = formatDateToStringDate(pickedDate)

State Machines (robot3)

Multi-step components are driven by robot3 state machines rendered through <Flow> (src/components/Flow/Flow.tsx). Flow re-emits every event to the upstream onEvent regardless of whether the local machine has a transition for it — so a parent already receives a step's completion signal without the child machine needing to transition anywhere for it.

  • Guided flows — top-level *Flow orchestrators driving a linear, wizard-style sequence with a real end (OnboardingFlow, TimeOffFlow) — may use a robot3 final state (final(), or an equivalent zero-transition state()) for their terminal step, since the parent app is expected to unmount the whole flow once it completes.
  • Hub-and-spoke flows — orchestrators the user can navigate through freely with no natural "end" — should not use a final state, since there's no terminal step to model one around.
  • Public, standalone components with their own internal state machine (a step component like Compensation, Deductions, or DocumentSigner that can also be mounted directly by a partner) must not model completion as a final state, guided or not. A robot3 final state has no transitions out — if the host doesn't unmount the component immediately after its completion event fires, every further interaction becomes a silent no-op while the last-rendered screen keeps looking interactive (SDK-1169). Let the completion event bubble via onEvent without transitioning the local machine, so a host that keeps the component mounted past completion keeps a fully working screen instead of a dead one.

Component & Feature Conventions

Durable conventions that apply SDK-wide to any component or feature:

  • One i18n namespace per self-contained component. A self-contained component owns a single translation namespace and reads only from it — never reach across namespaces. Run npm run i18n:generate after changing translations.
  • Scoped, unique events. Prefer unique event names scoped to the component/surface that fires them over sharing or borrowing names across flows (e.g. don't reuse an onboarding event from a different surface).
  • Standalone components take IDs, not entities. Public/standalone components accept entity IDs (e.g. employeeId, jobId) and fetch their own data rather than receiving full entity objects across the public API. Passing data structures is fine for internal-only components.
  • Partner-facing component APIs stay minimal. The primary prop is typically an entity ID; feature flags and opt-in behaviors (e.g. withI9, isSelfOnboardingEnabled) are fine as named booleans. Internal implementation details — sub-component props, internal state shapes, handler signatures — shouldn't leak into the public surface. If a new prop doesn't clearly belong in a partner integration contract, question it.
  • API response data in onEvent. Any onEvent call following a mutation should include the API response as payload, across every event type (not just _DONE). Pass the inner data value (e.g. result.data), not the top-level mutation result, which includes transport fields like httpMeta that partners don't need.
  • Non-form data hooks return the standard union. Data-fetching hooks return the HookLoadingResult | BaseHookReady<…> discriminated union (see src/partner-hook-utils/types.ts) for consistent loading/error/action ergonomics.
  • Shared UI stays presentational. UI reused across multiple flows takes its copy via props or an injected dictionary so a partner's copy override in one surface doesn't leak into another (e.g. the DeductionsForm + useFormDictionary pattern).
  • Partial, in-place loading. Prefer consistent partial loading — component chrome plus an inline loader — over full-surface loaders.
  • Thin orchestrators. Orchestrators stay thin and compose standalone, independently-consumable pieces.
  • Gate epic-level in-progress work behind @alpha + a flag. If a change is a slice of a larger feature that will eventually ship as its own announced capability (i.e. it maps to a Jira epic, not just a task/subtask), land it incrementally behind an @alpha TSDoc release tag and a new flag in UnstableFeatures (see useUnstableFeature) rather than merging partially-finished behavior that's reachable by default. Small in-progress changes to an already-stable surface (bug fixes, minor additions within an existing epic) don't need this — reserve it for work a partner could stumble into before it's ready to announce.
  • Graduate to Features when a released feature still needs a toggle. When an UnstableFeatures flag graduates but partners still need a way to turn it off, move it to Features (see useFeature) instead of deleting it outright. Features flags are @public and each one documents its own default with @defaultValue; keep the interface-level docs general rather than promising every flag is on by default or temporary. useFeature currently treats a flag as on unless it is explicitly false. Drop the @alpha tags on the graduated surface in the same change.

PR and Commit Conventions

  • Follow conventional commits: feat:, fix:, chore:, docs:, refactor:, test:, ci:, etc.
  • Prefer small, focused PRs (~400 lines max). Split large work into types → hooks → UI → integration.
  • During 0.x.x: feat: → MINOR bump, fix: → PATCH bump, feat!:/fix!: → MINOR bump (breaking)

Local Development Environment

Three-repo architecture with sibling directories:

~/workspace/
├── zenpayroll/          # Core Rails app
├── gws-flows/           # Rails API proxy for local SDK testing
└── embedded-react-sdk/  # This repo

Use ../gws-flows as the source of truth for current API behavior and response shapes. Do NOT copy gws-flows code 1:1 — understand the "what" then implement it the SDK way.

Storybook-First Development

Build and test components in Storybook (npm run storybook) before integrating into flows. No backend required. Verify all states: default, loading, error, empty, and edge cases.

Testing

  • Always use npm run test -- --run to avoid watch mode hanging
  • Run specific tests: npm run test -- --run src/components/MyComponent.test.tsx
  • Run with coverage: npm run test -- --run --coverage
  • Update snapshots: npm run test -- --run -u
  • E2E tests require gws-flows and ZenPayroll running. See e2e/local.config.example.env.
  • Prefer a small set of representative test cases (happy path, key error path, meaningful edge case) over many shallow variations that assert the same behavior with different data — e.g. don't write separate tests for rendering with the name "Alice", "Bob", and "Charlie" when one case covers the behavior.

Assertion style: prefer positive contracts

Assert what something is, not what it isn't. The set of properties an object doesn't have, methods it doesn't expose, or strings it doesn't contain is unbounded — every passing negative assertion is one of infinite possibilities, and the test gains no real signal from it.

// BAD — negative-space assertions over an infinite solution space
expect(entry).not.toHaveProperty('format')
expect(entry).not.toHaveProperty('isRemainder')
expect(rendered).not.toContain('admin')
expect(api).not.toBeUndefined()
// GOOD — positive shape assertion. If the shape changes intentionally (e.g.
// a new field is added), this test still passes. If a field changes type or
// goes missing, it fails — exactly when you want it to.
expect(entry).toMatchObject({
  uuid: 'bank-1',
  name: 'Chase',
  hiddenAccountNumber: 'XXXX0000',
})
expect(typeof entry.Field).toBe('function')

Use a negative assertion only when the absence is the contract under test — e.g. expect(mutationResolver).not.toHaveBeenCalled() when the test exists specifically to prove the API wasn't hit, or expect(screen.queryByRole('dialog')).toBeNull() when verifying a modal didn't open. The principle: a negative assertion needs a named, observable behavior it's guarding, not just "this object happens not to have X right now."

If you're tempted to add not.toHaveProperty to assert internal encapsulation (e.g. "the hook doesn't leak internal state"), prefer instead a positive assertion on the documented contract — partner-facing types and integration tests carry that load far better than property-absence checks.

Asserting on HTTP requests

When a test needs to verify which HTTP requests went out (verb, path, body, call count, ordering across endpoints), use vi.fn() to wrap the MSW resolver. Do not introduce a custom request-spy utility — vitest and MSW already provide everything needed.

Always pass the resolver type as a generic to vi.fn — without it, request is inferred as any and request.json() / request.url fail @typescript-eslint/no-unsafe-call. Import the type from msw:

import { HttpResponse, type HttpResponseResolver } from 'msw'

Per-endpoint assertions (call count, request body):

let createJobBody: Record<string, unknown> | null = null
const createJobResolver = vi.fn<HttpResponseResolver>(async ({ request }) => {
  createJobBody = (await request.json()) as Record<string, unknown>
  return HttpResponse.json({ uuid: 'new-job-uuid' /* ... */ }, { status: 201 })
})

server.use(handleCreateEmployeeJob(createJobResolver))

// ...interact with the component...

expect(createJobResolver).toHaveBeenCalledTimes(1)
expect(createJobBody).toMatchObject({ title: 'Engineer', hire_date: '2025-01-15' })

Cross-endpoint ordering — use toHaveBeenCalledBefore:

expect(createJobResolver).toHaveBeenCalledBefore(updateCompensationResolver)

Path assertions when the path itself encodes IDs (e.g., PUT /v1/jobs/:id) — capture the URL inside the resolver:

let updateJobPath: string | null = null
const updateJobResolver = vi.fn<HttpResponseResolver>(({ request }) => {
  updateJobPath = new URL(request.url).pathname
  return HttpResponse.json({/* ... */})
})

// ...
expect(updateJobPath).toBe('/v1/jobs/job-uuid')

When you want to assert that a specific endpoint was not called, register its handler with a vi.fn() resolver alongside the one(s) you do expect, then expect(resolver).not.toHaveBeenCalled(). This is more explicit than relying on the default mock returning success.

Most form-hook tests don't need this at all — assertions on the rendered DOM, the onEvent/onSaved callback payload, and the hook's data/status typically pin behavior sufficiently. Reach for resolver spies only when the wire-level contract (verb, path, version, sequence) is the thing under test, e.g. when a refactor needs to prove that two endpoints are still called in the right order or that the body still carries an optimistic-locking version.