Skip to content

ZtaMDev/RoundJS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

75 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Round JS

Round Framework Logo

NPM Version

Round is a lightweight, DOM-first framework for building SPAs with fine-grained reactivity, and fast, predictable updates powered by signals and bindables

Extension for VSCode and OpenVSX


Instead of a Virtual DOM diff, Round updates the UI by subscribing DOM updates directly to reactive primitives signals and bindables. This keeps rendering predictable, small, and fast for interactive apps.

The round-core package is the foundation of RoundJS.

You can think of round-core as:

  • A framework-level runtime, not just a state library
  • Comparable in scope to React + Router + Signals, but significantly smaller
  • Suitable for fast SPAs and simple SSR setups without heavy infrastructure

Installation

To use Round JS today, install the core package:

npm install round-core

Or with Bun:

bun add round-core

What Round is focused on

Round is a No-VDOM framework.

  1. Direct DOM Manipulation: Components run once. They return real DOM nodes (via document.createElement).
  2. Fine-Grained Reactivity: Use of signal, effect, and bindable creates a dependency graph.
  3. Ergonomic bindings: built-in two-way bindings with bind:* directives.
  4. Surgical Updates: When a signal changes, only the specific text node, attribute, or property subscribed to that signal is updated. The component function does not re-run.
  5. A JSX superset: .round files support extra control-flow syntax that compiles to JavaScript.

This avoids the overhead of Virtual DOM diffing and reconciliation entirely.

Concepts

SPA

A Single Page Application (SPA) loads one HTML page and then updates the UI dynamically as the user navigates and interacts—without full page reloads.

Fine-grained reactivity (signals)

A signal is a small reactive container.

  • Reading a signal inside an effect() tracks a dependency.
  • Writing to a signal triggers only the subscribed computations.

Quick start (create a new app)

Round includes a CLI with a project initializer.

# Install the CLI
bun add round-core

# Create a new app
round init myapp

# Navigate to the app directory
cd myapp

# Install dependencies
bun install

# Run the app
bun run dev

This scaffolds a minimal Round app with src/app.round and an example src/counter.round.

.round files

A .round file is a JSX-based component module (ESM) compiled by the Round toolchain. You can also use .jsx files, but you will not get the Round JSX superset features such as extended control flow.

Example src/app.round:

import { Route } from "round-core";
import { Counter } from "./counter.round";

export default function App() {
  return (
    <div
      style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
    >
      <Route route="/" title="Home">
        <Counter />
      </Route>
    </div>
  );
}

Core API & Examples

signal(initialValue)

Create a reactive signal.

  • Call with no arguments to read.
  • Call with one argument to write.
  • Use .value to read/write the current value in a non-subscribing way (static access).
import { signal } from "round-core";

export function Counter() {
  const count = signal(0);

  return (
    <div>
      <p>Count: {count()}</p>

      <button onClick={() => count(count() + 1)}>Increment</button>

      <button onClick={() => count(0)}>Reset</button>
    </div>
  );
}
Explanation:
  • signal(0) creates a reactive value initialized to 0

  • Calling count() reads the current value and subscribes the DOM

  • Calling count(newValue) writes a new value and updates only the subscribed nodes

  • The component function runs once — only the text node updates when count changes

Non-reactive (static) access with .value

const count = signal(5);

// Read without tracking
console.log(count.value); // 5

asyncSignal(fetcher)

Create a signal that manages asynchronous data fetching.

  • It returns a signal that resolves to the data once fetched.
  • .pending: A reactive signal (boolean) indicating if the fetch is in progress.
  • .error: A reactive signal containing any error that occurred during fetching.
  • .refetch(): A method to manually trigger a re-fetch.
import { asyncSignal } from 'round-core';

const user = asyncSignal(async () => {
    const res = await fetch('/api/user');
    return res.json();
});

export function UserProfile() {
    return (
        <div>
            {if(user.pending()){
                <div>Loading...</div>
            } else if(user.error()){
                <div>Error: {user.error().message}</div>
            } else {
                <div>Welcome, {user().name}</div>
            }}
            <button onClick={() => user.refetch()}>Reload</button>
        </div>
    );
}

Signals Internals

RoundJS utilizes a high-performance reactivity engine designed for efficiency and minimal memory overhead:

  • Doubly-Linked List Dependency Tracking: Instead of using heavy Set objects, RoundJS uses a linked-list of subscription nodes. This eliminates array spreads and object allocations during signal updates, providing constant-time performance for adding/removing dependencies.
  • Global Versioning (Clock): Every signal write increments a global version counter. Computed signals (derive) track the version of their dependencies and only recompute if they are "dirty" (out of date). This ensures true lazyness and avoids redundant calculations.
  • Automatic Batching: Multiple signal updates within the same execution cycle are batched. Effects and DOM updates only trigger once at the end of the batch, preventing "glitches" and unnecessary re-renders.

derive(fn)

Create a computed signal that updates automatically when its dependencies change.

Example: Reactive Counter

This example demonstrates signal, derive, and the JSX control flow if block.

import { signal, derive } from 'round-core';

export default function Counter() {
    const count = signal(0);
    // Derive creates a readonly signal that updates when count changes
    const doubled = derive(() => count() * 2);

    return (
        <div>
            <h1>Count: {count()} (Doubled: {doubled()})</h1>

            {/* Control flow is part of the Round JSX superset */}
            {if(count() > 5){
                 <p style="color: red">Count is high!</p>
            }}

            <div style={{ display: 'flex', gap: '8px' }}>
                <button onClick={() => count(count() + 1)}>Increment</button>
                <button onClick={() => count(count() - 1)}>Decrement</button>
            </div>
        </div>
    );
}

JSX superset control flow

Round extends JSX inside .round files with a control-flow syntax that compiles to JavaScript. This is preferred over ternary operators for complex logic.

if / else if / else

{
  if (user.loggedIn) {
    <Dashboard />;
  } else if (user.loading) {
    <div>Loading...</div>;
  } else {
    <Login />;
  }
}

Notes:

  • Conditions may be normal JS expressions.
  • For simple paths like flags.showCounter (identifier/member paths), Round will auto-unwrap signal-like values (call them) so the condition behaves as expected.
  • Multiple elements inside a block are automatically wrapped in a Fragment.

for (... in ...)

{for(item in items()) key=item.id {
    <div className="row">{item.name}</div>
}}

This compiles to efficient keyed reconciliation using the ForKeyed runtime component.

Keyed vs Unkeyed

  • Keyed (Recommended): By providing key=expr, Round maintains the identity of DOM nodes. If the list reorders, Round moves the existing nodes instead of recreating them. This preserves local state (like input focus, cursor position, or CSS animations).
  • Unkeyed: If no key is provided, Round simply maps over the list. Reordering the list will cause nodes to be reused based on their index, which might lead to state issues in complex lists.

switch(...)

{
  switch (status()) {
    case "loading":
      <Spinner />;
    case "error":
      <ErrorMessage />;
    default:
      <DataView />;
  }
}

Notes:

  • The switch expression is automatically wrapped in a reactive tracker, ensuring that the view updates surgically when the condition (e.g., a signal) changes.
  • Each case handles its own rendering without re-running the parent component.

try / catch

Round supports both static and reactive try/catch blocks inside JSX.

  • Static: Just like standard JS, but renders fragments.
  • Reactive: By passing one or more signals to try(...), the block will automatically re-run if any of those signals (or their dependencies) update. This is perfect for handling transient errors in async data.

Single dependency:

{try(user()) {
    {/* Note: we access .error because it is a asyncSignal() not a normal signal */}
    {if(user().error){
        raise(user().error) {/* raise() is a function to throw an error in JSX and is imported from 'round-core' */}
    }}
    <Profile data={user()} />
} catch(e) {
    <div className="error"> Failed to load user: {e.message} </div>
}}

Multiple dependencies: You can track multiple signals by listing them using the comma operator. Both signals will be tracked.

{try(signal1(), signal2()) {
    <div>
        Data 1: {signal1()}
        Data 2: {signal2()}
    </div>
} catch(e) {
    <div>One of the signals threw an error!</div>
}}

Additional Core API

effect(fn)

Run fn whenever the signals it reads change.

import { signal, effect } from "round-core";

const name = signal("Ada");

effect(() => {
  console.log("Name changed:", name());
});

name("Grace");

untrack(fn)

Run a function without tracking any signals it reads.

import { signal, untrack, effect } from "round-core";

const count = signal(0);
effect(() => {
  console.log("Count is:", count());
  untrack(() => {
    // This read won't trigger the effect if it changes elsewhere
    console.log("Static value:", count());
  });
});

bindable(initialValue)

bindable() creates a signal intended for two-way DOM bindings.

import { bindable } from "round-core";

export function Example() {
  const email = bindable("");

  return (
    <div>
      <input bind:value={email} placeholder="Email" />
      <div>Typed: {email()}</div>
    </div>
  );
}

DOM binding directives

Round supports two-way bindings via props:

  • bind:value={someBindable} for text-like inputs, <textarea>, and <select>.
  • bind:checked={someBindable} for <input type="checkbox"> and <input type="radio">.

Round will warn if the value is not signal-like, and will warn if you bind a plain signal() instead of a bindable().

bindable.object(initialObject) and deep binding

Round supports object-shaped state with ergonomic deep bindings via proxies.

import { bindable } from "round-core";

export function Profile() {
  const user = bindable.object({
    profile: { bio: "" },
    flags: { newsletter: false },
  });

  return (
    <div>
      <textarea bind:value={user.profile.bio} />
      <label>
        <input type="checkbox" bind:checked={user.flags.newsletter} />
        Subscribe
      </label>
    </div>
  );
}

createStore(initialState, actions)

Create a shared global state store with actions and optional persistence.

import { createStore } from 'round-core';

// 1. Define Store
const store = createStore({
    todos: [],
    filter: 'all'
}, {
    addTodo: (state, text) => ({
        ...state,
        todos: [...state.todos, { text, done: false }]
    })
});

// 2. Use in Component
export function TodoList() {
    const todos = store.use('todos'); // Returns a bindable signal

    return (
        <div>
            {for(todo in todos()){
                <div>{todo.text}</div>
            }}
            <button onClick={() => store.addTodo('Buy Milk')}>Add</button>
        </div>
    );
}

// 3. Persistence (Optional)
store.persist('my-app-store', {
    debounce: 100, // ms
    exclude: ['someSecretKey']
});

// 4. Advanced Methods
store.patch({ filter: 'completed' }); // Update multiple keys at once
const data = store.snapshot({ reactive: false }); // Get static JSON of state
store.set('todos', []); // Direct set(sets all todos to [] it wont save to storage)

.validate(validator, options)

Attach validation to a signal/bindable.

  • Invalid writes do not update the underlying value.
  • signal.error is itself a signal (reactive) containing the current error message or null.
  • options.validateOn can be 'input' (default) or 'blur'.
  • options.validateInitial can trigger validation on startup.
import { bindable } from "round-core";

export function EmailField() {
  const email = bindable("").validate(
    (v) => v.includes("@") || "Invalid email",
    { validateOn: "blur" }
  );

  return (
    <div>
      <input bind:value={email} placeholder="name@example.com" />
      <div style={() => ({ color: email.error() ? "crimson" : "#666" })}>
        {email.error}
      </div>
    </div>
  );
}

Lifecycle Hooks

Round provides hooks to tap into the lifecycle of components. These must be called during the synchronous execution of your component function.

onMount(fn)

Runs after the component is first created and its elements are added to the DOM. If fn returns a function, it's used as a cleanup (equivalent to onUnmount).

onUnmount(fn)

Runs when the component's elements are removed from the DOM.

onUpdate(fn)

Runs whenever any signal read during the component's initial render is updated.

onCleanup(fn)

Alias for onUnmount.

import { onMount, onUnmount } from "round-core";

export function MyComponent() {
  onMount(() => {
    console.log("Mounted!");
    const timer = setInterval(() => {}, 1000);
    return () => clearInterval(timer); // Cleanup
  });

  onUnmount(() => console.log("Goodbye!"));

  return <div>Hello</div>;
}

Routing

Round includes router primitives intended for SPA navigation. All route paths must start with a forward slash /.

Basic Usage

import { Route, Link } from "round-core";

export default function App() {
  return (
    <div>
      <nav>
        <Link href="/">Home</Link>
        <Link href="/about">About</Link>
      </nav>

      <Route route="/" title="Home" exact>
        <div>Welcome Home</div>
      </Route>
      <Route route="/about" title="About">
        <div>About Us Content</div>
      </Route>
    </div>
  );
}

Nested Routing and Layouts

Routes can be nested to create hierarchical layouts. Child routes automatically inherit and combine paths with their parents.

  • Prefix Matching: By default, routes use prefix matching (except for the root /). This allows a parent route to stay rendered as a "shell" or layout while its children are visited.
  • Exact Matching: Use the exact prop to ensure a route only renders when the path matches precisely (default for root /).
<Route route="/dashboard" title="Dashboard">
  <h1>Dashboard Shell</h1>

  {/* This route matches /dashboard/profile */}
  <Route route="/dashboard/profile">
    <h2>User Profile</h2>
  </Route>

  {/* This route matches /dashboard/settings */}
  <Route route="/dashboard/settings">
    <h2>Settings</h2>
  </Route>
</Route>

Routing Memoization (Keep-Alive)

Round allows you to persist the state and DOM nodes of a route even when you navigate away. This is perfect for maintaining the state of a complex list or preserving the state of a multi-step form.

When memo is set to true, the route's DOM nodes are hidden using display: none instead of being removed. This ensures that:

  • Signals and local state are preserved.
  • CSS Transitions and Animations don't reset.
  • Form inputs maintain their value and focus.
<Route route="/explore" memo={true}>
  <ExploreFeed />
</Route>

Tip

This works even if the Route component itself is unmounted and remounted (e.g., within an if block), as Round maintains a global persistence cache for memoized routes.

Suspense and lazy loading

Round supports Suspense for promise-based rendering and lazy() for code splitting.

import { Suspense, lazy } from "round-core";
const LazyWidget = lazy(() => import("./Widget"));

<Suspense fallback={<div>Loading...</div>}>
  <LazyWidget />
</Suspense>;

Markdown rendering with @round-core/markdown

Round includes an optional companion package for markdown rendering with syntax highlighting and rich code blocks.

Install it alongside round-core:

npm install @round-core/markdown

or with Bun:

bun add @round-core/markdown

Then use it in a .round file or JSX component:

import { createElement } from "round-core";
import {
  Markdown,
  defaultMarkdownStyles as markdown,
} from "@round-core/markdown";
import "@round-core/markdown/styles.css";

export default function App() {
  const content = `
# Markdown with code blocks

You can render fenced code blocks with syntax highlighting:

\`\`\`javascript
const value = signal(0);
\`\`\`
`;

  return (
    <div>
      <Markdown content={content} />
    </div>
  );
}

You can also theme the markdown surface (background/text) and accents via options.theme:

<Markdown
  content={content}
  options={{
    theme: {
      // Dark card-like surface
      markdownBackground: '#020617',
      markdownText: '#e5e7eb',
      primaryColor: '#e5e7eb',
      secondaryColor: '#38bdf8',
    },
  }}
/>

<Markdown
  content={content}
  options={{
    theme: {
      // Light mode
      markdownBackground: '#ffffff',
      markdownText: '#0f172a',
      primaryColor: '#0f172a',
      secondaryColor: '#2563eb',
    },
  }}
/>

Error handling

Round JS favors individual error control and standard browser debugging:

  1. Explict try/catch: Use the JSX try/catch syntax to handle local component failures gracefully.
  2. Console-First Reporting: Unhandled errors in component rendering or reactive effects are logged to the browser console with descriptive metadata (component name, render phase) and then allowed to propagate.
  3. No Intrusive Overlays: Round has removed conflicting global error boundaries to ensure that your local handling logic always takes precedence and the developer experience remains clean.

Example of a descriptive console log: [round] Error in phase "component.render" of component <UserProfile />: TypeError: Cannot read property 'avatar' of undefined

CLI

The CLI is intended for day-to-day development:

  • round dev
  • round build
  • round preview
  • round init <name>

Run round -h to see available commands.

Status

Round is under active development and the API is still stabilizing. The README is currently the primary documentation; a dedicated documentation site will be built later using Round itself.

License

MIT

About

Round is a lightweight, DOM-first framework for building SPAs with fine-grained reactivity, and fast, predictable updates powered by signals and bindables

Topics

Resources

License

Stars

Watchers

Forks

Contributors