Skip to content

feat(jotai): add three Jotai rules + register jotai bucket#531

Merged
aidenybai merged 2 commits into
mainfrom
feat/jotai-rules-bundle
May 28, 2026
Merged

feat(jotai): add three Jotai rules + register jotai bucket#531
aidenybai merged 2 commits into
mainfrom
feat/jotai-rules-bundle

Conversation

@aidenybai

@aidenybai aidenybai commented May 27, 2026

Copy link
Copy Markdown
Member

Why

Catches three Jotai re-render footguns from peterp.me/articles/jotai-structural-sharing-vs-selectatom. Peter measured 44× more commits than the equivalent React Query consumer when these patterns appear in jotai-tanstack-query codebases — same query, same response, same render output, just a lot more React work.

jotai-select-atom-in-render-body (error severity)

selectAtom returns a new atom on every call. Calling it in a component / hook body without useMemo rebuilds the derived atom on every render → useAtomValue re-subscribes to a brand-new atom each render → infinite render loop. Jotai's own documented #1 footgun.

Before:

function MyComponent() {
  const sliceAtom = selectAtom(baseAtom, (s) => s.foo);
  return useAtomValue(sliceAtom);  // infinite re-render
}

After:

const sliceAtom = selectAtom(baseAtom, (s) => s.foo);  // module scope
function MyComponent() { return useAtomValue(sliceAtom); }
// or, when the selector depends on props:
function MyComponent({ field }) {
  const sliceAtom = useMemo(() => selectAtom(baseAtom, (s) => s[field]), [field]);
  return useAtomValue(sliceAtom);
}

jotai-derived-atom-returns-fresh-object

Jotai propagates derived values with Object.is. A derivation that returns a fresh ObjectExpression / ArrayExpression literal — OR an array-producing method chain (.filter(), .map(), .toSorted(), Object.entries(...), Array.from(...)) — fails Object.is on every notify and re-renders every consumer, even when the field values didn't change.

Before:

const summaryAtom = atom((get) => ({
  count: get(cartAtom).items.length,
  total: sum(get(cartAtom).items),
}));
// → every cartAtom notify re-renders every consumer of summaryAtom

After:

const countAtom = atom((get) => get(cartAtom).items.length);
const totalAtom = atom((get) => sum(get(cartAtom).items));
// → only consumers of the field that actually changed re-render

jotai-tq-use-raw-query-atom

Subscribing directly to atomWithQuery puts every consumer on the full QueryObserverResult broadcast path. TanStack rebuilds that envelope on every observer notify (refetches, focus events, no-op cache hits) — consumers re-render even when their field didn't change.

Before:

const userQueryAtom = atomWithQuery(() => ({ queryKey: ["user"], queryFn }));
function UserProfile() {
  const result = useAtomValue(userQueryAtom);  // 44× re-renders
  return result.data?.name;
}

After:

const userQueryAtom = atomWithQuery(() => ({ queryKey: ["user"], queryFn }));
const userDataAtom = atom((get) => get(userQueryAtom).data);
function UserProfile() {
  const data = useAtomValue(userDataAtom);  // 1× per data change
  return data?.name;
}

What changed

  • Added `jotai/` bucket to `scripts/generate-rule-registry.mjs` with category "State & Effects".
  • Added `jotai-select-atom-in-render-body` (error severity).
  • Added `jotai-derived-atom-returns-fresh-object` (warn). Handles concise arrow bodies (ParenthesizedExpression stripped), block bodies with multiple top-level returns (every return must be a fresh structure), renamed `get` parameters, array-producing method chains, and `Object.{keys,values,entries,fromEntries}` / `Array.{from,of}` static-method shapes.
  • Added `jotai-tq-use-raw-query-atom` (warn). Tracks file-local `atomWithQuery(...)` bindings (aliased factory imports OK) AND cross-file imports via the established `*QueryAtom` / `*SuspenseQueryAtom` / `*InfiniteQueryAtom` / `*SuspenseInfiniteQueryAtom` naming convention. Excludes `useSetAtom`, `atomWithMutation`, and bindings imported from `jotai` / `react` themselves.
  • All three self-gate via import detection (no new project capability needed). Rules fire zero times on projects that don't import from `jotai`.

Eval results

RDE not run — local runner is `git clone` + `npm install` bound (sequential, no Vercel sandbox credentials locally). Jotai usage in the existing OSS corpus is small (~2 hits in the manifest), so signal would be low even with a full RDE run. 45 co-located tests cover the documented bug shapes, every v1 valid case, aliased imports, renamed parameters, and the cross-file naming-convention path.

Test plan

  • `pnpm exec vp test run src/plugin/rules/jotai` → 45 passed
  • `pnpm --filter oxlint-plugin-react-doctor typecheck` → passes (`291 rules` registered)
  • `npx vp fmt --check` → clean
  • `npx vp lint` → clean

Note

Low Risk
Additive lint-only rules gated on Jotai imports; no runtime or auth changes, with bounded false-positive risk on the cross-file *QueryAtom naming heuristic.

Overview
Adds a jotai rule bucket (category State & Effects) and three import-gated oxlint rules for common Jotai re-render footguns.

jotai-select-atom-in-render-body (error) flags selectAtom from jotai / jotai/utils inside a component or use* hook body unless it sits in a useMemo / useCallback callback or at module scope—avoids rebuilding a new derived atom every render and infinite re-subscribe loops.

jotai-derived-atom-returns-fresh-object (warn) flags read-only atom((get) => …) derivations that return fresh object/array literals or outermost “allocating” chains (e.g. .map, Object.entries) when the body actually calls get, since Jotai dedupes with Object.is.

jotai-tq-use-raw-query-atom (warn) flags useAtom / useAtomValue on jotai-tanstack-query query atoms (file-local factory tracking plus cross-file *QueryAtom naming), and nudges subscribing via a field-derived atom instead of the full observer envelope.

Registry codegen and rule-registry.ts wire all three in; 45 co-located tests cover positive/negative cases, aliases, and edge paths.

Reviewed by Cursor Bugbot for commit 339340a. Bugbot is set up for automated code reviews on this repo. Configure here.

aidenybai added a commit that referenced this pull request May 27, 2026
…rmost method

Bugbot caught that walking inward past non-matching methods in
freshFromMethodChain produced false positives on chains like
`get(users).filter(fn).reduce(sum, 0)` — the outer `.reduce()`
returns a primitive that dedupes via Object.is correctly, but the
inward walk would find `.filter()` and flag the whole chain.

Only the OUTERMOST method decides whether the atom's value is a
fresh structure. Removes the inward walk; tightens the detector
to the documented contract.

Adds three regression tests covering the false-positive shapes the
bot called out: `.filter().reduce()`, `.find()/.some()/.includes()`,
and `.join()`.

Bugbot: #531 (comment)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2dd1069. Configure here.

aidenybai added 2 commits May 27, 2026 16:06
Three Jotai re-render correctness rules from
peterp.me/articles/jotai-structural-sharing-vs-selectatom/.
Peter measured 44× more commits than the equivalent React Query
consumer when these patterns appear in jotai-tanstack-query
codebases.

- `jotai-select-atom-in-render-body` (error severity): `selectAtom`
  called in a component / hook body without `useMemo` returns a NEW
  atom on every render. `useAtomValue` re-subscribes to that new
  atom, triggering an infinite render loop — jotai's own #1
  documented footgun. Skips module scope, `useMemo` / `useCallback`
  callbacks, non-jotai `selectAtom` imports, and lowercase non-hook
  helpers.

- `jotai-derived-atom-returns-fresh-object`: jotai propagates with
  Object.is; a derivation that returns a fresh ObjectExpression /
  ArrayExpression literal — or an array-producing method chain
  (.filter, .map, .toSorted, Object.entries(...), Array.from(...))
  — fails Object.is on every notify and re-renders every consumer.
  Handles concise arrow bodies (ParenthesizedExpression stripped),
  block bodies with multiple top-level returns (every return must
  produce a fresh structure), renamed `get` parameters. Skips
  primitive returns, `get(x).foo` member chains, write-only atoms,
  constant atoms, conditional-mix returns.

- `jotai-tq-use-raw-query-atom`: subscribing to `atomWithQuery` /
  `atomWithSuspenseQuery` / `atomWithInfiniteQuery` directly with
  `useAtomValue` puts every consumer on the full
  `QueryObserverResult` broadcast path. TanStack rebuilds that
  envelope on every observer notify; consumers re-render on every
  refetch, focus event, and no-op cache hit. Recommends deriving
  the field first: `atom((g) => g(queryAtom).data)`. Tracks
  file-local `atomWithQuery(...)` bindings, aliased factory
  imports, and cross-file imports via the established
  `*QueryAtom` / `*SuspenseQueryAtom` / `*InfiniteQueryAtom`
  naming convention.

Registers the `jotai/` bucket in
`scripts/generate-rule-registry.mjs` with category "State &
Effects". Rules self-gate via import detection
(no new project-capability needed).
…rmost method

Bugbot caught that walking inward past non-matching methods in
freshFromMethodChain produced false positives on chains like
`get(users).filter(fn).reduce(sum, 0)` — the outer `.reduce()`
returns a primitive that dedupes via Object.is correctly, but the
inward walk would find `.filter()` and flag the whole chain.

Only the OUTERMOST method decides whether the atom's value is a
fresh structure. Removes the inward walk; tightens the detector
to the documented contract.

Adds three regression tests covering the false-positive shapes the
bot called out: `.filter().reduce()`, `.find()/.some()/.includes()`,
and `.join()`.

Bugbot: #531 (comment)
@aidenybai aidenybai force-pushed the feat/jotai-rules-bundle branch from 2dd1069 to 339340a Compare May 27, 2026 23:06
@aidenybai aidenybai merged commit 849b25d into main May 28, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant