CLAUDE.md
August 24, 2026 · View on GitHub
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is
ReactUnity: a React renderer that draws UI inside Unity3D (UGUI, UIToolkit, and the Unity Editor) without a DOM. This is a monorepo formed by merging ~10 previously separate ReactUnity repos (renderer, scripts, material, create, core, jint, quickjs, clearscript, docs, tests, full-sample (now kitchen-sink/), samples) with their histories preserved — see the chore: merge X into Y commits. Much of the layout only makes sense in that light, and the workflow files under .github/workflows/ carry detailed comments explaining why each piece is shaped the way it is. Read those before changing CI.
Two package universes
npm (packages/*) | UPM / Unity (unity/*) | |
|---|---|---|
| Members | @reactunity/renderer, scripts, material, create | com.reactunity.core, jint, quickjs, clearscript |
| Consumed by | the user's React app | the user's Unity project |
| Published by | .github/workflows/release-npm.yml (Tegami + npm OIDC) | .github/workflows/release-upm.yml (manual dispatch, orphan branch per package) |
All eight share one version number (currently 0.22.0). Tegami bumps packages/*; the syncUnityVersions plugin in scripts/tegami.mts copies that version into the four unity/*/package.json manifests.
unity/*/package.json files are UPM manifests, not npm manifests. Their dependencies are Unity package names (com.unity.editorcoroutines). They are deliberately excluded from the pnpm workspace — adding them would send pnpm to the npm registry looking for Unity packages.
pnpm workspace membership
Defined in pnpm-workspace.yaml, which documents every inclusion and exclusion. Members are: packages/*, unity/core/.react/* (three React apps embedded in the core Unity package; the .react dot-prefix hides them from Unity's asset importer), kitchen-sink/react, and docs.
Explicit non-members: samples/** (rotted — React 19 alongside React-18-capped redux deps), packages/create/scaffold/** (a template whose deps are placeholders), and anything under Library/ or PackageCache/ (Unity's own package cache is full of package.json files).
overrides in the same file force every @reactunity/* request to resolve to the workspace copy — without it pnpm downloads registry copies of packages that exist right here, and hard-fails once the local version is one npm has never seen.
Commands
Node >= 26 (.node-version pins 26), pnpm 11.17.0 via packageManager.
pnpm install
pnpm build
Builds packages/* only, topologically (material needs renderer's dist). tsdown is the bundler — one tsdown.config.ts per package, and tsc never emits. Output is bundled per entry rather than mirroring src, and targets ES2015, which is rolldown's floor (it cannot emit ES5). The config comments cover the externals.
pnpm typecheck
tsc --noEmit across the workspace, and the only thing that type checks — neither tsdown nor webpack does. CI runs it as its own step.
pnpm check
Biome lint + format check over the whole repo — this is what CI runs. pnpm lint and pnpm format are the narrower variants. Husky + lint-staged run biome check --write on commit.
Per-package work uses pnpm filters:
pnpm --filter @reactunity/renderer build
pnpm --filter @reactunity/material watch
Running a React app against Unity
Each app (kitchen-sink/react, unity/core/.react/*) uses react-unity-scripts, a CRA fork:
pnpm --filter reactunity-kitchen-sink start
start runs a dev server with HMR that Unity connects to (and serves a browser previewer at the port). build emits to BUILD_PATH — by default ../Assets/Resources/react, overridden per app in its .env. react-unity-scripts start --test swaps the entry point to test.ts. See packages/scripts/README.md for the full env-var surface (FILENAME, BUILD_PATH, JSX_IMPORT_SOURCE, …).
The documentation site
docs/ is an Astro site (it was a Next 12 fork of react.dev until the migration; the shape and MDX component set are still React's). pnpm --filter react-website start serves it on port 4321, pnpm --filter react-website build writes docs/dist. There is no type-check command for it -- see below. docs/README.md explains how a page is assembled; the parts worth knowing before editing it:
- Pages are
.mdxfiles indocs/src/content, wired up bydocs/src/content.config.tsand rendered bydocs/src/pages/[...slug].astro, which also owns the element-name → component map. Navigation, titles and prev/next links come fromsidebarLearn.json/sidebarReference.json, not frontmatter.layout:is not usable in frontmatter — Astro treats it as a component to import. - Almost everything renders to static HTML. Only three things ship JS: the nav (
client:load), the table of contents (client:load) and each<Sandpack>(client:visible). Components in the MDX map are.astrofiles on purpose — a React component there receives its children as an opaque<astro-slot>blob, so anything that needs to read its children (<Sandpack>'s code fences) gets the data from a remark plugin instead. - One Unity WebGL player is shared by every example on a page, via a module-level singleton in
docs/src/components/unity/global.tsx. It cannot be React context: each Sandpack is a separate island with its own React root, and the player is a ~100 MB download.
Unity C# tests
pnpm unity compile tests
pnpm unity test tests
pnpm unity player tests --backend il2cpp
scripts/unity/ drives a local Editor headlessly — compile (~8 s warm, the cheapest check on any C# edit), test, player, open, editors, and bridge for talking to an Editor that is already open. pnpm unity help lists it all, and .claude/skills/unity covers which path to use and what bites.
player is the only check here that covers IL2CPP, and the reason it exists is that the Editor is always Mono: nothing compile or test reports says anything about a P/Invoke stub the AOT compiler had to generate, a reverse callback it never saw, or a type the managed stripper deleted — which is most of what the QuickJS binding is made of. It builds a development player and runs EngineProbe inside it, which drives every engine in the build across the boundary and prints a verdict the runner reads back. Both halves are gated on REACT_UNITY_DEVELOPER, so none of it ships, and it is deliberately not on CI (it needs a C++ toolchain and the IL2CPP module). --backend mono builds the same player the other way, which is how an AOT failure gets told apart from a plain bug.
Unity's own unity CLI (July 2026, installed at ~/AppData/Local/Unity/bin/unity.exe, its skill at ~/.claude/skills/unity-cli) covers everything bridge does and much more against an Editor that is open, via the com.unity.pipeline package kitchen-sink picked up in b8225447. It does not cover the batch commands, because it launches the Editor without snapshotting the files a local run rewrites — the trap described next. The skill has the mapping.
Two things worth knowing before running any of it:
- The editor version comes from each project's
ProjectSettings/ProjectVersion.txt— whichever Editor last opened the project is the one the CLI drives. Nothing is pinned in the scripts.UNITY_VERSION=overrides per run, and only then is that stamp restored afterwards.tests/loads and runs on 6000.5.9f1, so the older note that the 6000.5 line could not run it at all is wrong. Both suites are green there: EditMode 342/350 and PlayMode 690/701, zero failures. It was not always —ButtonTestsandInputTestsused to fail on that editor and only that editor, because the suite transformed its JSX by running Babel inside QuickJS and Babel's parse-then-traverse depth did not fit the main-thread stack 6000.5 leaves. Replacing it with Sucrase fixed all 15; .claude/skills/unity/SKILL.md keeps the measurements, and they are the ones to beat before putting Babel back. A local pass is still not a matrix pass. That failure wascom.unity.inputsystem1.14.2, whose editor assemblies fail obsolete-as-error there — and 1.14.2 was only ever the manifest's minimum, which the resolver dropped back to whenever it had reason to re-resolve. The minimums are now raised past it.test-framework,uguiandext.nunitstay where they are because they arebuiltinand the editor supplies its own. CI runs 6000.0.51f1/6000.1.9f1, so a local pass is still not a matrix pass. - Opening
tests/rewrites its manifest into a 6000-only shape —com.unity.ugui2.x, notextmeshpro, plusmodules.physicscore2d/vectorgraphics/adaptiveperformance— and that manifest fails to resolve on 6000.1 (measured), which yields zero tests rather than a red suite. The CLI snapshots those files and restores them after every run;--no-restoreopts out. Restore covers batch runs only — an interactive Editor churns them freely, so checkgit statusafter one.
The Test Runner window still works, as does .github/workflows/unity-tests.yml for the real matrix. tests/Packages/manifest.json already points at file:../../unity/*, so the four Unity packages are wired up with no patching.
Driving an Editor that is already open is not this CLI's job any more. It was, through an AgentBridge loopback server in its own asmdef plus a pnpm unity bridge client; both were deleted once com.unity.pipeline was in both projects, because Unity's CLI covers every action they had and a great deal more. A repo-specific action wanted in a live Editor is now a [CliCommand] static method in an Editor assembly, which unity list discovers with no CLI release.
Each fixture's JSX snippet is transpiled at runtime, inside the engine under test, by
CodeTransformer — so the transpiler's own call
depth is charged to Unity's main-thread C stack, which is already deep. That budget is the whole
reason it is Sucrase and not Babel: Sucrase rewrites a token stream, where Babel parses to an
AST and traverses it, and Babel's floor did not fit on the 6000.5 editor. disableESTransforms is
on deliberately — lowering optional chaining breaks C# method handles under ClearScript, and all
three engines run the modern syntax natively anyway.
The bundle it loads is generated, so do not edit
unity/core/Editor/Resources/ReactUnity/tests/scripts/sucrase-standalone.js:
pnpm build:test-transformer
scripts/test-transformer/build.mts bundles it with esbuild and
stamps the Sucrase version into a header comment. Commit the result. Biome excludes it as generated
output rather than for size — unlike the 3.7 MB @babel/standalone it replaced, which was over
Biome's 1 MiB per-file ceiling and so failed pnpm check outright. The docs site still uses
@babel/standalone (8.x, a real dependency) for its Sandpack examples, which is fine: that one runs
in the browser, where stack is not scarce.
Rendering tests compare against snapshots in unity/core/Tests/.snapshots/{linux,windows}. To regenerate: the React > Tests > Overwrite Snapshots editor menu toggle (needs the REACT_UNITY_DEVELOPER define), the -reactOverwriteSnapshots command-line arg, [snapshots] in a commit message, or the workflow's overwrite-snapshots dispatch input. CI commits regenerated snapshots from the one matrix job marked main: true.
Releasing
pnpm tegami
Tegami config lives in scripts/tegami.mts (unrelated to packages/scripts, despite the name). Changelog entries are pending .tegami/*.md files; tegami ci on main either opens a "Version Packages" PR or publishes from the committed publish lock. UPM releases are separate and manual (release-upm.yml, workflow_dispatch).
The Kitchen Sink sample
kitchen-sink/ is both the project ReactUnity is manually tested against and the sample users are pointed at, so it is published standalone on the kitchen-sink orphan branch by release-kitchen-sink.yml.
node scripts/kitchen-sink/prepare.mts Logs/kitchen-sink --force
prepare.mts is the whole transform, and the workflow only packages what it produces — so run it locally to see exactly what users get. It copies the tracked files (git ls-files, which keeps the exclusion list honest), rewrites file:../../unity/* and workspace:* to the current published version, drops com.reactunity.jint/clearscript and the testables block, strips REACT_UNITY_DEVELOPER, and moves any scene pinned to a dropped engine back to EngineType: Auto.
Then it verifies, which is the part that matters: it fails if OpenUPM or npm have not published the pinned version yet. That is why this workflow is not chained to release-npm.yml the way release-upm.yml is — both registries build asynchronously after a release, and a manifest pinning a version they do not have gives a user an empty Packages folder, the same silent failure the file: refs cause. Dispatch it once they have caught up.
Anything added to kitchen-sink/ that only works inside this checkout has to be handled in prepare.mts, or the exported project breaks in a way nothing here would catch.
Architecture
The JS ↔ C# boundary
@reactunity/renderer is a react-reconciler host config that never touches a DOM. Two reconcilers live side by side in packages/renderer/src/renderer/:
sync/— calls into C# directly, one interop call per operation.async/— the default. Serializes mutations into a command buffer (async/commands.ts,async/serializer.ts), flushed once per microtask. Objects crossing the boundary are handles tracked inasync/objects.ts. Batching is what makes inline rich-text and SVG subcontexts possible; disabling it (disableBatchRendering) trades those away for lower per-call overhead on Jint.
The C# side of that call surface is unity/core/Runtime/Core/ReactUnityBridge.cs — createElement, appendChild, applyUpdate, and friends. Everything reachable from JS is [Preserve]d against IL2CPP stripping.
Type models are generated from C#
packages/renderer/src/models/generated/*.ts is emitted by unity/core/Editor/Developer/TypescriptModelsGenerator.cs, which reflects over the Unity assemblies. Biome ignores models/generated. Do not hand-edit those files — change the C# type (or the generator's include/remap options) and regenerate from the Unity Editor.
Rendering frameworks
unity/core/Runtime/Frameworks/ holds three backends behind the same component interfaces: UGUI (the mature one, with its own measurers, shapes, and state handlers), UIToolkit, and Noop (headless, used by tests). Each has its own asmdef. The matching TS type surfaces are @reactunity/renderer/ugui, /uitoolkit, /editor — each with its own jsx-runtime, so a project picks its element namespace by which one it imports.
JavaScript engines
unity/core/Runtime/Scripting/ defines IJavaScriptEngine plus DOM shims (DomProxies/ — fetch, XMLHttpRequest, WebSocket, localStorage, URL). Concrete engines ship as separate UPM packages so a project pulls in only one native binary: com.reactunity.quickjs (recommended), jint (pure C#, slower), clearscript (V8).
com.reactunity.quickjs binds quickjs-ng. It used to bind unity-jsb's fork of Bellard-era QuickJS, and the two are different engines rather than two versions of one — which is why the binary, the C shim and every P/Invoke declaration were rebuilt rather than upgraded. unity/quickjs/MIGRATION.md is the record of that, and is worth reading before changing anything under Runtime/Source/Native.
The C# is still unity-jsb's design — namespace QuickJS.*, assemblies jsb.core/jsb.native/jsb.shared/jsb.editor.binding, and the JSB_* shim symbols — but nothing is fetched from or linked against unity-jsb any more. All eleven native artifacts are built from native/quickjs by native-quickjs.yml and pinned to gkurt/quickjs v0.16.2-reactunity.1, a fork carrying the two async-module-loader additions that are not upstream yet.
Asynchronous module loading is what this bought: an import of an http URL, and so a dynamic import(), resolves without blocking a frame. Every target has it, WebGL included, so EngineCapabilities.ModuleResolution is claimed everywhere and the host import hook that stood in for it is gone. ModuleCompat is down to NeedsModuleScope: no engine needs its code rewritten, only its document type decided.
WebGL gets there differently, because there is no QuickJS in it. The host half is shared — QuickJSModuleLoader resolves and fetches, so import './x' obeys ReactUnity's paths on both — but the linking is the browser's: jsbplugin.ts assembles each module into a blob URL, rewriting every specifier to its dependency's URL, and imports the root. A module cannot see the globals proxy the rest of that backend runs inside (with is illegal in module code), so each one opens with a generated var {…} = … prelude of the host globals it mentions. MIGRATION.md's "The second implementation" has the four rules that prelude has to follow and why each was a bug first. Cycles are refused there — a blob URL needs final text, and a cycle's is not.
The jslib is generated; edit .source/jsbplugin.ts, never jsbplugin.jslib. TypeScript 5 is pinned (npx -p typescript@5 tsc && node postbuild.mjs in .source) because TS 7 removed every option the build needs and has no ES5 emit, which Emscripten still requires. ES5 is not decoration: async/await, spread and for…of all downlevel to helper functions tsc puts at the top of the file, and Emscripten only emits the library object's own members — so a helper reference is undefined at runtime. Plain .then() chains and forEach only. native-quickjs.yml rebuilds and diffs the jslib, so a hand-edit or a forgotten rebuild fails CI, and runs the module tests:
node --test unity/quickjs/Plugins/QuickJS/WebGL/.source/jsbplugin.test.mjs
Styling
unity/core/Runtime/Styling/ implements a CSS subset over Yoga flexbox. Note for anything UI-facing: flex direction defaults to column, not row; CSS cannot style SVG icons from libraries like react-icons (use their color/size props); emoji are not reliably supported.
Toolchain traps
These are load-bearing and easy to undo (see commit 43e90688):
packages/scripts/tsconfig.jsonmust keeppreserveSymlinks: false. Every consumer extends this config; under pnpm every dependency is a symlink, andtruebreaks module identity (renderer'sfetch/Responseglobals silently drop out of scope).- Loaders in
packages/scripts/config/webpack.config.jsmust berequire.resolve'd, not bare strings — webpack resolves loader strings against the consuming app's directory, which only ever worked under npm's flat hoisting. kitchen-sink/reactis"type": "module", so its webpack config iswebpack.config.cjs;config/paths.jsprefers a.cjssibling.- Root
.npmrcsetsnode-options="--import tsx"(so.mtsconfig is runnable) andstrict-peer-dependencies=false. - TypeScript is 7.x everywhere,
docsincluded -- which costdocsits type checking:astro checkruns on the compiler's JS API, and@astrojs/language-serverthrows inassertCompatibleTypeScripton 7, so the script and@astrojs/checkare gone (docs/package.jsonhas the note).astro buildis the remaining gate there. Nothing may reintroducerequire('typescript')orresolve.sync('typescript')— 7 has no CJS entry — and the options it removed (target: ES5,esModuleInterop: false,baseUrl) can't come back into a tsconfig. Everything else in the workspace is on latest. react-unity-scripts builddoes not fail on type errors — fork-ts-checker is gone, soTSC_COMPILE_ON_ERRORdoes nothing andpnpm typecheckis what catches them. packages/scripts/config/modules.js derives webpack'ssrcalias frombaseUrl, and accepts"paths": { "*": ["./*"] }as the same thing since TS 7 removedbaseUrl;unity/core/.react/devtoolsrelies on that for itssrc/…imports.pnpm-workspace.yaml's dependency-build allowlist isallowBuilds, not pnpm 10'sonlyBuiltDependencies. pnpm 11 still accepts the old key —pnpm config listechoes it back — but no longer consults it, so every install script silently gets skipped. Combined with pnpm 11 defaultingstrictDepBuildsto true, that turns a skipped build intoERR_PNPM_IGNORED_BUILDSand fails the install. Packages are listed explicitly astrueorfalse; omitting one leaves it "undecided", which is whatstrictDepBuildserrors on. Only four aretrue— the ones whose native or downloaded binaries never materialise otherwise. Anything whose install script just prints a funding banner goes in asfalse.
Conventions
Biome 2 (biome.jsonc) is the only JS/TS formatter and linter: single quotes, 2-space indent, width 140, LF, reactClassic JSX runtime for the formatter. Two settings there exist because of what this repo is, and both carry comments: a11y is off (every JSX file Biome sees renders Unity components, not DOM — <button> has no type, <image> has no alt), and the CSS noUnknown* rules are off (ReactUnity's CSS dialect is not the web's). docs/ is excluded and uses its own Prettier setup — it formats .astro files, which Biome cannot. C# formatting comes from the root .editorconfig.
Line endings are LF everywhere, enforced at two levels: .gitattributes normalises on commit (* text=auto eol=lf), so no editor can put a CRLF into the repo whatever Visual Studio or Unity write on disk, and .editorconfig asks editors for LF so the churn never starts. Both carry comments explaining the state they replaced — before them the repo was 1921 LF against 1015 CRLF, split within every extension. *.bat/*.cmd are the one declared CRLF exception, and *.asset is deliberately left to git's content sniffing (one is binary lighting data among 136 YAML ones).
There is one .editorconfig, at the root. There were fourteen — one per merged repo, each declaring root = true, so each subtree was governed by its own copy and eleven of them were byte-identical; the only thing the split achieved was disagreeing about line endings. Repo-wide editor rules go in the root file. The exception is packages/create/scaffold/react/.editorconfig, which is shipped to scaffolded user projects (its .npmignore un-ignores it deliberately) rather than configuring this repo.
Keep comments short. About one line inside a function body, about three for public API documentation. Comment the non-obvious decision — what was tried and why it was rejected — but compress it to a sentence and state the conclusion rather than narrating how you got there. Longer reasoning belongs in the commit message or the .tegami changelog entry, not the source.