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.
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)To get started, first run npm install, then for a dev SDK build:
npm run sdk-app # Dev build with HMROr 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.
- 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
/tsdocfor standards guidance. - Use descriptive variable/function names that eliminate the need for comments
- NEVER use
!importantin CSS/SCSS files — use proper CSS specificity instead - Do not include
@useimports in.module.scssfor modules globally available via Vite (e.g.,@/styles/Helpersis auto-injected) - Avoid
useMemo,useCallback, orReact.memounless 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 SomeTypecasts 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.
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.
All UI rendering goes through useComponentContext(). Never import UI primitives directly:
const Components = useComponentContext()
return <Components.TextInput {...props} />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.
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
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)
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-apimutation. It's redundant — the globalonSuccessalready invalidated the entire SDK namespace. Justawait 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
QueryClienttoApiProvider, 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.
GustoProvider → ComponentsProvider → ThemeProvider → LocaleProvider / I18nextProvider → ApiProvider → {children}
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.
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.
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 defaultIntl.DateTimeFormatwith notimeZone) rolls the displayed day back by one in any timezone behind UTC (all of the US).someLocalDate.toISOString()converts a local-midnightDate(e.g. from aDatePickerField) 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)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
*Floworchestrators driving a linear, wizard-style sequence with a real end (OnboardingFlow,TimeOffFlow) — may use a robot3 final state (final(), or an equivalent zero-transitionstate()) 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, orDocumentSignerthat 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 viaonEventwithout transitioning the local machine, so a host that keeps the component mounted past completion keeps a fully working screen instead of a dead one.
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:generateafter 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. AnyonEventcall 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 likehttpMetathat partners don't need. - Non-form data hooks return the standard union. Data-fetching hooks return the
HookLoadingResult | BaseHookReady<…>discriminated union (seesrc/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
dictionaryso a partner's copy override in one surface doesn't leak into another (e.g. theDeductionsForm+useFormDictionarypattern). - 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@alphaTSDoc release tag and a new flag inUnstableFeatures(seeuseUnstableFeature) 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
Featureswhen a released feature still needs a toggle. When anUnstableFeaturesflag graduates but partners still need a way to turn it off, move it toFeatures(seeuseFeature) instead of deleting it outright.Featuresflags are@publicand 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.useFeaturecurrently treats a flag as on unless it is explicitlyfalse. Drop the@alphatags on the graduated surface in the same change.
- 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)
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.
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.
- Always use
npm run test -- --runto 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.
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.
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.