Skip to content

Fix: Million.js Not Working — Compiler Errors, Components Not Optimized, or React Compatibility Issues

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Million.js issues — compiler setup with Vite and Next.js, block() optimization rules, component compatibility constraints, automatic mode, and debugging performance gains.

The Problem

The Million.js compiler throws during build:

npm run build
# Error: [million] Unsupported node type in block

Or a component wrapped in block() crashes at runtime:

import { block } from 'million/react';

const MyComponent = block(function MyComponent({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});
// Error: Cannot read properties of undefined (reading 'map')

Or the optimized component renders incorrectly:

const Counter = block(function Counter({ count }) {
  return <div>{count > 0 ? <span>{count}</span> : <span>Zero</span>}</div>;
});
// Renders wrong output when count changes between 0 and positive

Or Million.js doesn’t seem to make any performance difference:

No visible speed improvement after adding block()

Why This Happens

Million.js is a compiler that optimizes React components by replacing React’s virtual DOM diffing with direct DOM manipulation. It works by analyzing components at build time and generating optimized code:

  • block() has strict rules about what components can contain — the compiler needs to statically analyze JSX. Dynamic children (.map()), conditional rendering with different JSX trees (? <A/> : <B/>), and spread props aren’t always supported. Components that violate these rules crash at compile time or render incorrectly.
  • Not all components benefit from optimization — Million.js shines with components that re-render frequently with changing props (like list items, data tables, animations). Static components, components that rarely re-render, or components with complex nested children see little to no improvement.
  • The compiler plugin must be installed correctly — Million.js transforms code at build time via a Vite or webpack plugin. Without the plugin, block() is a no-op wrapper and provides zero optimization.
  • Automatic mode has broader compatibility but less control — Million.js’s automatic mode tries to optimize all components without explicit block() calls. It’s more forgiving but may miss optimizations or cause issues with incompatible components.

A subtler failure is that Million.js’s static analyser does not have a complete model of TypeScript generics. Components that look fine to the human eye but use generic type parameters with conditional return types can confuse the compiler into emitting code that types-check but renders the wrong branch. Watch for this whenever a generic list component breaks only when you flip the discriminating prop.

Finally, Million.js’s release cadence has historically tracked React canary closely, which means a working setup on React 18.2 can break the moment you upgrade to a React 19 release that changes its internal scheduler hooks. Pin both million and react to known-good versions in the same PR rather than upgrading independently.

Platform and Environment Differences

React 18 versus React 19 is the single most consequential variable. Million.js 3.x targets React 18’s useSyncExternalStore and the legacy scheduler; the React 19 stable release rewires the scheduler and introduces the React Compiler (react-compiler / babel-plugin-react-compiler). Running both Million.js and React Compiler on the same component double-optimises and produces hydration mismatches in production builds — pick one optimiser per component. If you upgrade to React 19, audit every block() site and confirm the Million release notes call out 19 compatibility; otherwise lock React to 18.3.x until upstream catches up.

Next.js App Router versus Pages Router changes plugin wiring. Pages Router exposes a single next.config.js that million.next() can wrap cleanly. App Router has the same entry point but additionally runs Server Components, and Million.js cannot optimise Server Components — only Client Components marked with 'use client'. Wrapping a server component in block() produces a build error mentioning react-server exports. Always confirm the optimised file starts with 'use client'.

SSR versus CSR matters because Million.js generates code that mutates real DOM nodes directly. During SSR the DOM does not exist, so the optimised render path is skipped and React’s normal renderToString runs. The hydration phase then takes over with Million’s patcher, which means any difference between the server-rendered HTML and the first client patch shows up as a hydration warning. Components that read window, localStorage, or Date.now() during render need defensive guards or a useEffect post-mount hydration pattern.

Babel versus SWC transform support has shifted over time. The original million/compiler shipped a Babel plugin, then SWC support, then a Rust-native transform inside the Vite/Next plugins. Custom Babel pipelines (CRA-with-craco, Storybook with babel-loader, Jest’s babel-jest) need the Babel plugin variant: ["million/babel", { auto: true }]. SWC users on Next.js get the optimisation through million.next() automatically. Mixing pipelines (Storybook uses Babel, Next uses SWC) causes inconsistent behaviour between dev and prod — pin one and use it everywhere.

Fix 1: Set Up the Compiler

npm install million

Vite:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import million from 'million/compiler';

export default defineConfig({
  plugins: [
    million.vite({ auto: true }),  // Auto mode — easiest setup
    react(),
  ],
});

Next.js:

// next.config.mjs
import million from 'million/compiler';

const nextConfig = {
  reactStrictMode: true,
};

export default million.next(nextConfig, {
  auto: true,  // Auto-optimize compatible components
  // auto: { threshold: 0.05 },  // Only optimize components above 5% render time
});

Webpack (Create React App or custom):

// webpack.config.js or craco.config.js
const million = require('million/compiler');

module.exports = {
  plugins: [
    million.webpack({ auto: true }),
  ],
};

Fix 2: Manual block() Optimization

When auto mode isn’t enough or you want explicit control:

import { block } from 'million/react';

// GOOD — simple props, no dynamic children
const UserCard = block(function UserCard({
  name,
  email,
  avatar,
  isOnline,
}: {
  name: string;
  email: string;
  avatar: string;
  isOnline: boolean;
}) {
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{email}</p>
      <span className={isOnline ? 'online' : 'offline'}>
        {isOnline ? 'Online' : 'Offline'}
      </span>
    </div>
  );
});

// GOOD — list item component (where Million.js helps most)
const TableRow = block(function TableRow({
  id,
  name,
  value,
  change,
}: {
  id: string;
  name: string;
  value: number;
  change: number;
}) {
  return (
    <tr>
      <td>{id}</td>
      <td>{name}</td>
      <td>${value.toFixed(2)}</td>
      <td style={{ color: change >= 0 ? 'green' : 'red' }}>
        {change >= 0 ? '+' : ''}{change.toFixed(2)}%
      </td>
    </tr>
  );
});

// Usage — the parent handles the list, block optimizes each item
function StockTable({ stocks }: { stocks: Stock[] }) {
  return (
    <table>
      <tbody>
        {stocks.map(stock => (
          <TableRow
            key={stock.id}
            id={stock.id}
            name={stock.name}
            value={stock.value}
            change={stock.change}
          />
        ))}
      </tbody>
    </table>
  );
}

Fix 3: Understand block() Constraints

import { block } from 'million/react';

// ❌ WRONG — .map() inside block creates dynamic children
const BadList = block(function BadList({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

// ✅ CORRECT — use For component from million/react
import { For } from 'million/react';

function GoodList({ items }: { items: Item[] }) {
  return (
    <ul>
      <For each={items}>
        {(item) => <li>{item.name}</li>}
      </For>
    </ul>
  );
}

// ❌ WRONG — conditional rendering with different JSX structures
const BadConditional = block(function BadConditional({ show }) {
  return show ? <div><span>Content</span></div> : <p>Empty</p>;
  // Different element types (div vs p) — block can't handle this
});

// ✅ CORRECT — same structure, different content
const GoodConditional = block(function GoodConditional({ show }) {
  return (
    <div>
      <span style={{ display: show ? 'block' : 'none' }}>Content</span>
      <span style={{ display: show ? 'none' : 'block' }}>Empty</span>
    </div>
  );
});

// ❌ WRONG — spread props
const BadSpread = block(function BadSpread(props) {
  return <div {...props} />;
});

// ✅ CORRECT — explicit props
const GoodExplicit = block(function GoodExplicit({
  className,
  style,
  children,
}: {
  className: string;
  style: React.CSSProperties;
  children: React.ReactNode;
}) {
  return <div className={className} style={style}>{children}</div>;
});

// ❌ WRONG — hooks inside block
const BadHooks = block(function BadHooks({ id }) {
  const [data, setData] = useState(null);  // Hooks not supported in block
  return <div>{data}</div>;
});

// ✅ CORRECT — lift hooks to parent, pass as props
function ParentWithHooks({ id }: { id: string }) {
  const [data, setData] = useState(null);
  useEffect(() => { fetchData(id).then(setData); }, [id]);
  return <OptimizedDisplay data={data} />;
}

const OptimizedDisplay = block(function OptimizedDisplay({
  data,
}: {
  data: any;
}) {
  return <div>{data?.name ?? 'Loading...'}</div>;
});

Fix 4: The For Component (Optimized Lists)

import { For } from 'million/react';

// For replaces .map() with an optimized list renderer
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      <For each={todos}>
        {(todo) => (
          <li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.text}
          </li>
        )}
      </For>
    </ul>
  );
}

// For with complex items
function DataGrid({ rows }: { rows: DataRow[] }) {
  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Value</th>
        </tr>
      </thead>
      <tbody>
        <For each={rows}>
          {(row) => (
            <tr>
              <td>{row.id}</td>
              <td>{row.name}</td>
              <td>{row.value}</td>
            </tr>
          )}
        </For>
      </tbody>
    </table>
  );
}

Fix 5: Automatic Mode Configuration

// vite.config.ts — fine-tune automatic optimization
import million from 'million/compiler';

export default defineConfig({
  plugins: [
    million.vite({
      auto: {
        // Skip specific components
        skip: ['ComplexComponent', 'LegacyWidget'],
        // Or use a threshold — only optimize components that render above this time
        threshold: 0.05,  // 50ms
      },
      // Telemetry for debugging
      telemetry: false,
    }),
    react(),
  ],
});
// Opt-out specific components from auto mode
// Add a comment to skip optimization

// million-ignore
function ComplexComponent() {
  // This component won't be optimized
  return <div>...</div>;
}

// Or use the runtime skip
import { renderReact } from 'million/react';

function AlwaysReactComponent({ data }) {
  // Force React's normal rendering path
  return renderReact(() => <ComplexTree data={data} />);
}

Fix 6: Measure Performance Gains

// React DevTools Profiler — compare before/after
// 1. Open React DevTools → Profiler tab
// 2. Record a session without Million.js
// 3. Enable Million.js and record again
// 4. Compare render times for optimized components

// Console measurement
import { block } from 'million/react';

const OptimizedRow = block(function OptimizedRow({ data }) {
  return (
    <tr>
      <td>{data.name}</td>
      <td>{data.value}</td>
    </tr>
  );
});

// Benchmark with many items
function BenchmarkTable() {
  const [items, setItems] = useState(() =>
    Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      name: `Item ${i}`,
      value: Math.random() * 100,
    }))
  );

  function shuffleItems() {
    console.time('render');
    setItems(prev => [...prev].sort(() => Math.random() - 0.5));
    // Measure in useEffect or requestAnimationFrame
    requestAnimationFrame(() => {
      console.timeEnd('render');
    });
  }

  return (
    <div>
      <button onClick={shuffleItems}>Shuffle (10k rows)</button>
      <table>
        <tbody>
          <For each={items}>
            {(item) => <OptimizedRow key={item.id} data={item} />}
          </For>
        </tbody>
      </table>
    </div>
  );
}

Still Not Working?

“Unsupported node type in block” at build time — the component uses a JSX pattern that the Million.js compiler can’t statically analyze. Common culprits: .map() calls (use <For> instead), spread props ({...props}), function calls that return JSX, or components as variables (const Comp = condition ? A : B). Simplify the component or add a // million-ignore comment.

Component renders correctly the first time but breaks on updateblock() generates code that patches the DOM directly instead of diffing a virtual tree. If the component’s structure changes between renders (e.g., <div> becomes <span>), the patch fails. Keep the JSX structure stable — hide/show with CSS or conditional content rather than conditional elements.

No performance improvement visible — Million.js helps most with components that re-render frequently with many prop changes (data tables, charts, real-time dashboards). If your component re-renders once or twice, the overhead of normal React diffing is negligible. Profile your app first to find the actual bottlenecks before optimizing.

Auto mode causes random components to break — add the breaking components to the skip list: auto: { skip: ['BrokenComponent'] }. Auto mode analyzes components heuristically and can’t always determine compatibility. Components with complex hooks, refs, or context dependencies may not be compatible.

Hydration mismatch warnings only in production builds — Million.js’s optimised patcher runs after React hydrates the server-rendered HTML, so any branch that reads browser-only APIs during render shows up as a mismatch. Move window, document, and localStorage reads into useEffect and gate the initial render with a mounted flag. If the mismatch persists, temporarily disable Million.js for that route to confirm the regression is in the patcher, not your component.

Build succeeds but the optimised component is double-rendered with React Compiler enabled — React 19’s compiler also memoises components. When both compilers process the same file, you can see the component mount twice. Exclude Million.js paths from the React Compiler config (compilationMode: 'annotation' and remove 'use memo' from optimised files) or exclude React Compiler from Million-targeted files.

Production bundle is larger than expected after enabling Million.js — the runtime helpers add roughly 4-6KB gzip even when only one component is optimised. If your bundle budget regressed, audit which components actually need the optimisation. For most apps a handful of hot list items is enough; wrapping every component negates the win because the helpers ship for components that barely re-render.

For related React performance issues, see Fix: React useState Not Updating, Fix: React memo Not Working, Fix: React Compiler Not Working, and Fix: Next.js Build Failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles