launch-quickly

Troubleshooting

Real symptoms from real builds — organised by what you actually see, not by which subsystem is at fault. Every entry here cost someone time.

Nothing on this page is hypothetical. Every entry came out of a friction log kept while building a product on this template, and each one cost somebody between ten minutes and an hour.

They are grouped by what you see, because that is what you have when you arrive. The subsystem at fault is usually not the one you are looking at.

Everything passes and the feature is broken

The most expensive category by a wide margin, and the reason this template pushes so hard on control-testing your own tests.

A test passes that could not have failed

Three real examples, all green, all worthless:

  • A concurrency test that never interleaved. "I wrote 'fire two transitions at once, assert one loses', it went green, and it was worthless: the two transactions ran in sequence rather than interleaving, so it never exercised SELECT … FOR UPDATE at all." The fix is to hold the lock in a competing transaction.
  • A negative test that passed for the wrong reason. zod 4's z.uuid() validates the RFC 9562 version and variant nibbles, so 00000000-0000-0000-0000-000000000001 — the placeholder everyone reaches for — is invalid. A test asserting "this bad status is rejected" passed because the id beside it was rejected first.
  • An e2e that posted one comment. The bug appeared from the second onward. See "the button says Posting… forever" below.

What to do: after writing an assertion, reintroduce the defect it is supposed to catch and watch it go red. A test that has never failed has not been tested.

The form submits the wrong number

useActionForm submits the schema's outputhandleSubmit hands the resolver's parsed values to the action — and the action then re-parses that with the same schema. So every schema must be idempotent under a double parse.

The shipped examples all happen to be ('' → null short-circuits .nullable(); z.coerce.number() is idempotent on a number), which makes the constraint invisible until you write the first transform that is not one.

A dollars-to-cents .transform() on createInvoiceSchema would have filed every $12.50 invoice for $1,250, with types, lint, lq check and a happy-path e2e all green.

What to do: keep unit conversion in the form component, not the schema. If a transform must live in the schema, pin idempotency with a test.

The button says "Posting…" forever, but the row is in the database

useActionForm runs the action inside a transition and calls onSuccess inside it too. A router.refresh() there joins the transition that owns pending, so pending never clears.

The first submit of a session works. From the second onward the button hangs and the new row never appears — though it committed fine.

Nothing catches this: lq check green, 252 unit and integration tests green, and an e2e that submits once is green too.

What to do: move the refresh into an effect rather than onSuccess. And if the list you are refreshing sits inside its own Suspense boundary, the refresh re-suspends it — render it inline instead.

A date silently clears when you edit a record

<input type="date"> accepts only 'YYYY-MM-DD'. Handed a Date, the browser rejects the value and renders the field empty, so opening a record and saving blanks the date.

Both lq check and the generated tests passed throughout. See ADR-0009 — the column must be date(..., { mode: 'string' }), and src/lib/format.ts keeps the string intact end to end.

The entire e2e suite fails and you changed nothing

A mass failure spanning unrelated specs is almost never your change. A real regression is narrow. Four causes, in the order they are worth checking:

1. A sibling project stole your port

Every project generated from this template defaults to E2E_PORT=3100 with reuseExistingServer on. Two projects on one machine means one suite silently attaches to the other product's server:

55 tests failed against someone else's app — with plausible errors like "heading 'Assistant' not found" that look exactly like my own bugs.

Note the process is named next-server, so pkill -f "next start" does not match it.

2. You ran the suite twice in quick succession

The second run attaches to the first one's server while it is still shutting down. The port answers, so Playwright proceeds — then 35 to 40 of 49 tests fail at once across specs you never touched.

3. You killed the server with kill -9

That leaves the port held. Playwright sees nothing listening, starts its own, and next start dies with EADDRINUSE — after which every test hits a dead server and times out.

I saw 16 failures spread across specs I had never touched and spent real time hunting for a regression in permissions, connection pooling and cold-start timing before reading far enough up the log to find the actual line.

The signal — ⨯ Failed to start server — is buried in hundreds of lines of expected UNAUTHORIZED noise from the signed-out smoke tests. Scroll up.

4. A helper that submits without waiting

An e2e helper that submits a form and returns lets the next page.goto() abort the in-flight write. It passes on an idle machine and fails the moment the box is loaded. The generated spec's create helper has exactly this shape.

Related: clicking a status change and immediately navigating races the action. Playwright auto-waits for elements, not for a startTransition plus router.refresh() to settle. Assert the next state before navigating.

Before believing any of it: check the port is free (lsof -ti:3100), kill gracefully, and run again.

lq check passes but next build fails

Both known causes are the same shape: a server-only module reaching the client graph, which nothing but a full build reports.

A client component imported another feature's barrel

The barrel re-exports queries.ts, which is server-only and imports the db client — so the whole postgres driver gets pulled into the browser bundle.

The rule "import features only through the barrel" and the rule "barrels re-export server-only queries" are in direct tension, and the only thing that reports it is a full production build.

What to do: move the shared pure helper somewhere neither server-only nor feature-private — src/lib/format.ts is usually the right home.

A client component imported policy.ts

Same failure, narrower cause. A slice's policy.ts is server-only, so a status→label map that lives beside the transition rules cannot be read by the buttons that render them. Put labels in the feature's badge module instead.

The page is stale after a mutation

revalidate is a typed registry precisely so a stale surface is a type error rather than a silent bug — revalidatePath('/projcets') is a typo the compiler shrugs at and the UI punishes.

If the surface you need is not on it, add it. The shipped dashboard was itself missing revalidate.dashboard() and kept showing pre-mutation counts. The registry did its job the moment someone tried to use it (.dashboard did not typecheck); the gap was that nothing had.

The data is there but the page shows nothing

You are on the free plan

Reading the audit log is gated on a paid plan, separately from recording, which happens on every plan. An e2e that moves a record and opens /settings/audit finds nothing — against a completely working audit trail.

e2e/audit.spec.ts seeds a subscription row for this reason. Any new test asserting on that page has to do the same.

A query returned zero rows

That is usually row-level security working. A request-path query runs as a role that cannot bypass it, so a query reaching the wrong tenant returns nothing rather than erroring — see ADR-0002. Check the TenantContext you passed before suspecting the query.

After an upgrade

lq check crashes instead of linting

TypeError: Key "rules": Key "@launchquickly/no-ad-hoc-formatting":
Could not find "no-ad-hoc-formatting" in plugin "@launchquickly".

The delivered lint config references rules that ship in a newer plugin than the one installed. Fixed for projects on 0.3.4 and later, where vendored tarballs carry the template version so the specifier changes and pnpm re-extracts.

On an older project: regenerate the lockfile and force the install. Neither alone is enough — the lockfile pins an integrity hash and pnpm's store keeps the old extraction.

Files were withheld

The upgrade refuses to deliver a file the project could not compile, and names what is missing. Two shapes:

  • needs @/components/ui/select — a primitive you do not have. Install it.
  • needs @/lib/format (formatNumber, formatEnum) — the module exists but your copy does not export those. Add them; your own helpers are untouched.

Anything still withheld after that is a layer this project does not use, which is a fine outcome — nothing was written, so the project still builds.

Files from an earlier upgrade do not compile

Reported separately, because there is nothing to withhold — they are already on disk. An older planner wrote them before the check existed. Add the missing exports, or delete the files.

Generator output that surprises people

  • The isolation test can fail on the first run for a ref(members) field: the fixture seeded a random uuid, which violates the composite foreign key the same generator emitted. Seed the org's own member id — do not delete the field from the fixture.
  • loading.tsx cascades into child routes. The list skeleton renders for the detail page too, so opening one record flashes a five-row table and reflows. Add a detail skeleton.
  • A references() on a tenant table is not enough. Postgres runs foreign-key checks as the system, so RLS does not apply to them — a plain reference lets one organization point at another's row. Use a composite FK against (organization_id, id).

Small things that waste ten minutes

  • pnpm db:psql -- -c "select 1" ignores the -c. The extra arguments land after the sh -c string rather than inside it. Read the URL out of .env.local and call psql directly for one-shot queries.
  • The post-edit hook reports the same in-progress errors on every edit. A refactor touching six files reports the same five type errors six times. Useful at the end of a change, noisy in the middle of one.
  • Deleting the example slice touches more than the listed files — the revalidate registry, the schema barrel, the projects limit in src/config/billing.ts rendered in the plan picker, the sample permissions in the action-client tests, the protected-route list in e2e/smoke.spec.ts, and the projects cleanup in the e2e session fixture. Most is caught by lq check; the e2e and billing ones are not.
  • e2e/projects.spec.ts is not only example code. About half of it tests the list convention — pagination, sorting, filter chips, the field-error path, the soft-404. Deleting the demo deletes the only end-to-end coverage those have. Port it into your own spec.

On this page