mini-react-repl
v0.23.1
Published
Browser-only React TSX REPL — multi-file editor, live preview, real Fast Refresh, no backend.
Maintainers
Readme
mini-react-repl
A multi-file React + TSX REPL that runs entirely in the browser. Edit, see the result live, ship the whole thing as static files
npm i mini-react-repl monaco-editor react react-domimport { useState } from 'react';
import { Repl } from 'mini-react-repl';
import { defaultVendor } from 'mini-react-repl/vendor-default';
import { MonacoReplEditor } from 'mini-react-repl/editor-monaco';
import 'mini-react-repl/theme.css';
export default function Playground() {
const [files, setFiles] = useState({
'App.tsx': 'export default () => <h1>hi</h1>',
});
return (
<Repl editor={MonacoReplEditor} vendor={defaultVendor} files={files} onFilesChange={setFiles} />
);
}That's the whole thing. Editor + sandboxed live preview, multi-file, real React Fast Refresh, no backend, no SSR, no server-side bundling.
What you get
- multi-file TSX/TS/CSS, with imports across files
- bare specifier imports (
import dayjs from 'dayjs') for a curated vendor set, swappable for your own - React Fast Refresh — component state survives edits
- inline source maps so stack traces point at your
.tsx, not transpiled JS - Monaco gets pre-configured (automatic JSX, ES2022, bundler resolution) and
pre-baked
.d.tsfor the curated vendor set — real squiggles + hover forreact,react-dom,dayjsout of the box - strictly controlled state. you own the file table. persistence, sharing, undo, multi-tab sync — all yours to wire up however
What it doesn't do, and won't pretend to
- arbitrary npm at runtime, by default. the vendor set is fixed at build
time and there's a builder if you want a different set. an opt-in
cdnprop lazy-loads anything outside that set from esm.sh on demand — off unless you pass it, so the default stays "no surprise network calls". see Lazy npm via esm.sh. - type errors as a build gate. swc strips types, the iframe runs. Monaco
shows red squiggles (user files + vendor packages, via the pre-baked
.d.ts), but a type error never blocks the run. like Vite dev: diagnostics are advisory. - folders. flat file list.
./Counter, not./components/Counter. - CJS. ESM-only. modern browsers only — Chrome 109+, FF 108+, Safari 16.4+.
- persistence, sharing, templates, console capture. open DevTools for the
console. write to IDB if you want to persist. the API gives you
filesandonFilesChange, the rest is on you. this is a feature.
If those are dealbreakers look at Sandpack (unmaintained) or StackBlitz WebContainers (monthly subscription) instead — they make different trade-offs and they're great at them
API
import {
Repl, // convenience: tabs + editor + preview
ReplProvider, // headless: just the engine + context
ReplFileTabs, // headless: tabs UI
ReplPreview, // headless: the iframe + error overlay
ReplErrorOverlay, // standalone overlay component
useRepl, // hook: files + crud actions
} from 'mini-react-repl';<Repl/> props
| prop | type | required | default | |
| ------------------------- | ------------------------------------------------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------- |
| files | Record<string, string> | yes | — | flat path → source map |
| onFilesChange | (next) => void | yes | — | called on every set/remove/rename |
| vendor | VendorBundle \| Promise<{ default }> | yes | — | { importMap, types? }; promise/thunk forms code-split it |
| editor | React.FC<ReplEditorProps> | yes | — | adapter component |
| entry | string | no | 'App.tsx' | the logical entry path |
| transformDebounceMs | number | no | 150 | |
| sandbox | string | no | 'allow-scripts allow-forms' | iframe sandbox tokens; pass extras to extend |
| unsafeDropSandbox | true | no | — | drop the sandbox attribute entirely (only for fully trusted code) |
| virtualModules | Record<string, string> | no | — | inline modules user code can import; see Virtual modules |
| baseHref | string \| null | no | window.location.origin | <base href> for root-relative URLs in user code; null omits it |
| headHtml | string | no | '' | injected into iframe <head> |
| bodyHtml | string | no | '' | injected into iframe <body> |
| showPreviewErrorOverlay | boolean | no | true | toggle built-in overlay |
| onPreviewError | (err: ReplError) => void | no | — | transform + runtime errors |
| onMounted | () => void | no | — | fires when the iframe runtime mounts the entry module |
| iframeRef | Ref<HTMLIFrameElement> | no | — | forwarded to the underlying <iframe>; postMessage host data in |
| onAddFile | () => MaybePromise<string \| null \| undefined> | no | — | custom add-file dialog; return the new path, or nullish to cancel |
| onDeleteFile | (path) => MaybePromise<boolean \| void> | no | — | confirm/cancel deletion; return false to cancel |
| swcWasmUrl | string | no | jsdelivr CDN | self-host this for offline / CI |
| loader | ReplLoader | no | — | per-file pre-processor; see Custom file types |
| cdn | ReplCdnResolver | no | — | lazy-load bare specifiers off a CDN; see Lazy npm via esm.sh |
useRepl()
const { files, setFile, removeFile, renameFile, activePath, setActivePath, reloadPreview } =
useRepl();That's it. No errors, no forceRefresh, no getPreviewIframe. By design —
errors come through onPreviewError. reloadPreview() is the recovery hatch
when user code crashes past what Fast Refresh can handle (empty entry file,
top-level throw): it remounts the iframe and re-runs every transform.
Headless layout
<ReplProvider files={files} onFilesChange={setFiles} vendor={defaultVendor}>
<Sidebar>
<ReplFileTabs />
</Sidebar>
<Main>
<Top>
<MyEditor />
</Top>
<Bottom>
<ReplPreview
headHtml={`<script src="https://cdn.tailwindcss.com"></script>`}
onPreviewError={(e) => toast(e.message)}
/>
</Bottom>
</Main>
</ReplProvider>ReplProvider is just context + the engine. lay it out however.
Vendor
The vendor bundle is what lets import dayjs from 'dayjs' work. it's
a curated, prebuilt set of ESM modules + an import map.
Default
import { defaultVendor } from 'mini-react-repl/vendor-default'
<Repl vendor={defaultVendor} ... />includes: react@19, react-dom@19, react/jsx-runtime,
react/jsx-dev-runtime, dayjs@1. inlined as
base64 data URLs so it works under srcdoc with zero hosting setup. ~200 kB
gzipped JS (most of it the development build of react-dom, which Fast
Refresh needs), plus pre-baked .d.ts (vendor.types) so Monaco shows real
red squiggles + hover signatures for the same packages.
date-fns and lodash-es were dropped from the default set in 0.23.0 — modern
browsers cover most of what they did, and the cdn prop lazy-loads them (or
anything else) from esm.sh when you actually need them. see
Lazy npm via esm.sh.
the import map and the .d.ts payload are both code-split. the import-map
chunk loads when <Repl/> mounts; the types chunk loads when an editor
mounts. preview-only or non-Monaco consumers never download types; routes
that never mount <Repl/> ship neither. need to warm a chunk earlier
(prefetch on hover, idle, etc.)? call defaultVendor.importMap() or
defaultVendor.types() directly — both return the same promise the
library awaits on mount, so the chunks land in cache before they're needed.
if your demo needs literally only those libs, stop reading.
Custom
You're going to outgrow the default. when that happens, write a
vendor.entry.ts that declares the bundle shape via standard ESM
imports/exports:
// src/sandbox/vendor.entry.ts
// Re-exports the iframe-runtime required core (react, react-dom,
// react-dom/client, react/jsx-runtime, react/jsx-dev-runtime,
// react-refresh/runtime). Skip this and the build errors loudly.
export * from 'mini-react-repl/vendor-base';
import * as zod from 'zod';
import * as framer from 'framer-motion';
import * as lodash from 'lodash-es'; // alias source: iframe imports 'lodash'
export { zod, framer as 'framer-motion', lodash };then build it:
# repl-vendor-build needs esbuild; it's an optional peer dep, install once:
npm i -D esbuild
npx repl-vendor-build src/sandbox/vendor.entry.ts
# → src/sandbox/vendor.generated/index.ts (exports customVendor)
# → src/sandbox/vendor.generated/import-map.json ({ imports: { ... } })
# → src/sandbox/vendor.generated/types.json (.d.ts payload)The output folder name (<basename-without-.entry.ext>.generated) is
matched by most default ignore globs (Prettier, oxlint, Knip) and is meant
to be gitignored as a unit. Pass --out <dir> to override.
Drop it into <Repl>:
import { customVendor } from './sandbox/vendor.generated';
<Repl vendor={customVendor} ... />Both the import-map and types are code-split. Routes that never mount
<Repl/> ship neither; preview-only consumers never download types.
SSR-safe. The generated index.ts has a loading-sequence diagram in
its header comment if you want the details.
The CLI is the only supported entry point — there is no public Node API.
Wire it into your build via an npm script ("build:vendor":
"repl-vendor-build src/vendor.entry.ts") or a pre-build hook.
Mix
To put back libraries that aren't in the default set (e.g. date-fns and
lodash-es, dropped in 0.23.0) alongside your own additions, declare them
all in one vendor.entry.ts:
// src/sandbox/vendor.entry.ts
export * from 'mini-react-repl/vendor-base';
import * as dateFns from 'date-fns';
import * as dayjs from 'dayjs';
import * as lodash from 'lodash-es';
import * as zod from 'zod'; // your additions
export { dateFns as 'date-fns', dayjs, lodash as 'lodash-es', zod };One import map, one types chunk, no runtime composition. Runtime composition
isn't supported anyway — defaultVendor.importMap / customVendor.importMap
are lazy thunks now, not plain objects.
Virtual modules
For ad-hoc helpers you don't want to ship as a vendor chunk — small utilities, theming primitives, mock APIs — pass them inline:
const VIRTUAL_MODULES = {
'@app/util': `export const greet = (name: string) => 'hello ' + name`,
} as const;
<Repl files={files} virtualModules={VIRTUAL_MODULES} ... />User code in the REPL can import { greet } from '@app/util' — the iframe
runtime executes it; Monaco autocompletes against the source. No bundling,
no hosting, no import-map entry. Virtuals can import each other and any
vendor package (react, dayjs, …) — the iframe's existing dep
substitution and the import map handle both.
Boot-time only. Snapshotted on first mount, identical to vendor.
Hoist to a top-level as const so the reference stays stable. Collisions
with vendor.importMap.imports keys resolve in favor of the virtual.
CSS aliases are not yet supported.
See examples/virtual-modules/ for a working setup with cross-virtual imports.
Lazy npm via esm.sh
The vendor set is curated and fixed at build time. To reach for a package
that isn't in it without rebuilding, pass the opt-in cdn prop — bare
specifiers the vendor import map doesn't cover are lazy-loaded from a CDN on
demand, at the moment user code imports them:
import { createEsmShCdnHandler } from 'mini-react-repl/cdn-esmsh';
// Build it ONCE at module scope — a stable reference. Pin versions for
// anything you ship publicly so the lazy import is reproducible.
const cdn = createEsmShCdnHandler({ versions: { 'canvas-confetti': '1.9.3' } });
<Repl files={files} vendor={defaultVendor} cdn={cdn} ... />// …then user code can just import it:
import confetti from 'canvas-confetti'; // resolved from esm.sh on demandIt's layered behind the vendor map, never instead of it: vendor
specifiers always win, so the React singleton, offline support, and Monaco
types stay intact for the curated set. esm.sh resolves the long tail. Each
emitted URL carries ?external=<vendor keys> so a lazy package reuses the
vendor's React (and every other vendor singleton) instead of bundling its
own — without that, two Reacts means "Invalid hook call".
createEsmShCdnHandler takes versions (pin for reproducibility), allow
(an allowlist gate), origin (a self-hosted mirror), and query (extra
esm.sh flags, e.g. { bundle: true } to flatten a deep dep tree into one
request). cdn is any (specifier, sharedDependencies, fromPath) => string |
null though, so jsDelivr +esm or your own resolver work too. Lazy modules
have no .d.ts, so they show as Monaco squiggles even though they run fine.
Caveats: it makes user code call a third-party CDN — leaving cdn
unset keeps the static-deploy, no-surprise-calls default. If you set a CSP,
allow script-src https://esm.sh (module fetches, static and dynamic, are
governed by script-src). Boot-time only, like vendor.
See examples/cdn-esmsh/ for a working setup (and the React-singleton proof).
Editor
You bring your own. the library doesn't bundle one by default. there's an adapter contract:
type ReplEditorProps = {
path: string;
value: string;
onChange: (next: string) => void;
language: 'typescript' | 'javascript' | 'css';
types?: TypeBundle; // forwarded from vendor.types; ignore if you don't care
};write a component matching this shape, pass it as editor={...}. that's the
whole interface.
The Monaco one
Most people are going to want Monaco. shipped under a separate import path so its weight is opt-in:
import { MonacoReplEditor } from 'mini-react-repl/editor-monaco';monaco-editor is an optional peer dep — only the people who import this
path install it. you'll still need a Monaco worker setup for your bundler
(vite-plugin-monaco-editor
or monaco-editor-webpack-plugin),
which Monaco needs whether or not you're using this library.
type-checking config
MonacoReplEditor configures Monaco's TS service on mount with compiler
options matching the runtime transform: automatic JSX, ES2022, bundler
resolution, strict, etc. Without this Monaco's defaults reject every .tsx
file with TS17004 (--jsx not provided) and every bare specifier with
TS2792 (module not found). If you want to override:
<MonacoReplEditor compilerOptions={{ strict: false }} />Same for diagnosticsOptions. Both are passthroughs to
monaco.languages.typescript.typescriptDefaults.
If vendor.types is set (the default vendor pre-bakes it; the builder
emits types.json alongside import-map.json in the generated folder),
MonacoReplEditor registers each .d.ts via addExtraLib so users get
real diagnostics + hover signatures for vendor packages. vendor.types
also accepts a Promise<TypeBundle> or a thunk so the types chunk loads
in parallel to the rest of the app — and can be skipped entirely on
routes that never mount an editor.
Monaco's theme defaults to 'auto', which wires up a tiny watcher that
follows the host page's prefers-color-scheme. Pass any registered
theme name ('vs-dark', your own, …) to pin it and skip the watcher.
CodeMirror? plain textarea?
Sure. write the adapter:
const TextAreaEditor: React.FC<ReplEditorProps> = ({ value, onChange }) => (
<textarea value={value} onChange={(e) => onChange(e.target.value)} />
);doesn't get more "bring your own" than that.
Custom file types
Every file flows through a loader. The default handles .css (injected as
<style>) and .tsx / .ts / .jsx / .js (swc-compiled). Anything else
is ignored.
Pass loader to claim more extensions. The function runs once per file (on
first load and on every change) and gets the file's source plus a
transform function — the same swc-wasm pipeline the default loader uses —
so you can keep delegating TS/JSX compilation. Return a ReplLoaderResult
to claim the file, or null / undefined to fall through:
import { defaultLoader, type ReplLoader } from 'mini-react-repl/loader';
const loader: ReplLoader = async (input) => {
if (input.path.endsWith('.sqlite')) {
// emit plain JS — no swc needed
return {
kind: 'module',
code: `export default ${JSON.stringify(parseSqlite(input.source))};`,
};
}
if (input.path.endsWith('.md')) {
// generate TSX, then run it through the same swc pass `.tsx` files use
const tsxSource = mdxToTsx(input.source);
return { kind: 'module', code: await input.transform(tsxSource, { tsx: true }) };
}
return defaultLoader(input);
};
<Repl
files={files}
onFilesChange={setFiles}
vendor={defaultVendor}
editor={MonacoReplEditor}
loader={loader}
/>;ReplLoaderResult is a discriminated union:
| variant | meaning |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| { kind: 'css', source: string } | inject source as a <style> tag |
| { kind: 'module', code: string } | code is already-compiled JS; the engine runs rewriteImports on it so relative specifiers resolve to other files |
| null / undefined | skip the file |
A user file can import data from './data.sqlite' once the loader claims it
— relative imports resolve against the files map by literal name first, so
non-standard extensions just work as long as you write them out.
The loader prop is boot-time only (like vendor / entry); to swap, remount
the provider with a different key.
Styling
unstyled by default. components emit stable class names + data attributes:
<div class="repl-tabs">
<button class="repl-tab" data-active="true">App.tsx</button>
</div>
<div class="repl-preview"><iframe class="repl-iframe"></iframe></div>
<div class="repl-error-overlay">…</div>three options:
import 'mini-react-repl/theme.css'; // sane defaults, light + dark…or write your own CSS targeting .repl-* and [data-active].
…or wrap each component, every one accepts className and style.
no Tailwind dep, no CSS-in-JS, no global pollution beyond the class names.
Iframe extras
The preview is a srcdoc. you can inject into it:
<ReplPreview
headHtml={`
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
<script>window.MY_API_KEY = 'demo-key'</script>
`}
bodyHtml={`<div id="my-extra-portal"></div>`}
/>headHtml runs before the import map and runtime, so don't try to use
the registry from there. bodyHtml is appended after #root, run anything
that should run after the React root mounts.
The preview document is loaded from a sandboxed blob: URL, so root-relative
URLs in user code (e.g. <img src="/img/yak-jumping.png">) would resolve
against the opaque blob: origin — which has no server behind it — and fail.
A <base href> is emitted as the first element in <head> to re-point those
URLs at a real origin. It defaults to the embedder's window.location.origin;
override it for a different asset host, or pass baseHref={null} to drop the
tag entirely.
<ReplPreview baseHref="https://cdn.example.com/" />if you want Tailwind in your previews this is where it goes. the library doesn't bundle it.
Element inspection
Optional subpath that ships an in-iframe element picker. Click an element in the live preview, get back the JSX call site that produced it with file, line, column, component name making use of the source-maps automatically
import { Repl } from 'mini-react-repl';
import { InspectMode } from 'mini-react-repl/inspect';
const [picking, setPicking] = useState(false);
<>
<button onClick={() => setPicking(true)}>Inspect</button>
<Repl files={files} onFilesChange={setFiles} vendor={vendor} editor={MonacoReplEditor}>
<InspectMode
active={picking}
onElementPicked={(pick) => {
setPicking(false);
// pick.dom.tag → 'h1'
// pick.dom.text → 'Today is Monday'
// pick.stack[0] → { fileName: 'App.tsx', lineNumber: 7, columnNumber: 7, componentName: 'App' }
}}
onCancel={() => setPicking(false)}
/>
</Repl>
</>;<InspectMode/> must live inside the surrounding <ReplProvider/> (or
<Repl>, which renders one) — it discovers the live iframe through provider
context, no ref plumbing required.
The picker is lazy-injected into the iframe on first activation —
consumers who never import mini-react-repl/inspect don't pay any byte for
the inspection feature
Pick shape
type ElementPick = {
dom: { tag: string; text: string | null; boundingRect: DOMRectReadOnly };
stack: StackFrame[];
};
type StackFrame = {
fileName: string;
lineNumber: number;
columnNumber: number;
componentName: string | null;
};The stack is ordered top-down: index 0 is the JSX call closest to the clicked
DOM node; later frames walk up the React _debugOwner chain. Every position
is in source space, ready to hand straight to an "open in editor" link
Errors
<Repl
showPreviewErrorOverlay={true} // default
onPreviewError={(err) => {
if (err.kind === 'runtime') sentry.captureException(err);
setLastError(err);
}}
/>type ReplError =
| { kind: 'transform'; path: string; message: string; loc?: { line: number; column: number } }
| { kind: 'runtime'; message: string; stack: string }
| { kind: 'resolve'; path: string; specifier: string };both transform and runtime errors flow through the same callback. line/col is
mapped to original .tsx via inline source maps.
when transform fails, the previous render stays mounted. you don't lose
your DOM because you forgot a }. this is intentional and the thing that
makes it feel like Vite dev rather than a "syntax error → blank page" REPL.
set showPreviewErrorOverlay={false} if you want to render the error
yourself.
How it actually works
The interesting part, and the part that took the longest to get right.
- you change a file.
<ReplProvider/>debounces 150ms. - the changed file is shipped to a Web Worker running
swc-wasm. swc strips types, transforms JSX (automatic runtime), and injects React Refresh signatures. - main thread takes the JS back, runs an import-rewrite pass:
- bare specifiers (
'react','dayjs') are left alone — the iframe has a native<script type="importmap">that resolves them - relative specifiers (
'./Counter') get rewritten to the current blob URL of that logical path
- bare specifiers (
- wrapped code becomes a
Blob, becomes aURL.createObjectURL, getspostMessaged to the iframe. - the iframe imports the blob URL. on top-level execution, the module
commit()s itself into a global registry keyed by logical path, not blob URL. React Refresh sees stable IDs, patches components in place, state survives.
The iframe itself is a srcdoc — generated once, never recomputed on file edits. blobs come and go through postMessage. errors come back the same way.
That's the whole pipeline. ~30KB of glue around swc-wasm and react-refresh.
Caveats
things that will bite you. read this part.
- bundler-native worker imports. the library does
new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). works in Vite, Rollup 4+, Webpack 5+, esbuild bundle, Parcel 2. doesn't work in pure-CDN no-bundler setups. there's a/standaloneentry planned for v2 with the inline-Blob-worker trick. - srcdoc origin. stack traces show
about:srcdoc:42for the iframe HTML itself. blob: URLs for transformed user code. inline source maps map back to original.tsx. DevTools "Sources" works. but if you grep frames for a pretty path you'll see srcdoc. - swc-wasm fetches at runtime. by default from jsdelivr. self-host it for
offline / CI / strict CSP:
<Repl swcWasmUrl="/swc.wasm" /> cdn(esm.sh) is off by default. the engine itself makes no third-party calls; only acdn-resolved lazy import reaches out. with a CSP, allowscript-src https://esm.sh. see Lazy npm via esm.sh.- Monaco workers are not our problem. if Monaco is your editor, you have to configure its workers in your bundler. there's no way around this and every Monaco-based library has the same constraint.
- CSS files. alphabetical concat across files. one
<style>per file.@importdoesn't resolve (browsers will try and fail). - rename/delete breaks importers. no auto-fix. importing files will fail
to transform, the overlay shows
Module not found, last-good render stays. fix the import yourself. (consider this a feature: predictable, no magic.) - default sandbox is
allow-scripts allow-forms. the iframe runs with a unique opaque origin, so user code can't reachwindow.parent,localStorage, or cookies of your app.unsafeDropSandboxdrops the attribute entirely — only do that for fully trusted code. for hostile inputs, additionally serve the host page from a separate origin so the sandbox can't be undone by a host-side XSS. - strictly controlled state means re-renders are yours to manage. if you
do
onFilesChange={files => setHeavyState(files)}andsetHeavyStateis expensive, that's on you. the library debounces transformation (150ms) but not yoursetState.
Compared to
| | this | Sandpack | StackBlitz WebContainers |
| ------------------ | ------------------------------------------------------- | --------------------------------- | --------------------------------------------------- |
| how it transforms | swc-wasm in a worker, browser only | bundler in a worker, browser only | full Node in WASM, real npm |
| arbitrary npm | curated set; opt-in esm.sh for the long tail (cdn) | yes, via esm.sh | yes, real npm install |
| static deploy | yes, no backend at all | yes | no, needs CSP/COOP/COEP headers from special origin |
| backend, ssr, etc. | no | no | yes, runs Node |
| bundle size | small (engine ~30KB + swc 3MB wasm + your vendor) | medium | enormous, but you get a whole VM |
| works offline | yes (with self-hosted swc.wasm + inline default vendor) | partly | no |
if you need real npm, real Node, you want WebContainers. if you need arbitrary client-side npm with a CDN runtime, Sandpack. if you want a fast, boring, static-deployable React playground with a known set of libs, this.
FAQ
how do I add a library?
write a vendor.ts (re-export mini-react-repl/vendor-base plus your own
deps), run npx repl-vendor-build vendor.ts, pass the result.
see Vendor.
can I use this for tutorials / blog post embeds? yes — that's the main use case. srcdoc preview means it works inside an iframe-in-an-iframe just fine. ship the demo as a static page, embed anywhere.
why not just use Vite? Vite needs a dev server. this doesn't. drop the build output on a static host and you're done.
why no Service Worker for module resolution? considered it. SW gives stable URLs (good for Refresh) but adds lifecycle complexity, scope rules, registration timing. blob URLs + a logical-path registry get the same Refresh behavior with no SW required. trade-off favored simplicity.
HMR for non-component edits? React Refresh handles it. utility/hook edits invalidate up to the nearest component boundary, which gets re-rendered. cascade is usually 1–2 modules deep. you don't have to think about it.
can I get TypeScript red squiggles for the vendor libs?
yes — the default vendor pre-bakes .d.ts for react, react-dom,
dayjs, and MonacoReplEditor registers them with
Monaco's TS service via addExtraLib. for custom vendors, repl-vendor-build
emits a types.json next to import-map.json in the generated folder. swc
still strips types at runtime — diagnostics are editor-side only and don't
gate the transform.
does it work in Storybook / Docusaurus / Notion-like embeds? yes — srcdoc preview means it doesn't care what frame it's rendered in.
why ESM-only? no CJS? it's 2026. our minimum browser is Safari 16.4. node 20 understands ESM. CJS is a tax we don't want to pay.
Dev
git clone https://github.com/jantimon/mini-react-repl
cd mini-react-repl
pnpm install
pnpm dev # runs examples/e2e-fixture (the Playwright target)
pnpm test # vitest
pnpm test:e2e # playwright (chromium only for now)
pnpm build # tsup, library onlyE2E tests run against examples/e2e-fixture on chromium only for v1. firefox + webkit
are deferred until the chromium suite is stable. see SPEC.md §17.
PRs welcome. small ones land fast. for anything architectural, open an issue first — there's a decision log in SPEC.md §20 covering the tradeoffs already made, please skim before proposing reverts.
License
MIT
