npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hansenexus/state-check

v0.3.0

Published

The hansenexus frontend state contract as a deterministic check: dynamic Next.js App Router segments need loading.tsx or <Suspense>; opt-in rules for client router routes (wouter, React Router: error boundary, lazy pages in Suspense), Convex useQuery load

Readme

@hansenexus/state-check

The hansenexus frontend state contract as a deterministic check. Four rule sets:

| Rule | Default | | | --- | --- | --- | | next-route | on | a dynamic Next.js App Router segment is covered by a loading.tsx or a <Suspense> | | client-route | opt-in | a client router route (wouter, React Router) renders in an error boundary, a lazy() page in a <Suspense> | | convex-query | opt-in | a Convex useQuery result is rendered with its undefined (loading) branch | | pending-action | opt-in | a form action, submit handler or mutation button shows that it is pending |

It runs as a ratchet: a per-app baseline lists the violations that already exist, a new one fails CI.

The check is primitive-agnostic. It verifies that a loading state exists, never which library renders it, so an app on Bauhaus skeletons passes the same way as one on @hansenexus/ui.

Install

bun add -d @hansenexus/state-check

Use

state-check --app apps/hansenexus                     # CI: fails on violations beyond the baseline
state-check --app apps/hansenexus --update-baseline   # rewrite the baseline to the current state
state-check --app apps/hansenexus --json              # machine-readable report

| Flag | | | --- | --- | | --app <dir> | the app; for next-route its router is <dir>/src/app or <dir>/app | | --config <file> | rule sets and options (default: see Config) | | --baseline <file> | default <app>/state-coverage.baseline.json; a missing file is an empty baseline | | --json | print the report as JSON (ok, stats, new, baselined, shrink, ignored, invalidIgnores) | | --update-baseline | rewrite the baseline to the current violations | | --auth-helper <name> | a call that reads the session, on top of the built-in list; repeatable |

Exit 0 when nothing is beyond the baseline, 1 on a new violation or an ignore without a reason, 2 on bad usage or an unreadable baseline or config.

Config

state-check.config.json in the app, or else a "state-check" key in the app's package.json (both at once is an error). Without either, only next-route runs.

{
  "rules": { "next-route": false, "convex-query": true, "pending-action": true },
  "authHelpers": ["requirePageSession"],
  "queryWrappers": ["BauhausQuery"],
  "mutationHooks": ["useSaveDraft"],
  "pendingComponents": ["SubmitButton"],
  "errorBoundaries": ["RouteErrorBoundary"]
}

rules overrides the defaults per rule; a Vite app such as kommandant turns next-route off and the other three on. The lists add to each rule's built-ins: authHelpers for next-route (the --auth-helper flag adds more), errorBoundaries for client-route, queryWrappers for convex-query, mutationHooks and pendingComponents for pending-action. An unknown rule or key exits 2.

The rule: next-route

Every routable page.{tsx,jsx,ts,js} under the router is classified. Route groups (group) and parallel slots @slot are transparent; _private folders are not routes.

A page is dynamic when any of these holds:

  • a [param], [...param] or [[...param]] directory in its path, unless the page or a layout at or below that segment exports generateStaticParams (those params are prerendered at build time, as next-intl's [locale] is);
  • an await in the default-exported page component or at module top level (await params only unwraps the route input and does not count; await searchParams does);
  • a call to cookies(), headers(), draftMode(), connection() or an auth helper (auth, currentUser, getServerSession, convexAuthNextjsToken, isAuthenticatedNextjs, plus --auth-helper), in the page or in a same-file function it calls;
  • export const dynamic = "force-dynamic".

Static RSC pages have none of these and are exempt.

A dynamic page is covered by a loading.* in its segment or any ancestor segment, or by a <Suspense> (or <React.Suspense>) in the page file. A <Suspense> only covers work below it: when the page component itself awaits data or reads the request in its own body, only a loading.tsx covers it.

Limits: helpers are followed within the page file only, and any <Suspense> in the page file counts as covering its async children.

The rule: client-route (opt-in)

For Vite apps without file-based routing. A route is a <Route> imported from wouter, react-router or react-router-dom (aliases and import * as included). Its page is what component={…} names (both arms of a ternary), or the outermost elements of element={…} or of its children.

A route has an error state when any of these holds:

  • an error boundary is a JSX ancestor: a class component of the app with static getDerivedStateFromError or componentDidCatch, an ErrorBoundary element (as react-error-boundary exports it), or one of errorBoundaries;
  • the route or an ancestor <Route> has an errorElement or ErrorBoundary prop (React Router data routers);
  • the page's outermost element is an error boundary, or the page component returns one.

A route has a loading state unless its page is lazy(() => import(…)) (declared in the router file or one import away) and no <Suspense> is a JSX ancestor of the route. Data loading inside a page is left to convex-query and pending-action.

Ancestors are followed out of the component that holds the routes: when Shell renders the <Switch>, every <Shell /> in the app must sit under the boundary, or its own parent component must. The violation is reported on the <Route>, one per route.

Limits: components are matched by name across the app, the page component is followed one import deep, and route objects (createBrowserRouter([{ … }])) are not read.

The rule: convex-query (opt-in)

Every useQuery imported from Convex (convex/react, convex-helpers/react…; aliases and import * as included) is checked in every script file of the app except tests and stories. Its result is handled when, somewhere in the function that holds it:

  • it is compared with undefined or null (posts === undefined, posts != null, typeof);
  • it is a condition: if (posts), if (!posts), posts ? … : …, posts && …;
  • it goes to <QueryState query={posts}> (or a queryWrappers component) or queryStatus(posts);
  • a custom hook (use…) returns it, which hands the branch to the hook's caller.

A fallback is not a loading branch: posts ?? [], posts || [] and posts?.length show the empty state while the query is still loading, and fail. Destructuring the result (const { a } = useQuery(…)) throws while loading and always fails. The violation is reported on the declaration.

Limits: a guard counts wherever it is in the function, shadowed names are not told apart, and a custom hook's callers are not followed.

The rule: pending-action (opt-in)

What starts a write:

  • <form action={…}> with an expression (a server action or a function; a URL string is a native navigation and exempt), or formAction={…} on any element;
  • <form onSubmit={…}> whose handler is async, awaits, calls fetch or triggers a mutation;
  • onClick={…} on any element whose handler triggers a mutation.

A mutation is a call to what useMutation or useAction (plus mutationHooks) returned: save(), m.mutate(), a destructured mutate(). So is a server action imported from a "use server" module of the app (relative, @/ or ~/ imports). Handlers are followed through same-file functions, useCallback and wrappers like handleSubmit(onValid).

What shows it, anywhere inside the element (the whole form for a form):

  • a pending, isPending, loading, isLoading or aria-busy prop;
  • disabled={…} reading a pending-like name (pending, loading, submitting, saving, busy, …); disabled={!valid} does not count;
  • a {…} child reading one ({isPending ? "Saving…" : "Save"});
  • a component that calls useFormStatus anywhere in the app, or one of pendingComponents.

The violation is reported on the element. Limits: the pending name is matched by spelling, not traced to useTransition or useFormStatus, and a handler imported from another file is not followed.

Opting out

// state-coverage-ignore: renders a static JSON import, nothing to wait for
export default function Page({ params }: Props) {

In JSX, write it as {/* state-coverage-ignore: <reason> */} above the element. The directive covers a violation on its own line or the line below; a route violation is reported on the export default line. The reason is mandatory: // state-coverage-ignore without one fails and cannot be baselined. Every suppression is listed and counted in the report.

Baseline

state-coverage.baseline.json holds allowed counts per rule and file, not lines, so moving code around does not fail CI:

{
  "version": 1,
  "violations": {
    "next-route": { "src/app/[locale]/blog/[slug]/page.tsx": 1 }
  }
}

A file over its count fails with file:line. A file under it passes and prints shrink the baseline; run --update-baseline and commit the smaller file.

Library

import { check, readBaseline, readConfig } from "@hansenexus/state-check";
const app = "apps/hansenexus";
const { rules, options } = readConfig(app);
const report = check(app, readBaseline(`${app}/state-coverage.baseline.json`), { ...options, rules });

Rules implement Rule (id, description, check(ctx) → { violations, stats }) and are listed in RULES; DEFAULT_RULES holds the ones that run without a config.

Release

Push a state-check-v<version> tag matching package.json. The release-state-check workflow runs the tests, smoke-installs the packed tarball and publishes to npm with provenance over OIDC trusted publishing. gh workflow run release-state-check.yml -f dry_run=true does all of it without publishing.