Skip to content

React Refresh: Parenthesized arrow functions not detected as components #21024

Description

@oliveryasuna

The react-refresh transform does not detect React components when the arrow function initializer is wrapped in parentheses.

I noticed this after upgrading vite to 8.0.1 and @vitejs/plugin-react to version 6.0.1, which now uses Oxc instead of Babel. This causes Vite to fall back to a full page reload instead of hot module replacement.

Reproduction

// HMR works
const App = () => (<div>Hello</div>);

// HMR does NOT work – full page reload
const App = (() => (<div>Hello</div>));

export { App };

Both are semantically identical. The only different is the outer parentheses wrapping the arrow function expression.

I know...

I acknowledge that, at first glance, this may seem like a silly thing to fix, and the outer parentheses are more-so my own stylistic choice.

However, it is important to note that parenthesized expressions are semantically transparent. (expr) and expr are identical in JavaScript/TypeScript. I believe it fair to say that any AST-based tool that changes behavior based on their presence has a bug – it violates the language spec.

Suggested fix

Note I am not familiar with the oxc source, nor am I familiar with Rust. I hope my explanation makes sense.

Unwrap any parenthesized expressions before checking the arrow/function expression.

Workaround

Hopefully the following helps anyone who stumbles across this issue!

Here's a simple Vite plugin that strips outer wrapping parentheses:

// OXC's react-refresh doesn't unwrap parenthesized arrow functions.
// So, `const foo = ((): T => ...)` never gets registered for HMR.
const oxcRefreshWorkaround = ((): Plugin => {
  const findMatchingClose = ((code: string, open: number): number => {
    let depth = 1;
    let i = (open + 1);
    while(i < code.length) {
      if(code[i] === '(') {
        depth++;
      } else if((code[i] === ')') && (--depth === 0)) {
        return i;
      }
      i++;
    }
    return -1;
  });

  return ({
    name: 'oxc-refresh-workaround',
    enforce: 'pre',
    transform: ((code, id) => {
      if(!/\.[tj]sx$/.test(id)) {
        return;
      }

      const re = /\b(?:const|let)\s+[A-Z]\w*\s*=\s*\(/g;
      let out = '';
      let cursor = 0;
      let m;

      while((m = re.exec(code)) !== null) {
        const open = ((m.index + m[0].length) - 1);
        let next = (open + 1);
        while((code[next] === ' ') || (code[next] === '\n') || (code[next] === '\r') || (code[next] === '\t')) {
          next++;
        }
        if(code[next] !== '(') {
          continue;
        }

        const close = findMatchingClose(code, open);
        if(close === -1) {
          continue;
        }

        out += code.slice(cursor, open);
        out += code.slice((open + 1), close);
        cursor = (close + 1);
        re.lastIndex = cursor;
      }

      if(cursor === 0) {
        return;
      }

      out += code.slice(cursor);
      return out;
    })
  });
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions