Skip to content

@astrojs/react check() emits spurious 'Invalid hook call' in React 19 dev because it invokes the candidate component #16767

@bykplx1

Description

@bykplx1

What version of astro are you using?

astro@6.2.1, @astrojs/react@5.0.5, react@19.2.5, react-dom@19.2.5.

Problem

In astro dev, every SSR pass that includes a React island whose top-level function calls any hook (useState, useEffect, useId, useSyncExternalStore, …) emits the React dev-mode warning to the server console:

Invalid hook call. Hooks can only be called inside of the body of a function component.
  1. mismatching versions of React and the renderer
  2. breaking the Rules of Hooks
  3. more than one copy of React in the same app

Hydration succeeds, the page returns 200, unit tests using renderToStaticMarkup pass, and astro build is clean — so it isn't a duplicated-React or rules-of-hooks problem. The warning is purely a side-effect of the renderer probe.

Root cause

packages/integrations/react/src/server.js check() (lines correspond to dist/server.js 26-37 in 5.0.5):

let isReactComponent = false;
function Tester(...args) {
  try {
    const vnode = Component(...args);
    if (vnode && (vnode["$$typeof"] === reactTypeof || vnode["$$typeof"] === reactTransitionalTypeof)) {
      isReactComponent = true;
    }
  } catch {}
  return React.createElement("div");
}
await renderToStaticMarkup.call(this, Tester, props, children);

Tester calls Component(...args) directly. When Component runs a hook, React's dev dispatcher fires console.error("Invalid hook call …") because the call did not originate from react-dom/server's normal render path (the dispatcher binding belongs to Tester, not Component). The thrown rules-of-hooks error itself is swallowed by the try/catch, but the console.error is not. The vnode is still returned, $$typeof still matches, isReactComponent becomes true, and hydration proceeds — the warning is cosmetic, but every Astro user with a hook-using island sees it on every dev request.

Reproduction

Minimal: any client:load React component that calls useState at the top level, served via astro dev with @astrojs/react 5.x and React 19.

What I tried (none worked)

  • vite.ssr.optimizeDeps.include: ['react','react-dom','react/jsx-runtime','react/jsx-dev-runtime']
  • vite.resolve.dedupe expanded
  • vite.ssr.resolve.conditions: ['node','import','module','default'] (skip react-server)
  • @astrojs/react 5.0.4 → 5.0.5

None affected the warning, confirming the source is check(), not module duplication.

Suggested fix

Replace probe-by-invocation with one of:

  1. Static source probe. Component.toString() for transpiled markers (React.createElement(, _jsx(, _jsxs(, jsx(, jsxs(). Cheap, no execution.
  2. Honor metadata.componentUrl unconditionally. The existing filter branch at line 22 already short-circuits when an explicit opts.include/opts.exclude is set; a metadata.componentUrl ending in .jsx/.tsx (or imported via the React renderer's resolved id) is a reliable positive signal without invocation.
  3. Hook-safe probe. Temporarily swap React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.ReactCurrentDispatcher.current to a no-op throwing dispatcher around the invocation so the rules-of-hooks check sees a "valid" render context. Fragile across React minors — not preferred.

(1) or (2) are clean and don't require touching React internals.

Local investigation thread

bykplx1/qa-learning-site#223 (comment)

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs triageIssue needs to be triaged

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions