@reactre/compat
v0.0.4
Published
React 19's API on reactre's templates: alias react and react-dom to it and a React app runs unchanged.
Maintainers
Readme
@reactre/compat
Run a React 19 app on reactre's templates, unchanged. Function components and hooks, context, memo, refs, portals, Suspense and lazy, transitions, synthetic events, controlled fields and hydration all work as they do in React, and libraries such as React Router, TanStack Query, Radix, framer-motion and Recharts run on it (some of them through the class build below).
bun add @reactre/compatAlias react, react-dom and their subpaths to it at build time. @reactre/plugin does it for Vite, Rollup, Rolldown, esbuild, webpack, Rspack and Bun, and compiles your JSX's HTML element trees into templates as well.
Vite
bun add -d @reactre/plugin @vitejs/plugin-react// vite.config.ts
import reactre from "@reactre/plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [reactre({ compat: true }), react()] });@vitejs/plugin-react brings Fast Refresh: save a component and it renders again in place, keeping its state, as with React. An edit that changes a component's hooks mounts it anew, and so does any edit to a class component, as React does. Fast Refresh is in compat's development build alone (dist/dev), which the development export condition resolves, as Vite's dev server does; the production build has none of its code, so its size and speed are as they were. @vitejs/plugin-react is optional: without it, Vite reloads the page when you save.
Without the plugin, an alias is enough. Vite then compiles JSX for React's automatic runtime on its own:
export default defineConfig({
resolve: {
// react, react-dom and every subpath of them, in your code and in your dependencies; the
// server renderer first.
alias: [
{ find: /^react-dom\/(server|static)(\.(browser|edge|node|bun))?(\.js)?$/, replacement: "@reactre/compat/server" },
{ find: /^react(-dom)?(\/.*)?$/, replacement: "@reactre/compat" },
],
},
});Other bundlers
The plugin has an entry for each: @reactre/plugin/rollup, /rolldown, /esbuild, /webpack, /rspack and /bun, each called as reactre({ compat: true }). See its docs.
Modules
| Module | What it is |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ |
| @reactre/compat | React's client API, whole: core and the feature modules |
| @reactre/compat/core | The slim base: React's client API without the feature modules |
| @reactre/compat/features/classes | Class components (lifecycles, setState, error boundaries), installed when loaded |
| @reactre/compat/features/stacks | React's component stacks in error reports, installed when loaded |
| @reactre/compat/features/keys | React's handling of a list whose keys repeat, installed when loaded |
| @reactre/compat/features/retry | React's retry of a render that throws, installed when loaded |
| @reactre/compat/features/concurrent | React's transition lanes, installed when loaded |
| @reactre/compat/features/viewTransitions | React's view transitions (needs the lanes, and loads them), installed when loaded |
| @reactre/compat/features/undo | React's handling of an urgent render that suspends with no boundary, installed when loaded |
| @reactre/compat/classes | @reactre/compat under its earlier name, for apps that resolve to it |
| @reactre/compat/server | react-dom/server: React's server rendering, on compat's hooks |
| @reactre/compat/static | react-dom/static: the same module, picked by react-dom/static's conditions |
| @reactre/compat/server.browser | react-dom/server.browser: the server with React's minified error codes |
| @reactre/compat/server.edge | react-dom/server.edge and .bun: the codes from renderToString alone |
| @reactre/compat/server.node | react-dom/server.node: the server with React's full error messages |
| @reactre/compat/compiler | The compiler @reactre/plugin runs, and a Bun plugin of its own |
| @reactre/compat/api | The keys that switch React's APIs and behaviours on and off, and their rules |
@reactre/compat is all of React's client API. The plugin builds narrower entries from the same modules: with compat: { classes: false }, or any key in compat.exclude, react resolves to a module built on @reactre/compat/core that loads each feature module still on, so every set of options behaves as React does for everything it keeps. Without class support, a class that renders throws an error naming the fix. Without the componentStack key, errors are reported with an empty componentStack; without repeatedKeys, a list whose keys repeat, which React warns about, is reconciled by compat's own rules; without renderRetry, an error a render throws goes to the nearest error boundary or the root at once, and nothing is reported as recoverable. See the plugin's docs. @reactre/compat/core alone, with no feature module, is for builds that choose all four off, as the js-framework-benchmark app does. The compiler is optional; without it, everything still runs.
Every change is checked against React 19 itself, scenario by scenario: the same app runs on both, and what each renders and logs must match line for line. Errors are React's production errors too: the client throws and reports the minified codes React's production client does (Minified React error #31; visit https://react.dev/errors/31?args[]=…, the full message one click away), and the full messages where React keeps them, as in Children.
Where the client differs
- Component stacks (
componentStackin whatonUncaughtError,onCaughtError,onRecoverableErrorandcomponentDidCatchget) are React's production stacks: host elements and boundaries by name, and each function and class component by the frame of a native stack trace, found as React'sdescribeNativeComponentFramefinds it, by calling or constructing the component without props when the error is reported (use()of a promise throws then, as React's does with no dispatcher). One case differs: under JavaScriptCore (Bun, Safari), a component that returns a hook's result directly, a tail call, has no frame of its own, so the frame shown is the hook's, in compat's code rather than React's. - Invalid props throw React's errors (#60, #61, #62, #91, #137, #284) where React's client throws them: a mount's as the element renders, an update's as it commits, after the whole render, so the rest of the tree renders and commits, and the nearest boundary gets the error, with a stack from the element, once the commit's effects ran. One case differs: an update that gives a void element children that render no node (
false,[], a component that renders nothing) does not throw. - Repeated keys: compat matches a list's rows against the old ones as React's production build does, down to the old row React's map hides staying in the DOM, never rendered again. Before building a map, compat matches what it can cheaply by position (rows that line up, one run of removed rows, two rows that traded places, the changes a compiled list reports); where a key repeats, the keys feature module sends a list those paths would take to React's algorithm. Without that module (
repeatedKeysoff, or@reactre/compat/corealone), such a list is matched by compat's own rules. - Hooks are checked as React's production client checks them: a render that calls fewer hooks than the last (#300) or more (#310; #467 in a later pass of a first render, and #312 in a later pass on the server, where compat counts effects as hooks and React's server does not), or a state or optimistic hook where the last render called a hook of another kind (#311). A render that calls no hook is not compared, as in React: the next one mounts its hooks anew. A hook of another kind that React does not check, such as a ref where state was, reads what compat kept in the slot, which is not always what React kept.
- A render that throws an error renders again before the error counts, as in React's production client (with
@reactre/compat/features/retry, therenderRetrykey): the root's first render renders again whole, and an update renders again the component that threw (or the boundary that caught, with its update undone), and only an error that render throws too is caught or reported. If it renders, the first error goes toonRecoverableErroras #520, with the stack it was first captured with. A throw captures the component stack twice, as React does, so React's calls of each component without props to read its frame match too. Where it differs:- Only an error a component's render throws renders again. An error from an element's props, a child's type, a class lifecycle,
lazyor a context consumer counts at once. React checks an update's props as it commits them, so for those it does the same; on a mount it renders the root again first. - In an update, React renders the whole root again, and compat only the component that threw, after the boundary above renders its fallback for show. The components between the boundary and the root render once, and elements a parent passed down from above are not rendered again.
- The retry does not compute
useMemoagain. - Hydration does not render again: it recovers as described below.
- Only an error a component's render throws renders again. An error from an element's props, a child's type, a class lifecycle,
getSnapshotBeforeUpdateis called as in React: after the whole render, children first, with the DOM as it was before the render (compat writes the DOM as it renders, so it undoes the render's changes around the calls and makes them again). Without aMutationObserverit is called before its class renders; once per update either way, however many times the update renders.- A root's first render whose shell suspends, with no Suspense boundary above, renders (or hydrates) once more, as React's prerender does. An update outside a transition that suspends with no boundary above commits what rendered, and renders the root again when the wait settles; React keeps the screen as it was, prerenders, and renders the update then (telling it apart would cost every render a snapshot).
- Transitions render as React's lanes do (with
@reactre/compat/features/concurrent, theconcurrentRenderingkey): their updates wait and render in a task after urgent ones, replayed in order over the urgent updates made since;isPendingrenders true first, on React's sync lane; transitions started in different events render one after the other, and those that change the same state together; updates made in an async action wait for it;useDeferredValuerenders the new value in a background transition; a store a transition read that moved meanwhile renders it again before it commits. A transition that suspends where content already shows (a boundary that shows it, or no boundary) commits nothing: the screen stays,isPendingstays true, and it renders again when the data comes or another update arrives. Compat gets there by rendering the transition in place and undoing it: a MutationObserver records its DOM changes, its roots' fibers are saved, and the effects, cleanups and ref detaches it would run wait for the commit. Where it differs:- No time slicing: a transition renders whole in its task, so an urgent update that arrives while it renders waits for it. A render shorter than React's 5 ms slice looks the same.
@reactre/full-compatslices as React does, at about 1 KB gzipped and a clock check before each component, in every render. - Render counts match React's in an attempt that suspends (the boundary's other children and its fallback render too), when a suspended transition renders again after another commit, and for a component whose state its transition leaves as it was (it renders, its children do not). Which of two transitions waiting at once renders first follows the transition lane each got, from React's cycle of 10 shared by the page, and an urgent update that sets state to the value it has while a transition waits to change it renders the component, which then bails out, as in React.
- Content a boundary can show again within 300 ms of the last fallback appearing renders at once and commits when the 300 ms end, as React's throttled reveal: its DOM changes are undone and its fibers put back until then, and an update that comes first drops the render, which renders again. Where the render cannot be undone (a root that hydrates, or no
MutationObserver), compat waits, then renders. - Undoing puts back attributes, text and nodes, the document metadata the render hoisted to
<head>(not what other code put there), the value and checkedness of inputs and textareas, and the options a select had selected. Scroll positions stay too, as no layout runs between the render and its undo, except an element's own that the render removed from the document or moved, which the browser resets as the element leaves it (Chrome). A root that is hydrating, or a page withoutMutationObserver, renders its transitions without undo, showing fallbacks as an update outside a transition does.
- No time slicing: a transition renders whole in its task, so an urgent update that arrives while it renders waits for it. A render shorter than React's 5 ms slice looks the same.
ViewTransitionworks as in React 19.3.1's client (with@reactre/compat/features/viewTransitions, theviewTransitionskey): a transition, or content a Suspense boundary reveals, that changes a<ViewTransition>commits throughdocument.startViewTransition, with the transition's types (addTransitionType), React'sview-transition-nameandview-transition-classon the elements of what leaves, enters, updates or pairs by name, the root's own transition cancelled when nothing outside changed, andonEnter,onExit,onUpdateandonSharecalled once it is ready, their cleanups once it finishes. A commit made while one runs waits for it to finish; an update before its update callback skips it and commits at once. Compat holds the render as it holds a throttled reveal: undone, its names put on the old DOM, then made again in the update callback. Without the API the transition commits plainly, as React's does. Where it differs: React's passive phase clears the names of elements it never named, which leaves an emptystyleattribute on them, and compat does not; which boundaries changed is worked out from the fibers saved before the render rather than from React's flags, so an exotic change React flags differently can name a boundary React does not.SuspenseListis not exported, as React 19.3.1's stablereactdoes not export it; an element made with its symbol (Symbol.for("react.suspense_list")) renders its children in order without coordinating when they reveal, where React's client coordinates them.- Hydration does what React's production client does. The shell hydrates in a task (at once inside
flushSync), and each Suspense boundary and Activity it meets stays dehydrated, the server's HTML in place, until a later pass hydrates it, nested boundaries a pass after the ones around them, with React's order of renders, effects and reports. A boundary whose content suspends keeps the server's HTML and hydrates once the content can render; one still streaming in (<!--$?-->,<!--$~-->) waits for the server, through_reactRetryand the document's load, and one the server could not finish (<!--$!-->) renders on the client first, with React's error. A click or another discrete event inside a dehydrated boundary, or before the shell hydrated, hydrates it first, outermost first, then reaches its handlers; while its target cannot hydrate, it reaches none. The latest continuous event of each kind (focusin,dragenter,mouseover, andpointeroverandgotpointercapturefor each pointer) whose target has not hydrated is kept, what blocks it hydrates before other boundaries, and it is dispatched again once nothing does. Claimed elements keep the server's attributes and styles until a prop changes, and take only what React'shydrateInstanceapplies: listeners, and an input's or textarea's initial value, checkedness and name. Where the server's markup does not match (an element, text or boundary the server did not render, server nodes the client does not render, text that differs), and for any other error thrown while hydrating, the nearest Suspense boundary or Activity the server rendered, or else the root, renders on the client, andonRecoverableErrorgets React's error (#418, or #422 or #423 caused by the error) and component stack. A whole document that renders on the client, or that unmounts after an uncaught error, keeps its<html>,<head>and<body>, and the scripts, styles and stylesheets in them, as React's singletons do.useSyncExternalStorehydrates withgetServerSnapshot, and without one renders on the client with React's error (#407, as the cause of #422 or #423); once it subscribes, a store whose snapshot is not the server's renders again. Where it differs:- An update that reaches a boundary still dehydrated hydrates the boundary first, with the props and context it had, in a commit of its own, then renders. Before each such boundary hydrates, the updated components render as far as it, a render that is then undone, as React renders them and drops that render (with
@reactre/compat/features/concurrent; without it, or withoutMutationObserver, that render is left out). In a transition, content that cannot hydrate yet keeps React's old UI until it can; compat, which does not undo a transition on a root that hydrates, renders that boundary on the client, as React does for any other update.
- An update that reaches a boundary still dehydrated hydrates the boundary first, with the props and context it had, in a commit of its own, then renders. Before each such boundary hydrates, the updated components render as far as it, a render that is then undone, as React renders them and drops that render (with
Server rendering
@reactre/compat/server is react-dom/server (and react-dom/static, and their .browser, .edge, .node and .bun builds) for compat apps; the plugin resolves them to it, each build to the entry that stands for it. renderToString, renderToStaticMarkup, renderToReadableStream and renderToPipeableStream write the HTML React 19.3.1's production build writes for the same tree, byte for byte: text separators, Suspense markers and the scripts that stream suspended content in, hoisted <title>, <meta> and <link> tags, image preloads, the links and scripts of preload, preinit and the other resource functions (or the Link header onHeaders gets), stylesheet precedence, form actions and useId's ids. Components call compat's own hooks, effects never run, and class components render whether or not the class build is loaded.
Server Components stay on React: the plugin leaves the RSC environment, and any build resolving with the react-server condition, on real React. Where compat differs from React's server renderer:
- Partial prerendering is React's:
prerender,prerenderToNodeStream,resumeAndPrerenderandresumeAndPrerenderToNodeStream(react-dom/static) write React's prelude and its postponed state, the same JSON, so compat resumes React's prerenders and React resumes compat's;resumeandresumeToPipeableStreamwrite the rest. The web entries enqueue as React's browser build and hash$ACTION_KEYwith MurmurHash3, the Node stream ones as its Node build, with MD5. React 19.3.1's stable builds have noonPostponeorunstable_postpone: only an abort postpones work, and anonPostponeoption is ignored, as React ignores it. Where React's own partial prerendering fails, compat fails the same way: aresumeAndPrerenderaborted inside a boundary it resumed fails with React's "It should not be possible to postpone at the root" error, and a resume ofSuspenseListrows that reveal in order or together never resolvesallReady. preload,preinitand the other resource functions called in async work a stream's render started, such as a promise callback, reach that render whereverAsyncLocalStorageexists (a global one, as edge runtimes have, ornode:async_hooks'), as in React's Node build and its edge build. Under Bun,react-dom/serverresolves to React's Bun build, which keeps no such storage and drops those hints; compat takes them there too, asreact-dom/server.nodedoes under Bun. WithoutAsyncLocalStorage, only code the render's work runs reaches it, as in React's browser build.useActionState's server action key ($ACTION_KEY) hashes the hook's place in the tree as each React build does for the entry: MD5 forrenderToPipeableStream, as React's Node build; MurmurHash3 forrenderToReadableStreamandrenderToString, as its browser, edge and legacy builds. React's Node build hashes itsrenderToReadableStream's keys with MD5 too, and its Bun build hashes withBun.hash; a postback still matches the server that wrote the form. (unstable_externalRuntimeSrcis ignored, as React's production build ignores it.)- Errors are those of React's build for the entry. React's browser builds (
react-dom/server.browserandreact-dom/static.browser, which a bundle for the browser or a web worker resolvesreact-dom/serverto) throw its minified production codes (Minified React error #62; visit https://react.dev/errors/62 …), and so does@reactre/compat/server.browser. Its Node build throws full messages, and so does@reactre/compat/server(and/server.node). Its edge and Bun builds throw full messages from the streams and minified codes fromrenderToStringandrenderToStaticMarkup, which they take from the browser build, and so does@reactre/compat/server.edge.@reactre/compat/serverand@reactre/compat/staticpick among the three withreact-dom's export conditions (browserandworkerthe browser entry,workerd,edge-lightand, for/server,bunthe edge one, and for/static,denothe browser one), so a bundle for the browser gets the codes and a Node server the full text, as with React. Each entry exports the whole API, where React's browser and edge builds leave out the Node stream functions, and the browser entry's Node stream functions throw the codes too. onError'scomponentStackis React's production stack, each component's frame read from a native stack trace as React reads it, with two exceptions. A component whose first call that throws without props isuse()of a promise gets a frame with only its name, as compat'suse()reads a promise outside a render where React's throws. Under JavaScriptCore (Bun, Safari), a component that returns a hook's result directly, a tail call, has no frame of its own, so the frame shown is the hook's, in compat's code rather than React's. Frames are formatted as React's browser, Bun and legacy builds format them; its Node and edge builds use anError.prepareStackTraceof their own, which Bun writes differently for an engine-made frame such as a class's implicit constructor.- Stream chunks are those of one React build per entry:
renderToPipeableStreamwrites as React's Node build does (4096-byte views, a long string as it is, and a pause when the writable is full until it drains),renderToReadableStreamenqueues as its browser and edge builds do (2048-byte views). React's Bun build, whichreact-dom/serverresolves to under Bun, writes each piece on its own instead. The bytes are the same.
Size
Each module bundled whole, minified, for the browser in production mode, with React 19.3.1 for comparison:
| Bundle | Minified | gzip | brotli |
| ----------------------------------- | -------: | ------: | ------: |
| @reactre/compat | 120.4 KB | 44.0 KB | 39.0 KB |
| @reactre/compat/core | 103.9 KB | 38.0 KB | 33.6 KB |
| react + react-dom/client 19.3.1 | 214.1 KB | 66.7 KB | 57.4 KB |
The feature modules cost an app about 29.6 KB minified, 10.9 KB gzipped: class support 1.6 KB gzipped, React's transition lanes 3.2 KB, React's view transitions 3.6 KB, component stacks 1.1 KB, React's render retry 0.8 KB and React's handling of repeated keys 0.4 KB, each left out with its key off. @reactre/compat/core holds the code of the keys, the retry, the lanes and the view transitions' commit, which a bundle that does not load their modules leaves out. Most of the runtime ships whatever an app imports: a counter with one useState and createRoot comes to 31.4 KB gzipped (20.5 KB on core alone), against 64.4 KB on React. Measured with Bun's bundler, gzip at level 9 and brotli at quality 11; Rolldown, the bundler in Vite, gives sizes within 1 KB of these.
License
Apache-2.0
