Reference index · a build tool, and a browser entry for the playground
Compiles TSX into template clones and DOM parts. Guide: Building and deploying; design: ADR-0009.
The Babel plugin with nothing around it, for compiling TSX where @babel/core
cannot go — a browser tab:
import * as Babel from '@babel/standalone';
import firsthand from '@firsthandjs/compiler/plugin';
Babel.registerPlugin('firsthand', firsthand);
const { code } = Babel.transform(source, {
filename: 'playground.tsx',
presets: [['typescript', { isTSX: true, allExtensions: true }]],
plugins: [['firsthand', { packageName: 'playground' }]],
});
This is what the playground
runs: the same plugin the build uses, in the page, on every keystroke. The main
entry pulls in @babel/core and is for Node; this one imports @babel/types
and nothing else, and no module in it reaches for a Node built-in — which a
test asserts, because it is the kind of thing one import would quietly undo.
import { firsthand } from '@firsthandjs/compiler/vite';
export default defineConfig({
plugins: [firsthand({ packageName: 'my-app' })],
});
function firsthand(options?: FirsthandPluginOptions): VitePluginLike;
interface FirsthandViteOptions {
/** Used when hashing stable component ids. */
packageName?: string;
/** Refuse to compile a value read once in a setup and then kept. Default: true. */
strictReactivity?: boolean;
/** Files to compile. Everything with a JSX extension, by default. */
include?: readonly RegExp[];
/** Files to leave alone — a folder of another framework's components, say. */
exclude?: readonly RegExp[];
}
It handles .tsx and .jsx, runs before the bundler’s own TypeScript pass,
and needs no esbuild.jsx configuration — configuring jsx: 'preserve'
changes nothing and is a type error on Vite 8.
include and exclude are regular expressions rather than globs, so this
package keeps having no dependencies and what matches is something you can
read. They are how a project says these files are not mine:
firsthand({ packageName: 'my-app', exclude: [/\/legacy\//] });
That matters where another framework is in the same repository. What this
compiler compiles, it claims: a function it translated markup into is marked
as a view, and createComponent reads that mark before it reaches for an
adapter. A file it never saw is never claimed, so a React component in an
excluded folder goes to @firsthandjs/react exactly as one from node_modules
does.
packageName should be set and should be stable: component ids are hashed from
it plus the module path, which is what makes an id survive a rebuild
(ADR-0004). Two packages that both used
the default app and shipped in one bundle would collide.
On by default. firsthand({ packageName: 'my-app', strictReactivity: false })
turns it off.
Refuses to compile a declaration whose value is read once in a component setup and then kept — the mistake the single-run setup invites (ADR-0019):
const Widget = component((props) => {
const count = props.items.length; // build error under strictReactivity
return <p>{count} items</p>;
});
The error names the declaration, says why the value will not change again, and
points at the two ways out: move the read into the part, handler, effect or
computed that should re-read it, or wrap it in
snapshot if reading once is what you meant.
It is deliberately narrow, because a false positive stops a build. Only a
declaration whose initialiser is nothing but a read is reported — identifiers,
member accesses, literals and the operators between them. Anything containing a
call is left alone, which covers signal(props.initial), peek(), computed,
every handler, and snapshot() itself. A read inside a nested function is left
alone too: those bodies run again.
What it therefore cannot see is a read that leaves the module — doSomething(props)
with the read in another file. That is what the runtime half is for:
setStrictReactivity(true) reports the same
mistake during development, including the cases no compiler can follow.
strictReactivity also covers the three things a view cannot do, which are
errors for the same reason: nothing throws, and the screen is simply wrong.
return open.value ? <A /> : <B /> in a setup. The
error offers both ways out — the choice in the markup, or a render function.signal, computed, effect,
useResource and their kind belong in the setup, which runs once.key. Where markup stands identifies one of
it; only a key identifies many
(ADR-0026).The runtime half stays opt-in, and this one does not. The difference is
precision. The compiler sees the shape of a declaration and only reports the
one that is almost always wrong; the runtime sees a read with nothing
subscribing, which signal(props.initial) also is. A check that is usually
right can be on by default. One that is often wrong would only teach people to
ignore it.
Neither costs anything in production: this one runs at build time, and the other lives in a module the production build replaces with empty functions.
firsthand({ packageName: 'my-app', devtools: true });
Labels each cell with the variable that holds it and the line it was written on, for devtools. The Vite plugin turns this on while serving and off while building, so a production build emits nothing; the option overrides that either way.
const count = signal(0);
// becomes, in development only:
const count = _$label(signal(0), 'signal', 'count (main.tsx:9)');
It exists because neither half is available at runtime. A runtime cannot see
that the variable is called count, and new Error().stack reports a position
in the compiled module — browsers do not apply source maps to error.stack,
so the line it names is not the line that was written. The compiler knows both.
Only signal, computed and deepSignal assigned to a plain identifier are
labelled. Anything else is left alone and simply unnamed.
The Vite plugin returns one, so a debugger shows the JSX rather than the
hoisted templates and protocol calls it compiles to. compileModule is the
entry point that produces it:
import { compileModule } from '@firsthandjs/compiler';
const { code, map } = compileModule(source, { filename, sourceMaps: true });
It returns a Compiled: code, and map, which is a SourceMap — the shape
a bundler expects, so the plugin’s transform result is assignable to Rollup’s
SourceMapInput without a cast. map is null when no map was asked for.
transform remains the string-returning form, and asks for no map.
import firsthandPlugin from '@firsthandjs/compiler';
// babel.config.js
export default { plugins: [['@firsthandjs/compiler', { packageName: 'my-app' }]] };
firsthandPlugin is the default export, so any Babel-based toolchain — Rollup,
webpack, Jest, esbuild via a Babel step — can use it directly. It requires the
JSX syntax plugin (and, for TSX, the TypeScript syntax plugin) ahead of it.
function transform(code: string, options?: TransformOptions): string;
interface TransformOptions extends FirsthandPluginOptions {
filename?: string;
/** Parse TypeScript syntax. */
typescript?: boolean;
}
Compiles one module and returns the code. This is what the Vite plugin and the compiler’s own tests call, and it is the way to see what your TSX became.
Type annotations are left in the output: stripping them is the bundler’s job, and doing it here would duplicate the work and add a source-map hop.
function stableId(packageName: string, filename: string): string;
The id hash, exported so a tool can reproduce a component id without running the compiler.
Static markup is hoisted into one <template> per JSX tree and cloned;
dynamic expressions become thunks passed to the specialised parts in
@firsthandjs/dom/internal; nodes are reached by
child index rather than by query. Props destructuring in a component’s
parameter list is rewritten into live reads, so ({ count }) => … stays
reactive, and a default is re-applied per read for undefined alone —
({ count = 0 }) emits props.count === undefined ? 0 : props.count, so a
parent passing null means null.
The emitted code is a contract with the runtime, versioned by
PROTOCOL_VERSION: the compiler and @firsthandjs/dom are upgraded together.