Skip to content

t-k/mreact

Repository files navigation

mreact

React-flavored framework inspired by Marko's compiler-first philosophy.

The codebase was largely generated by an AI agent, it may be slop. Good luck.

Motivations

  • Fine-grained reactivity. Update only the DOM nodes whose dependencies changed instead of re-rendering whole component subtrees.
  • Modular runtime. Avoid shipping client runtime for routes that do not need it. A route with no client-side signals can stay server-rendered without a client route bundle.
  • Compile-time over runtime. When a transformation can be done once by the compiler, it should not be re-done on every render in the browser.
  • Chunk-based streaming SSR. HTML is written into a coalescing UTF-8 buffer and flushed at shell, threshold, and out-of-order fragment boundaries. The streaming path is designed to avoid buffering the full rendered document in common cases, and to let bytes leave the server as soon as they are ready, similar to Marko's streaming model.
  • Route-level client/runtime inference. The router/compiler uses file conventions such as .client.tsx and .compat.tsx, configured clientBoundaryImports, and supported app-local static relative imports to decide which route modules need client runtime. It records inferred client references in server transform metadata and transports that manifest to the hydration runtime. This is not general semantic proof over arbitrary code.
  • Dual environment optimization. Server and client get separately compiled, separately optimized programs from one source: HTML string/stream emission on the server, fine-grained DOM mutation on the client.

Status

Experimental. APIs may change.

Quick Start

Create an app-router project:

npx @reckona/create-mreact-app my-app --template app-router --src-dir
cd my-app
pnpm install
pnpm dev

Create the Tailwind CSS template instead:

npx @reckona/create-mreact-app my-app --template app-router-tailwind --src-dir

Add generic container deploy files for Cloud Run, AWS App Runner, and similar platforms:

npx @reckona/create-mreact-app my-app --template app-router --src-dir --deploy container

Generate an AWS Lambda entrypoint instead:

npx @reckona/create-mreact-app my-app --template app-router --src-dir --deploy aws-lambda

Generate a Cloudflare Workers-oriented template:

npx @reckona/create-mreact-app my-app --template cloudflare

Cloudflare builds emit .mreact/cloudflare/route-modules.mjs for dynamic and non-prerendered App Router pages, and the generated worker imports that registry directly. Use mreact-router build --target=cloudflare for Workers-only artifacts, or mreact-router build --target=node for Node, container, and AWS Lambda artifacts that should not bundle Cloudflare route modules. Generated Cloudflare route modules support stream = true pages with route-local <Await> boundaries and local server-component imports.

Build and run production output:

pnpm build
pnpm start

Enable compact request logs in either local development or built-output serving with mreact-router dev --log=requests, mreact-router start .mreact --log=requests, or MREACT_ROUTER_LOG=requests.

Server-rendered routes stay JavaScript-free by default. When a server-only route needs Link prefetch/navigation behavior without hydration, export navigationRuntime = true from that page:

import { Link } from "@reckona/mreact-router/link";

export const navigationRuntime = true;

export default function Page() {
  return <Link href="/docs" prefetch="viewport">Docs</Link>;
}

The generated Vite config is explicit about app paths:

// vite.config.ts
import { defineConfig } from "vite";
import { mreactRouter } from "@reckona/mreact-router/vite";

export default defineConfig({
  plugins: [
    mreactRouter({
      projectRoot: __dirname,
      routesDir: "src/app",
      publicDir: "public",
      allowedSourceDirs: ["src"],
    }),
  ],
});

A typical project layout:

my-app/
  src/
    app/
      layout.tsx
      page.tsx
    lib/
      app-info.ts
  public/
  vite.config.ts

App Router Examples

Routes live under app/ or src/app/. The router recognizes page.tsx, layout.tsx, template.tsx, loading.tsx, error.tsx, not-found.tsx, middleware.ts, and route.ts.

src/app/
  layout.tsx
  page.tsx
  counter/page.tsx
  users/$id/page.tsx
  files/$...path/page.tsx
  api/time/route.ts

Static Page and Metadata

// src/app/page.tsx
export const metadata = {
  title: "Home",
  description: "A server-rendered mreact page.",
};

export default function Page() {
  return (
    <main>
      <h1>Hello from mreact</h1>
      <p>This route has no client state, so it can render as static HTML.</p>
    </main>
  );
}

Layouts and Slots

Layouts wrap child routes with <Slot />. Named slots let a child route fill a specific region of a parent layout.

// src/app/docs/layout.tsx
export const metadata = {
  title: "Docs",
  description: "Documentation section.",
};

export default function DocsLayout() {
  return (
    <section class="docs-layout">
      <aside>
        <nav>
          <a href="/docs">Overview</a>
          <a href="/docs/routing">Routing</a>
        </nav>
        <Slot name="aside" />
      </aside>
      <article>
        <Slot />
      </article>
    </section>
  );
}
// src/app/docs/page.tsx
function TipAside() {
  return <p>Read the routing guide next.</p>;
}

export const slots = {
  aside: TipAside,
};

export default function Page() {
  return <h1>Docs overview</h1>;
}

For TypeScript apps that type-check route files directly, include the app-router global declarations so <Slot /> is available without a local import:

{
  "compilerOptions": {
    "types": ["@reckona/mreact-router/app-router-globals"]
  }
}

Server-side route code that runs through loaders, middleware, route handlers, metadata, or server actions should use relative imports for app-local modules. The production server bundler applies the import policy before Vite-only or tsconfig path alias plugins can rewrite aliases such as ~/*, so an alias like ~/lib/csrf is treated as a package name by the import policy. Prefer ../lib/csrf.js or another relative specifier in server-side modules.

Client Interactivity

cell() values are tracked by the compiled client output. A route using cell() and an event handler gets a client route bundle.

For component boundaries, use the file conventions intentionally: .client.tsx marks a component as a client boundary and .compat.tsx marks React-compatible component code. The router can infer some route-level client runtime needs from supported syntax and app-local static imports, but it is not a general semantic or TypeScript type-flow analyzer. When a server route imports a client component, render that imported binding as JSX so the analyzer can include it in the client reference manifest.

Automatic client boundary inference currently follows direct JSX, JSX member roots, simple component aliases, and app-local barrel re-exports:

import { Counter } from "./Counter.client";
import { widgets } from "./widgets.client";
import { SaveButton } from "./components";

const InlineCounter = Counter;

export default function Page() {
  return (
    <>
      <Counter />
      <widgets.Toggle />
      <InlineCounter />
      <SaveButton />
    </>
  );
}

The analyzer cannot prove dynamic registries, computed component selection, or non-JSX uses are safe client boundaries. In those cases the build emits MR_CLIENT_BOUNDARY_INFERENCE_UNSUPPORTED_REFERENCE; render the imported binding through one of the supported JSX shapes or configure clientBoundaryImports explicitly.

// src/app/counter/page.tsx
import { cell } from "@reckona/mreact-reactive-core";

export default function CounterPage() {
  const count = cell(0);

  return (
    <button type="button" onClick={() => count.set((value) => value + 1)}>
      Count: {count.get()}
    </button>
  );
}

Client Navigation

The app router intercepts same-origin anchors and updates the changed route payload without a full document reload. It keeps head metadata and route data synchronized, restores scroll for back/forward navigation, and prefetches client route scripts for likely navigations when the browser is not in reduced-data mode.

Use Link or linkProps() when a route needs explicit navigation behavior:

// src/app/page.tsx
import { Link } from "@reckona/mreact-router/link";

export default function Page() {
  return (
    <nav>
      <Link href="/docs" prefetch="viewport">
        Docs
      </Link>
      <Link href="/editor" scroll="preserve" transition="auto">
        Editor
      </Link>
      <Link href="/legacy" reload>
        Legacy page
      </Link>
    </nav>
  );
}

The client runtime also exposes getNavigationState() and subscribeNavigationState() from @reckona/mreact-router/navigation-state for devtools and advanced UI integrations that need to observe pending navigations. The root @reckona/mreact-router entrypoint still re-exports these helpers for compatibility, but the subpaths are preferred for client-only code because they give bundlers a narrower module boundary.

Dynamic Routes, Loaders, and 404s

Use $name for dynamic segments and $...name for catch-all segments. loader() runs before render and passes its return value as props.data.

// src/app/users/$id/page.tsx
import { notFound } from "@reckona/mreact-router";

interface LoaderContext {
  params: { id: string };
  request: Request;
}

const users = new Map([
  ["ada", { name: "Ada Lovelace", role: "admin" }],
  ["grace", { name: "Grace Hopper", role: "editor" }],
]);

export const prerender = true;

export async function generateStaticParams() {
  return [...users.keys()].map((id) => ({ id }));
}

export async function loader(context: LoaderContext) {
  const user = users.get(context.params.id);
  if (user === undefined) notFound();
  return user;
}

export default function UserPage(props: {
  params: { id: string };
  data: { name: string; role: string };
}) {
  return (
    <main>
      <h1>{props.data.name}</h1>
      <p>Role: {props.data.role}</p>
    </main>
  );
}
// src/app/files/$...path/page.tsx
export default function FilePage(props: { params: { path: string } }) {
  return <p>Requested file path: {props.params.path}</p>;
}

Catch-all params are decoded before they reach your route module. Re-encode each segment before embedding them into a URL.

function hrefForCatchAll(path: string): string {
  return `/files/${path.split("/").map(encodeURIComponent).join("/")}`;
}

A loader can also return a Response to short-circuit page rendering. This is useful for redirects that need headers such as Set-Cookie:

// src/app/login/page.tsx
export function loader() {
  return new Response(null, {
    status: 303,
    headers: {
      location: "/",
      "set-cookie": "pending_oidc=1; Path=/; HttpOnly; SameSite=Lax",
    },
  });
}

export default function LoginPage() {
  return <main>Login</main>;
}

Page modules may export route convention values such as loader, metadata, revalidate, stream, prerender, generateStaticParams, and slots. Other lowercase named exports are treated as local helpers during page rendering, so they can be tested from source without being compiled as route components. Uppercase exported functions are still treated as renderable component helpers.

Route Handlers

route.ts files expose HTTP method functions.

// src/app/api/time/route.ts
export function GET(): Response {
  return Response.json({
    now: new Date().toISOString(),
  });
}

export async function POST(request: Request): Promise<Response> {
  const body = await request.json();
  return Response.json({ received: body });
}

Dynamic route handlers receive decoded file-system route params as their second argument:

// src/app/api/users/$id/route.ts
export function GET(
  _request: Request,
  context: { params: { id: string } },
): Response {
  return Response.json({ id: context.params.id });
}

Route handlers may return a Response or throw a Response. Throwing a Response is useful for guard helpers that need to stop execution immediately:

function requireCsrf(request: Request): void {
  if (request.headers.get("x-csrf") !== "expected") {
    throw new Response("CSRF verification failed", { status: 403 });
  }
}

export function POST(request: Request): Response {
  requireCsrf(request);
  return Response.json({ ok: true });
}

Middleware

Middleware can return a Response to short-circuit rendering, or return undefined to continue.

// src/app/middleware.ts
import { redirect } from "@reckona/mreact-router";

export const config = {
  matcher: ["/blocked", "/admin/:path*"],
};

export async function middleware(request: Request): Promise<Response | undefined> {
  const url = new URL(request.url);

  if (url.pathname === "/blocked") {
    return new Response("<h1>Blocked</h1>", {
      headers: { "content-type": "text/html; charset=utf-8" },
      status: 451,
    });
  }

  if (url.pathname.startsWith("/admin")) {
    const signedIn = request.headers.get("cookie")?.includes("sid=");
    if (!signedIn) redirect("/login");
  }

  return undefined;
}

Streaming, Loading, and Await

Streaming routes can flush the shell while async work continues. A collocated loading.tsx file supplies the loading boundary.

// src/app/streaming/page.tsx
export const stream = true;

async function readFeed(): Promise<string[]> {
  await new Promise((resolve) => setTimeout(resolve, 100));
  return ["Compiler output", "Streaming shell", "Out-of-order fragment"];
}

export default function Page() {
  const feed = readFeed();

  return (
    <main>
      <h1>Streaming</h1>
      <Await value={feed} placeholder={<p>Loading feed...</p>}>
        {(items) => (
          <ul>
            {items.map((item) => <li key={item}>{item}</li>)}
          </ul>
        )}
      </Await>
    </main>
  );
}
// src/app/streaming/loading.tsx
export default function Loading() {
  return <p>Preparing the stream...</p>;
}

Server Actions and Route Cache

Server actions currently require a top-level "use server" directive in the action module. The router only lowers imported functions from marked modules when it sees <form action={action}>; this keeps ordinary imported functions out of the server-action registry. Cached route HTML can be invalidated with revalidatePath().

Server action requests reject Content-Length values over 10 MiB by default before parsing FormData or JSON. Pass serverActions: { maxBodyBytes } to the dev server, production server, Vite plugin, or deployment adapter when an app needs a different limit.

// src/app/server-actions/page.tsx
import { addNote } from "./actions.js";
import { listNotes } from "./store.js";

export const revalidate = 30;

export default function Page() {
  return (
    <main>
      <form method="post" action={addNote}>
        <input name="text" required maxlength="200" />
        <button type="submit">Add note</button>
      </form>
      <ul>
        {listNotes().map((note) => (
          <li key={note.id}>{note.text}</li>
        ))}
      </ul>
    </main>
  );
}
// src/app/server-actions/actions.ts
"use server";

import { revalidatePath } from "@reckona/mreact-router";
import { addNoteToStore } from "./store.js";

export async function addNote(formData: FormData): Promise<void> {
  const raw = formData.get("text");
  const text = typeof raw === "string" ? raw.trim() : "";
  if (text.length > 200) {
    throw new Error("Note text must be at most 200 characters.");
  }
  if (text.length > 0) {
    addNoteToStore(text);
    revalidatePath("/server-actions");
  }
}

Use runtime cache control when the policy depends on request data:

// src/app/products/$id/page.tsx
import { cacheControl } from "@reckona/mreact-router";

export async function loader() {
  cacheControl({
    sMaxAge: 60,
    staleWhileRevalidate: 300,
  });

  return { generatedAt: new Date().toISOString() };
}

Query Prefetch and Hydration

@reckona/mreact-query provides a tiny query client. The router gives loaders a per-request QueryClient; after render it dehydrates the cache into HTML.

// src/app/query/page.tsx
import {
  createQuery,
  getQueryClient,
  type QueryClient,
} from "@reckona/mreact-query";

const TIME_KEY = ["time"] as const;

async function fetchTime() {
  return { value: new Date().toISOString() };
}

export async function loader(context: { queryClient: QueryClient }) {
  return context.queryClient.fetchQuery({
    queryKey: TIME_KEY,
    queryFn: fetchTime,
  });
}

export default function Page(props: { data: { value: string } }) {
  const query = createQuery(getQueryClient(), {
    queryKey: TIME_KEY,
    queryFn: fetchTime,
  });
  const result = query.result.get();

  return (
    <main>
      <p>Loader value: {props.data.value}</p>
      <p>Reactive value: {result.data?.value ?? "pending"}</p>
    </main>
  );
}

Forms

@reckona/mreact-forms keeps form state in reactive cells and can map server validation errors back to fields.

// src/app/contact/page.tsx
import { createForm } from "@reckona/mreact-forms";

interface ContactValues {
  email: string;
  message: string;
}

export default function Page() {
  const form = createForm<ContactValues>({
    initialValues: { email: "", message: "" },
    validateOn: ["change", "blur"],
    validate: {
      email: (value) => value.includes("@") ? undefined : "Enter a valid email.",
      message: (value) => value.length >= 10 ? undefined : "Write at least 10 characters.",
    },
  });

  async function submit() {
    const result = await form.submit(async (values) => {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(values),
      });

      if (!response.ok) {
        form.setServerErrors(await response.json());
        return;
      }

      form.reset();
    });

    if (result.status === "error") {
      form.setErrors({ root: ["Could not submit the form."] });
    }
  }

  const state = form.state.get();

  return (
    <form onSubmit={(event) => { event.preventDefault(); void submit(); }}>
      <input
        value={state.values.email}
        onInput={(event) =>
          void form.setValue("email", (event.target as HTMLInputElement).value)}
      />
      <textarea
        value={state.values.message}
        onInput={(event) =>
          void form.setValue("message", (event.target as HTMLTextAreaElement).value)}
      />
      <button type="submit" disabled={state.submitting}>Send</button>
    </form>
  );
}

Auth

@reckona/mreact-auth builds on router sessions and adds role/permission guards.

// src/app/session-store.ts
import {
  configureAuth,
  createMemorySessionStore,
} from "@reckona/mreact-auth";

export interface SessionData {
  userId: string;
  roles: string[];
}

export const sessions = createMemorySessionStore<SessionData>();

configureAuth({
  redirectTo: "/login",
  forbiddenTo: "/forbidden",
});
// src/app/admin/audit/page.tsx
import { requireRole } from "@reckona/mreact-auth";
import { sessions, type SessionData } from "../../session-store.js";

export async function loader(context: { request: Request }) {
  const session = await requireRole<SessionData>(
    context.request,
    sessions,
    "admin",
  );

  return { userId: session.data.userId };
}

export default function Page(props: { data: { userId: string } }) {
  return <h1>Audit log for {props.data.userId}</h1>;
}

i18n Helpers

defineMessages() keeps message bundles typed. detectLocale() can read from the URL prefix or Accept-Language.

// src/app/i18n/messages.ts
import { defineMessages } from "@reckona/mreact-router";

export const messages = defineMessages({
  en: { heading: "Locale detection", welcome: "Hello!" },
  ja: { heading: "Locale detection (ja)", welcome: "Hello from ja!" },
});
// src/app/i18n/page.tsx
import { detectLocale } from "@reckona/mreact-router";
import { messages } from "./messages.js";

export function loader(context: { request: Request }) {
  return detectLocale(context.request, {
    defaultLocale: "en",
    locales: ["en", "ja"],
  });
}

export default function Page(props: { data: { locale: "en" | "ja" } }) {
  const t = messages[props.data.locale];
  return <h1>{t.heading}</h1>;
}

Deployment Adapters

The router provides adapters for common deployment shapes:

// Node http server
import { createNodeRequestHandler } from "@reckona/mreact-router/adapters/node";

const handler = createNodeRequestHandler({
  allowedHosts: ["example.com"],
  hostPolicy: "strict",
  onResponse(response) {
    response.headers.set("strict-transport-security", "max-age=31536000; includeSubDomains");
    response.headers.set("x-content-type-options", "nosniff");
    response.headers.set("referrer-policy", "same-origin");
  },
  outDir: ".mreact",
  port: 3000,
});

For public deployments, set allowedHosts to the exact hosts your app serves. Use hostPolicy: "strict" to fall back to the configured hostname/port when a request Host is not allow-listed. Use hostPolicy: "trusted-proxy" only when a trusted reverse proxy normalizes the Host header before traffic reaches mreact. Use onResponse to add global headers to the final Response; it runs for rendered pages, route handlers, middleware responses, redirects, errors, prerendered routes, and built static/client assets returned through the built app runtime.

// Edge-style runtime
import { createEdgeRequestHandler } from "@reckona/mreact-router/adapters/edge";

const handler = createEdgeRequestHandler({
  render(request) {
    const url = new URL(request.url);
    return new Response(`<h1>${url.pathname}</h1>`, {
      headers: { "content-type": "text/html; charset=utf-8" },
    });
  },
});

export default {
  fetch(request: Request) {
    return handler(request);
  },
};

Additional adapters are available at:

  • @reckona/mreact-router/adapters/aws-lambda
  • @reckona/mreact-router/adapters/cloudflare
  • @reckona/mreact-router/adapters/static
// AWS Lambda HTTP API v2 / Lambda Function URL
import { createAwsLambdaRequestHandler } from "@reckona/mreact-router/adapters/aws-lambda";

export const handler = createAwsLambdaRequestHandler({
  onResponse(response) {
    response.headers.set("x-frame-options", "DENY");
    response.headers.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
  },
  outDir: ".mreact",
  importPolicy: {
    allowedPackages: [
      "@reckona/mreact",
      // "cookie",
      // "zod",
    ],
  },
});

Production adapters enforce the app-router import policy when bundling loaders, middleware, route handlers, metadata, and server actions. Add every npm package imported by server-side application code to importPolicy.allowedPackages, including dependencies reached through app-local helper modules.

For Lambda Function URL response streaming, use the explicit streaming handler:

import { createAwsLambdaStreamingRequestHandler } from "@reckona/mreact-router/adapters/aws-lambda";

export const handler = createAwsLambdaStreamingRequestHandler({
  outDir: ".mreact",
});

Container Deploy

create-mreact-app --deploy container generates a vendor-neutral Dockerfile, .dockerignore, and docs/deploy/container.md. The generated image uses Node 24 LTS, sets PORT=8080, builds with mreact-router build --target=node, and starts with mreact-router start .mreact through the package start script.

The same container shape works for Cloud Run, AWS App Runner, Fly.io, Render, and other platforms that run an HTTP server from a container:

FROM node:24-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm install --frozen-lockfile || pnpm install

FROM node:24-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY --from=deps /app ./
RUN pnpm run build

FROM node:24-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
RUN corepack enable
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/.mreact ./.mreact
EXPOSE 8080
CMD ["pnpm", "start"]

AWS Lambda Deploy

create-mreact-app --deploy aws-lambda generates src/lambda.ts and docs/deploy/aws-lambda.md. The generated handler targets API Gateway HTTP API v2 and Lambda Function URL payload format 2.0:

import { createAwsLambdaRequestHandler } from "@reckona/mreact-router/adapters/aws-lambda";

export const handler = createAwsLambdaRequestHandler({
  outDir: new URL("../.mreact", import.meta.url).pathname,
  importPolicy: {
    allowedPackages: [
      "@reckona/mreact",
      // "cookie",
      // "zod",
    ],
  },
});

Add every npm package imported by loaders, middleware, route handlers, metadata, server actions, or their app-local helper modules to importPolicy.allowedPackages. Common examples include validation, cookie, database, auth, and AWS SDK packages.

Use relative imports for app-local server modules in Lambda builds. TypeScript or Vite path aliases such as ~/* are not resolved before the production import policy checks package imports.

Build Lambda artifacts with mreact-router build --target=node so the build writes only the Node-compatible server/client output and does not attempt to bundle Cloudflare Workers route modules for loaders that import Node-only dependencies such as database drivers.

Keep Lambda assets below AWS's 250 MB unzipped deployment package limit by packaging a dedicated directory instead of the project root. The runtime needs .mreact/, the bundled Lambda handler such as dist/lambda.mjs, package.json / lockfiles, and production node_modules; src/, tests, dev dependencies, build caches, and Playwright/Vitest/Vite tooling are not required at runtime. The Lambda adapter treats outDir as read-only and materializes generated runtime files under /tmp/mreact-router/<hash>/runtime by default, with a node_modules symlink back to the deployed package root. Handler creation starts a background preload for the built runtime, loader modules, middleware, route handlers, and route metadata so route-specific bundling can move out of the first matched request on warmable runtimes. Set runtimeDir only when you need a custom writable cache directory. For pnpm, a deploy script can copy those files into .lambda/ and run pnpm --dir .lambda install --prod --frozen-lockfile --ignore-scripts --config.node-linker=hoisted before CDK/SAM/serverless packages that directory. pnpm's default isolated linker creates many symlinks, and some Lambda packaging tools dereference or count those links differently; verify find .lambda -type l | wc -l and the actual file bytes in addition to du -sh .lambda. Packages listed in importPolicy.allowedPackages must also exist in that production dependency set.

Lambda proxy responses are buffered, so this adapter does not provide true response streaming. For production, serve .mreact/client from S3 + CloudFront or another CDN and configure assetBaseUrl / publicAssetBaseUrl.

Use createAwsLambdaStreamingRequestHandler() only with a Lambda Function URL or API Gateway integration configured for payload response streaming. It uses the Node.js Lambda runtime awslambda.streamifyResponse() and awslambda.HttpResponseStream.from() APIs, and streams response bytes without base64 buffering.

CDN Asset Base URLs

Built client route assets are written to .mreact/client. Public files are copied from public/ to .mreact/client/public. By default, the mreact server serves those assets itself:

  • /_mreact/client/*
  • root public paths such as /styles.css

To serve static assets from a CDN, upload .mreact/client to a static origin and configure base URLs in vite.config.ts:

import { defineConfig } from "vite";
import { mreactRouter } from "@reckona/mreact-router/vite";

export default defineConfig({
  plugins: [
    mreactRouter({
      projectRoot: __dirname,
      routesDir: "src/app",
      publicDir: "public",
      allowedSourceDirs: ["src"],
      assetBaseUrl: "https://cdn.example.com/_mreact/client/",
      publicAssetBaseUrl: "https://cdn.example.com/",
    }),
  ],
});

assetBaseUrl is used for route scripts and modulepreload links emitted into HTML. publicAssetBaseUrl is persisted in the server manifest and is intended for public asset helpers and deployment tooling. If these options are omitted, the generated HTML stays on the existing root-relative paths.

Reactive Primitives

Use @reckona/mreact-reactive-core outside the router or inside compiled routes.

import { batch, cell, computed, effect } from "@reckona/mreact-reactive-core";

const first = cell("Ada");
const last = cell("Lovelace");
const fullName = computed(() => `${first.get()} ${last.get()}`);

const dispose = effect(() => {
  console.log(fullName.get());
});

batch(() => {
  first.set("Grace");
  last.set("Hopper");
});

dispose();

When you use the low-level DOM bindings directly, mount them through createRoot() from @reckona/mreact-reactive-dom and keep the returned dispose function. Bindings and effects are intentionally explicit-lifetime primitives; if you create them outside a root scope, you must dispose them yourself. Compiled router/client output wires this scope for you.

Store

@reckona/mreact-store wraps reactive state with patch updates, transactions, selectors, subscriptions, and optional persistence/instrumentation hooks.

import { createStore, shallowEqual } from "@reckona/mreact-store";

interface CartState {
  lines: Array<{ id: string; quantity: number }>;
  promoCode: string | null;
}

const cart = createStore<CartState>({
  lines: [{ id: "book", quantity: 1 }],
  promoCode: null,
});

const itemCount = cart.select(
  (state) => state.lines.reduce((total, line) => total + line.quantity, 0),
);

cart.transaction(() => {
  cart.set({ promoCode: "MREACT10" });
  cart.update((state) => ({
    lines: state.lines.map((line) =>
      line.id === "book" ? { ...line, quantity: line.quantity + 1 } : line,
    ),
  }));
});

cart.select((state) => ({ promoCode: state.promoCode }), shallowEqual);
console.log(itemCount.get());

React Compatibility

@reckona/mreact and @reckona/mreact-dom expose React-like entry points for compatibility-oriented builds.

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@reckona/mreact"
  }
}
import { Suspense, lazy, useEffect, useState } from "@reckona/mreact";
import { createRoot } from "@reckona/mreact-dom/client";

const LazyPanel = lazy(() => import("./Panel.js"));

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `count = ${count}`;
  }, [count]);

  return (
    <main>
      <button type="button" onClick={() => setCount((value) => value + 1)}>
        Count: {count}
      </button>
      <Suspense fallback={<p>Loading...</p>}>
        <LazyPanel />
      </Suspense>
    </main>
  );
}

createRoot(document.getElementById("root")!).render(<App />);

For source that imports react and react-dom, configure your bundler to resolve those specifiers to the mreact shim packages or use the workspace example in examples/react-compat.

Compatibility scope:

Area Status Notes
JSX runtime, createElement, fragments Supported Covered by the compat compiler/runtime path.
useState, useReducer, useMemo, useCallback, useRef, useEffect, useLayoutEffect Supported Intended for React-like client components.
Context Supported Provider, consumer, and useContext are implemented in the compat runtime.
Suspense, lazy Partial Fallback and retry are implemented; this is not a full React concurrent renderer.
startTransition, useTransition, useDeferredValue Partial Scheduled through mreact's cooperative scheduler, with a smaller surface than React.
useSyncExternalStore Partial Snapshot stabilization exists, but React's full tearing semantics are not guaranteed.
Portals Not supported Avoid APIs that require createPortal.
Server Components / Flight client interop Experimental Flight encode/decode exists for mreact router internals, not React ecosystem compatibility.
React DevTools protocol Not supported mreact has its own lightweight devtools hooks.

SSR Without the App Router

@reckona/mreact-server can render compiled server-stream functions to strings or streaming sinks directly.

import { renderToString, type HtmlSink } from "@reckona/mreact-server";

function Page(sink: HtmlSink) {
  sink.append("<main>");
  sink.append("<h1>Server HTML</h1>");
  sink.append("<p>Rendered without the app router.</p>");
  sink.append("</main>");
}

const html = await renderToString(Page);

For lower-level streaming, pass a sink to a compiled server-stream module:

const chunks: string[] = [];

await Page({
  append(chunk: string) {
    chunks.push(chunk);
  },
});

Benchmarks

The repository contains two benchmark suites:

  • benchmarks/primitive: primitive UI/reactivity comparisons across mreact, React, Solid, Svelte, Qwik, Marko, and beta variants where available.
  • benchmarks/router: app-router comparisons across mreact app router, Next.js App Router, Qwik City, SolidStart, TanStack Start, Marko Run, and beta variants where available.

Some compared frameworks, especially Qwik and Marko, are designed around resumability or partial client activation rather than React-style full hydration. These benchmarks include server rendering, streaming, bundle size, and browser interaction timing, but they do not fully model every scenario where delaying or avoiding client-side work is beneficial. Primitive DOM-update cases mostly measure already-active update paths, and the browser interaction cases use small synthetic routes.

For resumability-oriented frameworks, interpret first-interaction and update-path results together with shipped JavaScript size, startup work, and whether unused UI needs to be activated at all. The router suite splits browser interaction timing into initial page load before interaction, first interaction from DOMContentLoaded, first interaction after networkidle, and second interaction latency to make those trade-offs easier to read.

Some primitive cases intentionally expose architectural differences. For example, select row in 10k rows is fast in mreact because the selected state is attached to the class attributes that actually change, so the update path mutates a small number of DOM nodes directly. React re-runs reconciliation for the keyed list, and other fine-grained frameworks vary depending on whether their adapter binds the selection state directly to DOM attributes or routes it through broader list bookkeeping.

Run them from the repo root:

pnpm bench:primitive
pnpm bench:router
pnpm bench:all

The latest GitHub Actions benchmark runs are listed on the Benchmarks workflow page. Each run uploads primitive.md, router.md, and the corresponding JSON summary files as artifacts.

Examples

The examples/ directory contains focused applications:

Example What it demonstrates
examples/app-router Full app-router tour: layouts, metadata, streaming, server actions, cache, route handlers, middleware, auth, query, forms, i18n, deployment adapters
examples/reactive-primitives cell, computed, effect, and DOM updates
examples/store Shared store, selectors, transactions, and subscriptions
examples/ssr-streaming String rendering, streaming rendering, and async boundaries
examples/react-compat React-like hooks, Suspense, lazy, and DOM root entry points
examples/selective-hydration Selective hydration without the app router

Packages

Package Purpose
@reckona/mreact React-like public runtime entry point
@reckona/mreact-dom React DOM-compatible client and server entry points
@reckona/mreact-compat Compatibility runtime used by the public React-like packages
@reckona/mreact-scheduler Scheduler compatibility package
@reckona/mreact-compiler TSX compiler for client and server targets
@reckona/mreact-reactive-core cell, computed, effect, batch, and dependency tracking
@reckona/mreact-reactive-dom DOM bindings for text, lists, events, props, and hydration
@reckona/mreact-server SSR string, stream, async boundary, and Flight helpers
@reckona/mreact-router File-system app router, build pipeline, server actions, cache, adapters
@reckona/mreact-vite Standalone Vite plugin for compatibility-oriented builds
@reckona/mreact-shared Shared HTML escaping and URL safety helpers
@reckona/mreact-query Query cache, mutation observer, dehydration, client hand-off
@reckona/mreact-store Global/client state primitives
@reckona/mreact-auth Session and authorization helpers
@reckona/mreact-forms Form validation and server-action error integration
@reckona/mreact-devtools Shared development event hooks
@reckona/mreact-test-utils Router and SSR testing helpers
@reckona/create-mreact-app Project scaffolder
@reckona/mreact-router-native Optional native route matcher package with platform variants
@reckona/mreact-router-native-linux-x64-gnu Linux x64 glibc native addon package
@reckona/mreact-router-native-darwin-arm64 macOS arm64 native addon package
@reckona/mreact-router-native-win32-x64-msvc Windows x64 MSVC native addon package
@reckona/mreact-next Experimental Next-oriented compiler integration

Development

Install dependencies and build packages:

pnpm install
pnpm build

Run tests:

pnpm test
pnpm test:e2e

Run the app-router example:

pnpm example:mreact-app-router:dev

Generate and check API documentation:

pnpm docs:api
pnpm docs:api:check
pnpm api:report
pnpm api:report:check

docs/api contains the generated TypeDoc HTML reference and is intentionally committed. etc/api contains API Extractor reports used to review public API signature changes.

License

MIT

About

React-flavored framework inspired by Marko

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors