Skip to content

feat(lint): lint the context hook contract in packages/react - #575

Merged
kianbazza merged 1 commit into
ui-617-lint-classname-and-style-resolutionfrom
ui-618-lint-the-context-hook-contract
Sep 29, 2026
Merged

kianbazza merged 1 commit into
ui-617-lint-classname-and-style-resolutionfrom
ui-618-lint-the-context-hook-contract

Conversation

@kianbazza

@kianbazza kianbazza commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

bazza/context-hook-contract enforces the naming contract for context hooks in packages/react/src. A context created with a null default is missing when a part renders outside its provider. useX promises a value or a clear error. A hook that can hand back the missing context is named useMaybeX.

const SelectContext = React.createContext<SelectContextValue | null>(null)

export function useSelectContext() {           ✓ checks, then throws
  const context = React.useContext(SelectContext)
  if (!context) throw new Error('Select components must be used within a Select.Root')
  return context
}
export function useMaybeSelectContext() {      ✓ says it can be missing
  return React.useContext(SelectContext)
}
export function useGroupContext() {            ✗ can return null under a `useX` name
  return React.useContext(GroupContext)
}
  • What counts as proof: the hook reads the context into a variable. Then, before any return, a top-level if tests that exact variable for the missing value (!ctx, ctx == null, or === null for a null default) and always throws. A ?? fallback whose fallback can't be null also counts.
  • Not reported: hooks that only return something derived from the context, such as useContext(Ctx) !== null or useContext(Ctx)?.depth.
  • Can't tell: shapes the rule can't follow, such as invariant(ctx) or a flag-guarded throw, get a "can't tell" report without the rename advice.
  • Scope: the rule checks contexts created in the same module, matched by scope. Every useContext in src today reads one of those.
  • Allowlist: seven hooks break it today:
    • useGroupContext
    • useSelectPositionerContext
    • useComboboxPositionerContext
    • useGraftPoint
    • useMenuTreeResolver
    • usePopupSurfaceId
    • useAsyncMenuCoordinator, which is identical to useMaybeAsyncMenuCoordinator
  • Refactor, no behaviour change: shared AST helpers (isCallTo, outermostWrapper, variableOf) move into tooling/lint/rules/ast.mjs, and the earlier rules use them.
  • Docs: packages/react/AGENTS.md gets a "Context hooks" section.

Evidence

  • Before: a new useFooContext() { return React.useContext(FooContext) } passed bun run check, and the first sign of trouble was a null crash somewhere downstream.
    After:
    `useFooContext` can return a missing `FooContext` (its default is `null`). Either check it and throw, naming the provider the part needs (…), or rename the hook `useMaybeFooContext` so callers know to check.
    
  • With the allowlist removed, the rule reports exactly the seven hooks above. Every strict hook passes, including useVideoPlayerContext(component), useContextMenuInternal and the seek slider's inline context.
  • The tooling/lint tests grow to 64. They cover every accepted proof shape, and every lookalike that can still return null: a flag-guarded throw, a dev-only throw, === undefined against a null default, a check on a property, an early return, ?? null, and checking a different variable.

Merge Danger

Door: two-way

Blast Radius: tooling

New context hooks in packages/react/src must throw or be named useMaybe*. Existing hooks are unaffected until someone removes them from the allowlist.

Closes UI-618

@kianbazza
kianbazza added this pull request to stack #572 September 29, 2026 14:48
@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ui-canary Ready Ready Preview Sep 29, 2026 3:09pm UTC

Request Review

@linear-code

linear-code Bot commented Sep 29, 2026

Copy link
Copy Markdown

UI-618

Adds `bazza/context-hook-contract`: a `useX` hook that reads a context created with a `null` default checks it and throws, or falls back to a non-null value; a hook that can return the missing context is named `useMaybeX`. The seven hooks that break it today are allowlisted. `packages/react/AGENTS.md` documents the pattern.
@kianbazza
kianbazza force-pushed the ui-618-lint-the-context-hook-contract branch from 7dffdcf to 53bbb9c Compare September 29, 2026 15:08
@kianbazza
kianbazza marked this pull request as ready for review September 29, 2026 15:41
@kianbazza
kianbazza merged commit 5fd354a into canary Sep 29, 2026
5 of 8 checks passed

This branch was successfully deployed

1 active deployment
Preview – ui-canary — 53bbb9c6 Deployed Sep 29, 2026 by vercel[bot]
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