round-core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,20 +12,20 @@
12
12
  <em><b>Round</b> is a lightweight, DOM-first framework for building SPAs with fine-grained reactivity, and fast, predictable updates powered by signals and bindables</em>
13
13
  </h3>
14
14
 
15
-
16
15
  <div align="center">
17
16
 
18
17
  Extension for [VSCode](https://marketplace.visualstudio.com/items?itemName=ZtaMDev.round) and [OpenVSX](https://open-vsx.org/extension/ztamdev/round)
19
18
 
20
19
  </div>
21
20
 
22
- ---
21
+ ---
23
22
 
24
23
  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.
25
24
 
26
25
  The `round-core` package is the **foundation of RoundJS**.
27
26
 
28
27
  You can think of `round-core` as:
28
+
29
29
  - A **framework-level runtime**, not just a state library
30
30
  - Comparable in scope to React + Router + Signals, but significantly smaller
31
31
  - Suitable for fast SPAs and simple SSR setups without heavy infrastructure
@@ -101,103 +101,109 @@ such as extended control flow.
101
101
  Example `src/app.round`:
102
102
 
103
103
  ```jsx
104
- import { Route } from 'round-core';
105
- import { Counter } from './counter.round';
104
+ import { Route } from "round-core";
105
+ import { Counter } from "./counter.round";
106
106
 
107
107
  export default function App() {
108
- return (
109
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
110
- <Route route="/" title="Home">
111
- <Counter />
112
- </Route>
113
- </div>
114
- );
108
+ return (
109
+ <div
110
+ style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
111
+ >
112
+ <Route route="/" title="Home">
113
+ <Counter />
114
+ </Route>
115
+ </div>
116
+ );
115
117
  }
116
118
  ```
117
119
 
118
- ## Markdown rendering with `@round-core/markdown`
120
+ ## Core API & Examples
119
121
 
120
- Round includes an optional companion package for markdown rendering with
121
- syntax highlighting and rich code blocks.
122
+ ### `signal(initialValue)`
122
123
 
123
- Install it alongside `round-core`:
124
+ Create a reactive signal.
124
125
 
125
- ```bash
126
- npm install @round-core/markdown
127
- ```
126
+ - Call with no arguments to **read**.
127
+ - Call with one argument to **write**.
128
+ - Use `.value` to read/write the current value in a non-subscribing way (static access).
128
129
 
129
- or with Bun:
130
+ ```jsx
131
+ import { signal } from "round-core";
130
132
 
131
- ```bash
132
- bun add @round-core/markdown
133
+ export function Counter() {
134
+ const count = signal(0);
135
+
136
+ return (
137
+ <div>
138
+ <p>Count: {count()}</p>
139
+
140
+ <button onClick={() => count(count() + 1)}>Increment</button>
141
+
142
+ <button onClick={() => count(0)}>Reset</button>
143
+ </div>
144
+ );
145
+ }
133
146
  ```
134
147
 
135
- Then use it in a `.round` file or JSX component:
148
+ ##### Explanation:
149
+
150
+ - `signal(0)` creates a reactive value initialized to 0
151
+
152
+ - Calling `count()` reads the current value and subscribes the DOM
153
+
154
+ - Calling `count(newValue)` writes a new value and updates only the subscribed nodes
155
+
156
+ - The component function runs once — only the text node updates when count changes
157
+
158
+ #### Non-reactive (static) access with .value
136
159
 
137
160
  ```jsx
138
- import { createElement } from 'round-core';
139
- import { Markdown, defaultMarkdownStyles as markdown } from '@round-core/markdown';
140
- import '@round-core/markdown/styles.css';
161
+ const count = signal(5);
141
162
 
142
- export default function App() {
143
- const content = `
144
- # Markdown with code blocks
163
+ // Read without tracking
164
+ console.log(count.value); // 5
165
+ ```
145
166
 
146
- You can render fenced code blocks with syntax highlighting:
167
+ ### `asyncSignal(fetcher)`
147
168
 
148
- \`\`\`javascript
149
- const value = signal(0);
150
- \`\`\`
151
- `;
169
+ Create a signal that manages asynchronous data fetching.
170
+
171
+ - It returns a signal that resolves to the data once fetched.
172
+ - **`.pending`**: A reactive signal (boolean) indicating if the fetch is in progress.
173
+ - **`.error`**: A reactive signal containing any error that occurred during fetching.
174
+ - **`.refetch()`**: A method to manually trigger a re-fetch.
175
+
176
+ ```jsx
177
+ import { asyncSignal } from 'round-core';
152
178
 
179
+ const user = asyncSignal(async () => {
180
+ const res = await fetch('/api/user');
181
+ return res.json();
182
+ });
183
+
184
+ export function UserProfile() {
153
185
  return (
154
186
  <div>
155
- <Markdown content={content}/>
187
+ {if(user.pending()){
188
+ <div>Loading...</div>
189
+ } else if(user.error()){
190
+ <div>Error: {user.error().message}</div>
191
+ } else {
192
+ <div>Welcome, {user().name}</div>
193
+ }}
194
+ <button onClick={() => user.refetch()}>Reload</button>
156
195
  </div>
157
196
  );
158
197
  }
159
198
  ```
160
199
 
161
- You can also theme the markdown surface (background/text) and accents via
162
- `options.theme`:
163
-
164
- ```jsx
165
- <Markdown
166
- content={content}
167
- options={{
168
- theme: {
169
- // Dark card-like surface
170
- markdownBackground: '#020617',
171
- markdownText: '#e5e7eb',
172
- primaryColor: '#e5e7eb',
173
- secondaryColor: '#38bdf8',
174
- },
175
- }}
176
- />
177
-
178
- <Markdown
179
- content={content}
180
- options={{
181
- theme: {
182
- // Light mode
183
- markdownBackground: '#ffffff',
184
- markdownText: '#0f172a',
185
- primaryColor: '#0f172a',
186
- secondaryColor: '#2563eb',
187
- },
188
- }}
189
- />
190
- ```
191
-
192
- ## Core API & Examples
193
-
194
- ### `signal(initialValue)`
200
+ ### Signals Internals
195
201
 
196
- Create a reactive signal.
202
+ RoundJS utilizes a high-performance reactivity engine designed for efficiency and minimal memory overhead:
197
203
 
198
- - Call with no arguments to **read**.
199
- - Call with one argument to **write**.
200
- - Use `.value` to read/write the current value in a non-subscribing way (static access).
204
+ - **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.
205
+ - **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.
206
+ - **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.
201
207
 
202
208
  ### `derive(fn)`
203
209
 
@@ -218,7 +224,7 @@ export default function Counter() {
218
224
  return (
219
225
  <div>
220
226
  <h1>Count: {count()} (Doubled: {doubled()})</h1>
221
-
227
+
222
228
  {/* Control flow is part of the Round JSX superset */}
223
229
  {if(count() > 5){
224
230
  <p style="color: red">Count is high!</p>
@@ -233,14 +239,6 @@ export default function Counter() {
233
239
  }
234
240
  ```
235
241
 
236
- ### Signals Internals
237
-
238
- RoundJS utilizes a high-performance reactivity engine designed for efficiency and minimal memory overhead:
239
-
240
- - **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.
241
- - **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.
242
- - **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.
243
-
244
242
  ## JSX superset control flow
245
243
 
246
244
  Round extends JSX inside `.round` files with a control-flow syntax that compiles to JavaScript. This is preferred over ternary operators for complex logic.
@@ -248,18 +246,21 @@ Round extends JSX inside `.round` files with a control-flow syntax that compiles
248
246
  ### `if / else if / else`
249
247
 
250
248
  ```jsx
251
- {if(user.loggedIn){
252
- <Dashboard />
253
- } else if(user.loading){
254
- <div>Loading...</div>
255
- } else {
256
- <Login />
257
- }}
249
+ {
250
+ if (user.loggedIn) {
251
+ <Dashboard />;
252
+ } else if (user.loading) {
253
+ <div>Loading...</div>;
254
+ } else {
255
+ <Login />;
256
+ }
257
+ }
258
258
  ```
259
259
 
260
260
  Notes:
261
+
261
262
  - Conditions may be normal JS expressions.
262
- - For *simple paths* like `flags.showCounter` (identifier/member paths), Round will auto-unwrap signal-like values (call them) so the condition behaves as expected.
263
+ - For _simple paths_ like `flags.showCounter` (identifier/member paths), Round will auto-unwrap signal-like values (call them) so the condition behaves as expected.
263
264
  - Multiple elements inside a block are automatically wrapped in a Fragment.
264
265
 
265
266
  ### `for (... in ...)`
@@ -270,26 +271,30 @@ Notes:
270
271
  }}
271
272
  ```
272
273
 
273
- This compiles to efficient **keyed reconciliation** using the `ForKeyed` runtime component.
274
+ This compiles to efficient **keyed reconciliation** using the `ForKeyed` runtime component.
274
275
 
275
276
  #### Keyed vs Unkeyed
277
+
276
278
  - **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).
277
279
  - **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.
278
280
 
279
281
  ### `switch(...)`
280
282
 
281
283
  ```jsx
282
- {switch(status()){
283
- case 'loading':
284
- <Spinner />;
285
- case 'error':
286
- <ErrorMessage />;
284
+ {
285
+ switch (status()) {
286
+ case "loading":
287
+ <Spinner />;
288
+ case "error":
289
+ <ErrorMessage />;
287
290
  default:
288
- <DataView />;
289
- }}
291
+ <DataView />;
292
+ }
293
+ }
290
294
  ```
291
295
 
292
296
  Notes:
297
+
293
298
  - The `switch` expression is automatically wrapped in a reactive tracker, ensuring that the view updates surgically when the condition (e.g., a signal) changes.
294
299
  - Each case handles its own rendering without re-running the parent component.
295
300
 
@@ -301,6 +306,7 @@ Round supports both static and **reactive** `try/catch` blocks inside JSX.
301
306
  - **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.
302
307
 
303
308
  **Single dependency:**
309
+
304
310
  ```jsx
305
311
  {try(user()) {
306
312
  // Note: we access .error because it is a asyncSignal() not a normal signal
@@ -334,48 +340,15 @@ You can track multiple signals by listing them using the comma operator. Both si
334
340
  Run `fn` whenever the signals it reads change.
335
341
 
336
342
  ```javascript
337
- import { signal, effect } from 'round-core';
343
+ import { signal, effect } from "round-core";
338
344
 
339
- const name = signal('Ada');
345
+ const name = signal("Ada");
340
346
 
341
347
  effect(() => {
342
- console.log('Name changed:', name());
348
+ console.log("Name changed:", name());
343
349
  });
344
350
 
345
- name('Grace');
346
- ```
347
-
348
- ### `asyncSignal(fetcher)`
349
-
350
- Create a signal that manages asynchronous data fetching.
351
-
352
- - It returns a signal that resolves to the data once fetched.
353
- - **`.pending`**: A reactive signal (boolean) indicating if the fetch is in progress.
354
- - **`.error`**: A reactive signal containing any error that occurred during fetching.
355
- - **`.refetch()`**: A method to manually trigger a re-fetch.
356
-
357
- ```jsx
358
- import { asyncSignal } from 'round-core';
359
-
360
- const user = asyncSignal(async () => {
361
- const res = await fetch('/api/user');
362
- return res.json();
363
- });
364
-
365
- export function UserProfile() {
366
- return (
367
- <div>
368
- {if(user.pending()){
369
- <div>Loading...</div>
370
- } else if(user.error()){
371
- <div>Error: {user.error().message}</div>
372
- } else {
373
- <div>Welcome, {user().name}</div>
374
- }}
375
- <button onClick={() => user.refetch()}>Reload</button>
376
- </div>
377
- );
378
- }
351
+ name("Grace");
379
352
  ```
380
353
 
381
354
  ### `untrack(fn)`
@@ -383,15 +356,15 @@ export function UserProfile() {
383
356
  Run a function without tracking any signals it reads.
384
357
 
385
358
  ```javascript
386
- import { signal, untrack, effect } from 'round-core';
359
+ import { signal, untrack, effect } from "round-core";
387
360
 
388
361
  const count = signal(0);
389
362
  effect(() => {
390
- console.log('Count is:', count());
391
- untrack(() => {
392
- // This read won't trigger the effect if it changes elsewhere
393
- console.log('Static value:', count());
394
- });
363
+ console.log("Count is:", count());
364
+ untrack(() => {
365
+ // This read won't trigger the effect if it changes elsewhere
366
+ console.log("Static value:", count());
367
+ });
395
368
  });
396
369
  ```
397
370
 
@@ -400,17 +373,17 @@ effect(() => {
400
373
  `bindable()` creates a signal intended for **two-way DOM bindings**.
401
374
 
402
375
  ```jsx
403
- import { bindable } from 'round-core';
376
+ import { bindable } from "round-core";
404
377
 
405
378
  export function Example() {
406
- const email = bindable('');
379
+ const email = bindable("");
407
380
 
408
- return (
409
- <div>
410
- <input bind:value={email} placeholder="Email" />
411
- <div>Typed: {email()}</div>
412
- </div>
413
- );
381
+ return (
382
+ <div>
383
+ <input bind:value={email} placeholder="Email" />
384
+ <div>Typed: {email()}</div>
385
+ </div>
386
+ );
414
387
  }
415
388
  ```
416
389
 
@@ -428,23 +401,23 @@ Round will warn if the value is not signal-like, and will warn if you bind a pla
428
401
  Round supports object-shaped state with ergonomic deep bindings via proxies.
429
402
 
430
403
  ```jsx
431
- import { bindable } from 'round-core';
404
+ import { bindable } from "round-core";
432
405
 
433
406
  export function Profile() {
434
- const user = bindable.object({
435
- profile: { bio: '' },
436
- flags: { newsletter: false }
437
- });
407
+ const user = bindable.object({
408
+ profile: { bio: "" },
409
+ flags: { newsletter: false },
410
+ });
438
411
 
439
- return (
440
- <div>
441
- <textarea bind:value={user.profile.bio} />
442
- <label>
443
- <input type="checkbox" bind:checked={user.flags.newsletter} />
444
- Subscribe
445
- </label>
446
- </div>
447
- );
412
+ return (
413
+ <div>
414
+ <textarea bind:value={user.profile.bio} />
415
+ <label>
416
+ <input type="checkbox" bind:checked={user.flags.newsletter} />
417
+ Subscribe
418
+ </label>
419
+ </div>
420
+ );
448
421
  }
449
422
  ```
450
423
 
@@ -460,16 +433,16 @@ const store = createStore({
460
433
  todos: [],
461
434
  filter: 'all'
462
435
  }, {
463
- addTodo: (state, text) => ({
464
- ...state,
465
- todos: [...state.todos, { text, done: false }]
436
+ addTodo: (state, text) => ({
437
+ ...state,
438
+ todos: [...state.todos, { text, done: false }]
466
439
  })
467
440
  });
468
441
 
469
442
  // 2. Use in Component
470
443
  export function TodoList() {
471
444
  const todos = store.use('todos'); // Returns a bindable signal
472
-
445
+
473
446
  return (
474
447
  <div>
475
448
  {for(todo in todos()){
@@ -481,10 +454,10 @@ export function TodoList() {
481
454
  }
482
455
 
483
456
  // 3. Persistence (Optional)
484
- store.persist('my-app-store', {
457
+ store.persist('my-app-store', {
485
458
  debounce: 100, // ms
486
- exclude: ['someSecretKey']
487
- });
459
+ exclude: ['someSecretKey']
460
+ });
488
461
 
489
462
  // 4. Advanced Methods
490
463
  store.patch({ filter: 'completed' }); // Update multiple keys at once
@@ -502,23 +475,22 @@ Attach validation to a signal/bindable.
502
475
  - `options.validateInitial` can trigger validation on startup.
503
476
 
504
477
  ```jsx
505
- import { bindable } from 'round-core';
478
+ import { bindable } from "round-core";
506
479
 
507
480
  export function EmailField() {
508
- const email = bindable('')
509
- .validate(
510
- (v) => v.includes('@') || 'Invalid email',
511
- { validateOn: 'blur' }
512
- );
481
+ const email = bindable("").validate(
482
+ (v) => v.includes("@") || "Invalid email",
483
+ { validateOn: "blur" }
484
+ );
513
485
 
514
- return (
515
- <div>
516
- <input bind:value={email} placeholder="name@example.com" />
517
- <div style={() => ({ color: email.error() ? 'crimson' : '#666' })}>
518
- {email.error}
519
- </div>
520
- </div>
521
- );
486
+ return (
487
+ <div>
488
+ <input bind:value={email} placeholder="name@example.com" />
489
+ <div style={() => ({ color: email.error() ? "crimson" : "#666" })}>
490
+ {email.error}
491
+ </div>
492
+ </div>
493
+ );
522
494
  }
523
495
  ```
524
496
 
@@ -527,30 +499,34 @@ export function EmailField() {
527
499
  Round provides hooks to tap into the lifecycle of components. These must be called during the synchronous execution of your component function.
528
500
 
529
501
  ### `onMount(fn)`
502
+
530
503
  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`).
531
504
 
532
505
  ### `onUnmount(fn)`
506
+
533
507
  Runs when the component's elements are removed from the DOM.
534
508
 
535
509
  ### `onUpdate(fn)`
536
- Runs whenever any signal read during the component's *initial* render is updated.
510
+
511
+ Runs whenever any signal read during the component's _initial_ render is updated.
537
512
 
538
513
  ### `onCleanup(fn)`
514
+
539
515
  Alias for `onUnmount`.
540
516
 
541
517
  ```jsx
542
- import { onMount, onUnmount } from 'round-core';
518
+ import { onMount, onUnmount } from "round-core";
543
519
 
544
520
  export function MyComponent() {
545
- onMount(() => {
546
- console.log('Mounted!');
547
- const timer = setInterval(() => {}, 1000);
548
- return () => clearInterval(timer); // Cleanup
549
- });
521
+ onMount(() => {
522
+ console.log("Mounted!");
523
+ const timer = setInterval(() => {}, 1000);
524
+ return () => clearInterval(timer); // Cleanup
525
+ });
550
526
 
551
- onUnmount(() => console.log('Goodbye!'));
527
+ onUnmount(() => console.log("Goodbye!"));
552
528
 
553
- return <div>Hello</div>;
529
+ return <div>Hello</div>;
554
530
  }
555
531
  ```
556
532
 
@@ -561,24 +537,24 @@ Round includes router primitives intended for SPA navigation. All route paths mu
561
537
  ### Basic Usage
562
538
 
563
539
  ```jsx
564
- import { Route, Link } from 'round-core';
540
+ import { Route, Link } from "round-core";
565
541
 
566
542
  export default function App() {
567
- return (
568
- <div>
569
- <nav>
570
- <Link href="/">Home</Link>
571
- <Link href="/about">About</Link>
572
- </nav>
573
-
574
- <Route route="/" title="Home" exact>
575
- <div>Welcome Home</div>
576
- </Route>
577
- <Route route="/about" title="About">
578
- <div>About Us Content</div>
579
- </Route>
580
- </div>
581
- );
543
+ return (
544
+ <div>
545
+ <nav>
546
+ <Link href="/">Home</Link>
547
+ <Link href="/about">About</Link>
548
+ </nav>
549
+
550
+ <Route route="/" title="Home" exact>
551
+ <div>Welcome Home</div>
552
+ </Route>
553
+ <Route route="/about" title="About">
554
+ <div>About Us Content</div>
555
+ </Route>
556
+ </div>
557
+ );
582
558
  }
583
559
  ```
584
560
 
@@ -591,33 +567,128 @@ Routes can be nested to create hierarchical layouts. Child routes automatically
591
567
 
592
568
  ```jsx
593
569
  <Route route="/dashboard" title="Dashboard">
594
- <h1>Dashboard Shell</h1>
570
+ <h1>Dashboard Shell</h1>
571
+
572
+ {/* This route matches /dashboard/profile */}
573
+ <Route route="/dashboard/profile">
574
+ <h2>User Profile</h2>
575
+ </Route>
576
+
577
+ {/* This route matches /dashboard/settings */}
578
+ <Route route="/dashboard/settings">
579
+ <h2>Settings</h2>
580
+ </Route>
581
+ </Route>
582
+ ```
583
+
584
+ ### Routing Memoization (Keep-Alive)
585
+
586
+ 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.
587
+
588
+ When `memo` is set to `true`, the route's DOM nodes are hidden using `display: none` instead of being removed. This ensures that:
595
589
 
596
- {/* This route matches /dashboard/profile */}
597
- <Route route="/dashboard/profile">
598
- <h2>User Profile</h2>
599
- </Route>
590
+ - **Signals and local state** are preserved.
591
+ - **CSS Transitions and Animations** don't reset.
592
+ - **Form inputs** maintain their value and focus.
600
593
 
601
- {/* This route matches /dashboard/settings */}
602
- <Route route="/dashboard/settings">
603
- <h2>Settings</h2>
604
- </Route>
594
+ ```jsx
595
+ <Route route="/explore" memo={true}>
596
+ <ExploreFeed />
605
597
  </Route>
606
598
  ```
607
599
 
600
+ > [!TIP]
601
+ > 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.
602
+
608
603
  ## Suspense and lazy loading
609
604
 
610
605
  Round supports `Suspense` for promise-based rendering and `lazy()` for code splitting.
611
606
 
612
607
  ```jsx
613
- import { Suspense, lazy } from 'round-core';
614
- const LazyWidget = lazy(() => import('./Widget'));
608
+ import { Suspense, lazy } from "round-core";
609
+ const LazyWidget = lazy(() => import("./Widget"));
615
610
 
616
611
  <Suspense fallback={<div>Loading...</div>}>
617
- <LazyWidget />
618
- </Suspense>
612
+ <LazyWidget />
613
+ </Suspense>;
614
+ ```
615
+
616
+ ## Markdown rendering with `@round-core/markdown`
617
+
618
+ Round includes an optional companion package for markdown rendering with
619
+ syntax highlighting and rich code blocks.
620
+
621
+ Install it alongside `round-core`:
622
+
623
+ ```bash
624
+ npm install @round-core/markdown
619
625
  ```
620
626
 
627
+ or with Bun:
628
+
629
+ ```bash
630
+ bun add @round-core/markdown
631
+ ```
632
+
633
+ Then use it in a `.round` file or JSX component:
634
+
635
+ ```jsx
636
+ import { createElement } from "round-core";
637
+ import {
638
+ Markdown,
639
+ defaultMarkdownStyles as markdown,
640
+ } from "@round-core/markdown";
641
+ import "@round-core/markdown/styles.css";
642
+
643
+ export default function App() {
644
+ const content = `
645
+ # Markdown with code blocks
646
+
647
+ You can render fenced code blocks with syntax highlighting:
648
+
649
+ \`\`\`javascript
650
+ const value = signal(0);
651
+ \`\`\`
652
+ `;
653
+
654
+ return (
655
+ <div>
656
+ <Markdown content={content} />
657
+ </div>
658
+ );
659
+ }
660
+ ```
661
+
662
+ You can also theme the markdown surface (background/text) and accents via
663
+ `options.theme`:
664
+
665
+ ```jsx
666
+ <Markdown
667
+ content={content}
668
+ options={{
669
+ theme: {
670
+ // Dark card-like surface
671
+ markdownBackground: '#020617',
672
+ markdownText: '#e5e7eb',
673
+ primaryColor: '#e5e7eb',
674
+ secondaryColor: '#38bdf8',
675
+ },
676
+ }}
677
+ />
678
+
679
+ <Markdown
680
+ content={content}
681
+ options={{
682
+ theme: {
683
+ // Light mode
684
+ markdownBackground: '#ffffff',
685
+ markdownText: '#0f172a',
686
+ primaryColor: '#0f172a',
687
+ secondaryColor: '#2563eb',
688
+ },
689
+ }}
690
+ />
691
+ ```
621
692
 
622
693
  ## Error handling
623
694
 
@@ -641,13 +712,6 @@ The CLI is intended for day-to-day development:
641
712
 
642
713
  Run `round -h` to see available commands.
643
714
 
644
- ## Performance
645
-
646
- RoundJS sits in a powerful "middle ground" of performance:
647
-
648
- - **vs React**: Round's fine-grained reactivity is **massively faster** (>30x in micro-benchmarks) than React's component-level reconciliation. DOM updates are surgical and don't require diffing a virtual tree.
649
- - **vs Preact Signals**: While highly optimized, RoundJS signals are currently slightly slower than Preact Signals (~10x difference in raw signal-to-signal updates), as Preact utilizes more aggressive internal optimizations. However, for most real-world applications, RoundJS provides more than enough performance.
650
-
651
715
  ## Status
652
716
 
653
717
  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.
package/package.json CHANGED
@@ -1,54 +1,57 @@
1
- {
2
- "name": "round-core",
3
- "version": "0.2.0",
4
- "description": "A lightweight frontend framework for SPA with signals and fine grained reactivity",
5
- "main": "./src/index.js",
6
- "types": "./src/index.d.ts",
7
- "readme": "README.md",
8
- "exports": {
9
- ".": {
10
- "types": "./src/index.d.ts",
11
- "default": "./src/index.js"
12
- },
13
- "./vite-plugin": {
14
- "types": "./src/compiler/vite-plugin.d.ts",
15
- "default": "./src/compiler/vite-plugin.js"
16
- }
17
- },
18
- "files": [
19
- "src",
20
- "README.md",
21
- "LICENSE"
22
- ],
23
- "type": "module",
24
- "icon": "Round.png",
25
- "repository": {
26
- "url": "https://github.com/ZtaMDev/RoundJS.git"
27
- },
28
- "bin": {
29
- "round": "./src/cli.js"
30
- },
31
- "scripts": {
32
- "dev": "node ./src/cli.js dev --config ./test/main/round.config.json",
33
- "build": "node ./src/cli.js build --config ./test/main/round.config.json",
34
- "bench": "bun run --cwd benchmarks bench",
35
- "bench:build": "bun run --cwd benchmarks bench:build",
36
- "bench:runtime": "bun run --cwd benchmarks bench:runtime"
37
- },
38
- "keywords": [
39
- "framework",
40
- "spa",
41
- "signals",
42
- "vite"
43
- ],
44
- "author": "Round Framework Team",
45
- "license": "MIT",
46
- "dependencies": {
47
- "vite": "^5.0.0"
48
- },
49
- "devDependencies": {
50
- "@types/node": "latest",
51
- "bun-types": "latest",
52
- "vitest": "^1.6.0"
53
- }
54
- }
1
+ {
2
+ "name": "round-core",
3
+ "version": "0.2.2",
4
+ "description": "A lightweight frontend framework for SPA with signals and fine grained reactivity",
5
+ "workspaces": [
6
+ "packages/*"
7
+ ],
8
+ "main": "./src/index.js",
9
+ "types": "./src/index.d.ts",
10
+ "readme": "README.md",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.d.ts",
14
+ "default": "./src/index.js"
15
+ },
16
+ "./vite-plugin": {
17
+ "types": "./src/compiler/vite-plugin.d.ts",
18
+ "default": "./src/compiler/vite-plugin.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "type": "module",
27
+ "icon": "Round.png",
28
+ "repository": {
29
+ "url": "https://github.com/ZtaMDev/RoundJS.git"
30
+ },
31
+ "bin": {
32
+ "round": "./src/cli.js"
33
+ },
34
+ "scripts": {
35
+ "bench": "bun run --cwd benchmarks bench",
36
+ "bench:build": "bun run --cwd benchmarks bench:build",
37
+ "bench:runtime": "bun run --cwd benchmarks bench:runtime"
38
+ },
39
+ "keywords": [
40
+ "framework",
41
+ "spa",
42
+ "signals",
43
+ "vite"
44
+ ],
45
+ "author": "Round Framework Team",
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "vite": "^5.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "latest",
52
+ "bun-types": "latest",
53
+ "eslint": "^9.39.2",
54
+ "prettier": "^3.7.4",
55
+ "vitest": "^1.6.0"
56
+ }
57
+ }
package/src/cli.js CHANGED
@@ -211,15 +211,33 @@ async function runInit({ name }) {
211
211
 
212
212
  const projectDir = path.resolve(process.cwd(), name);
213
213
  const srcDir = path.join(projectDir, 'src');
214
-
215
- ensureDir(srcDir);
216
-
214
+ const vscodeDir = path.join(projectDir, '.vscode');
215
+ const vscodeSettingsPath = path.join(vscodeDir, 'settings.json');
217
216
  const pkgPath = path.join(projectDir, 'package.json');
218
217
  const configPath = path.join(projectDir, 'round.config.json');
219
218
  const viteConfigPath = path.join(projectDir, 'vite.config.js');
219
+ const prettierConfigPath = path.join(projectDir, '.prettierrc');
220
+ const eslintConfigPath = path.join(projectDir, 'eslint.config.js');
220
221
  const appRoundPath = path.join(srcDir, 'app.round');
221
222
  const counterRoundPath = path.join(srcDir, 'counter.round');
222
223
 
224
+ ensureDir(srcDir);
225
+ ensureDir(vscodeDir);
226
+
227
+ writeFileIfMissing(vscodeSettingsPath, JSON.stringify({
228
+ "eslint.validate": [
229
+ "javascript",
230
+ "javascriptreact",
231
+ "round"
232
+ ],
233
+ "prettier.documentSelectors": [
234
+ "**/*.round"
235
+ ],
236
+ "[round]": {
237
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
238
+ }
239
+ }, null, 4) + '\n');
240
+
223
241
  writeFileIfMissing(pkgPath, JSON.stringify({
224
242
  name,
225
243
  private: true,
@@ -228,16 +246,71 @@ async function runInit({ name }) {
228
246
  scripts: {
229
247
  dev: 'round dev',
230
248
  build: 'round build',
231
- preview: 'round preview'
249
+ preview: 'round preview',
250
+ lint: 'eslint src',
251
+ format: 'prettier --write src'
232
252
  },
233
253
  dependencies: {
234
- 'round-core': '^0.1.9'
254
+ 'round-core': '^' + getRoundVersion(),
255
+ '@round-core/shared': '^1.0.0'
235
256
  },
236
257
  devDependencies: {
237
- vite: '^5.0.0'
258
+ '@round-core/lint': '^0.1.0',
259
+ '@round-core/prettier': '^0.1.0',
260
+ 'eslint': '^9.39.2',
261
+ 'espree': '^10.0.0',
262
+ 'globals': '^17.0.0',
263
+ 'prettier': '^3.7.4',
264
+ 'vite': '^5.0.0'
238
265
  }
239
266
  }, null, 4) + '\n');
240
267
 
268
+ writeFileIfMissing(prettierConfigPath, JSON.stringify({
269
+ plugins: ["@round-core/prettier"],
270
+ overrides: [
271
+ {
272
+ files: "*.round",
273
+ options: {
274
+ parser: "round"
275
+ }
276
+ }
277
+ ]
278
+ }, null, 4) + '\n');
279
+
280
+ const eslintConfigContent = `import lintPlugin from '@round-core/lint';
281
+ import globals from 'globals';
282
+ import * as espree from 'espree';
283
+
284
+ export default [
285
+ {
286
+ files: ["**/*.round"],
287
+ plugins: {
288
+ '@round-core/lint': lintPlugin
289
+ },
290
+ processor: lintPlugin.processors['.round'],
291
+ rules: {
292
+ "no-unused-vars": "warn",
293
+ "no-undef": "error"
294
+ },
295
+ languageOptions: {
296
+ parser: espree,
297
+ ecmaVersion: "latest",
298
+ sourceType: "module",
299
+ globals: {
300
+ ...globals.browser,
301
+ ...globals.node,
302
+ },
303
+ parserOptions: {
304
+ ecmaFeatures: {
305
+ jsx: true
306
+ }
307
+ }
308
+ }
309
+ }
310
+ ];
311
+ `;
312
+ writeFileIfMissing(eslintConfigPath, eslintConfigContent);
313
+
241
314
  writeFileIfMissing(configPath, JSON.stringify({
242
315
  mode,
243
316
  entry: './src/app.round',
package/src/index.d.ts CHANGED
@@ -282,8 +282,21 @@ export interface RouteProps {
282
282
  title?: string;
283
283
  /** Meta description to set in the document header when active. */
284
284
  description?: string;
285
- /** Advanced head configuration including links and meta tags. */
286
- head?: any;
285
+ /** If true, the route stays rendered (hidden) when not active. Preserves state. */
286
+ memo?: boolean;
287
+ /** Meta tags to set in the document header when active. */
288
+ meta?: any;
289
+ /** Advanced head configuration including links and icons. */
290
+ head?: {
291
+ /** Title of the page. */
292
+ title?: string;
293
+ /** Links to include in the head. */
294
+ links?: string;
295
+ /** Icon to include in the head. */
296
+ icon?: string;
297
+ /** Favicon to include in the head. */
298
+ favicon?: string;
299
+ };
287
300
  /** Component or elements to render when matched. */
288
301
  children?: any;
289
302
  }
@@ -441,4 +454,15 @@ export function Suspense(props: SuspenseProps): any;
441
454
  /**
442
455
  * Defines static head metadata (titles, meta tags, etc.).
443
456
  */
444
- export function startHead(head: any): any;
457
+ export function startHead(head: any): any;
458
+
459
+
460
+ /**
461
+ * Raises an error to be handled by the runtime.
462
+ * Use cases:
463
+ * - Throwing errors in reactive functions
464
+ * - Throwing errors in try catch JSX statements
465
+ *
466
+ * @param error - The error to raise.
467
+ */
468
+ export function raise(error: any): never;
package/src/index.js CHANGED
@@ -14,7 +14,8 @@ import * as Router from './runtime/router.js';
14
14
  import * as Suspense from './runtime/suspense.js';
15
15
  import * as Context from './runtime/context.js';
16
16
  import * as Store from './runtime/store.js';
17
-
17
+ import { raise } from './runtime/errors.js';
18
+ export { raise } from './runtime/errors.js';
18
19
  export function render(Component, container) {
19
20
  Lifecycle.initLifecycleRoot(container);
20
21
  // Errors are no longer handled by a global overlay.
@@ -31,5 +32,6 @@ export default {
31
32
  ...Suspense,
32
33
  ...Context,
33
34
  ...Store,
34
- render
35
+ render,
36
+ raise
35
37
  };
@@ -67,8 +67,11 @@ export function createElement(tag, props = {}, ...children) {
67
67
  }
68
68
 
69
69
  if (node instanceof Node) {
70
- node._componentInstance = componentInstance;
71
- componentInstance.nodes.push(node);
70
+ // Only set the instance if not already present (supports memoization/persistence)
71
+ if (!node._componentInstance) {
72
+ node._componentInstance = componentInstance;
73
+ componentInstance.nodes.push(node);
74
+ }
72
75
 
73
76
  componentInstance.mountTimerId = setTimeout(() => {
74
77
  componentInstance.mountTimerId = null;
@@ -150,3 +150,7 @@ export function initErrorHandling(container) {
150
150
  });
151
151
  }
152
152
  }
153
+
154
+ export function raise(error) {
155
+ throw error;
156
+ }
@@ -116,6 +116,7 @@ const observer = (typeof MutationObserver !== 'undefined')
116
116
  mutations.forEach(mutation => {
117
117
  if (mutation.removedNodes.length > 0) {
118
118
  mutation.removedNodes.forEach(node => {
119
+ if (node._roundKeepAlive) return;
119
120
  if (node._componentInstance) {
120
121
  unmountComponent(node._componentInstance);
121
122
  }
@@ -127,6 +128,7 @@ const observer = (typeof MutationObserver !== 'undefined')
127
128
  : null;
128
129
 
129
130
  function cleanupNodeRecursively(node) {
131
+ if (node._roundKeepAlive) return;
130
132
  if (node._componentInstance) {
131
133
  unmountComponent(node._componentInstance);
132
134
  }
@@ -1,4 +1,4 @@
1
- import { signal, effect } from './signals.js';
1
+ import { signal, effect, batch } from './signals.js';
2
2
  import { createElement } from './dom.js';
3
3
  import { createContext, readContext } from './context.js';
4
4
 
@@ -26,6 +26,7 @@ let autoNotFoundMounted = false;
26
26
  let userProvidedNotFound = false;
27
27
 
28
28
  const RoutingContext = createContext('');
29
+ const routeMemoCache = new Map();
29
30
 
30
31
  function ensureListener() {
31
32
  if (!hasWindow || listenerInitialized) return;
@@ -99,19 +100,31 @@ export function useRouteReady() {
99
100
  export function getIsNotFound() {
100
101
  const pathname = normalizePathname(currentPath());
101
102
  if (pathname === '/') return false;
102
- return !Boolean(pathHasMatch());
103
+ if (pathHasMatch()) return false;
104
+ if (!pathEvalReady()) return false;
105
+ return true;
103
106
  }
104
107
 
105
108
  export function useIsNotFound() {
106
109
  return () => {
107
110
  const pathname = normalizePathname(currentPath());
108
111
  if (pathname === '/') return false;
109
- // Mirror getIsNotFound: react immediately to the current path and
110
- // the latest match flag, instead of waiting for pathEvalReady.
111
- return !Boolean(pathHasMatch());
112
+ if (pathHasMatch()) return false;
113
+ if (!pathEvalReady()) return false;
114
+ return true;
112
115
  };
113
116
  }
114
117
 
118
+ // Global effect to trigger path evaluation whenever the path changes.
119
+ // This ensures that pathEvalReady and pathHasMatch are managed centrally.
120
+ effect(() => {
121
+ const pathname = normalizePathname(currentPath());
122
+ beginPathEvaluation(pathname);
123
+ }, { onLoad: false }); // We run it manually once below
124
+
125
+ // Initialize synchronous evaluation for the starting path.
126
+ beginPathEvaluation(normalizePathname(currentPath.peek()));
127
+
115
128
  function mountAutoNotFound() {
116
129
  if (!hasWindow || autoNotFoundMounted) return;
117
130
  autoNotFoundMounted = true;
@@ -290,11 +303,13 @@ function matchRoute(route, pathname, exact = true) {
290
303
 
291
304
  function beginPathEvaluation(pathname) {
292
305
  if (pathname !== lastPathEvaluated) {
293
- lastPathEvaluated = pathname;
294
- hasMatchForPath = false;
295
- pathHasMatch(false);
306
+ batch(() => {
307
+ lastPathEvaluated = pathname;
308
+ hasMatchForPath = false;
309
+ pathEvalReady(false);
310
+ pathHasMatch(false);
311
+ });
296
312
 
297
- pathEvalReady(false);
298
313
  setTimeout(() => {
299
314
  if (lastPathEvaluated !== pathname) return;
300
315
  pathEvalReady(true);
@@ -314,6 +329,11 @@ export function setNotFound(Component) {
314
329
  * @param {string} [props.title] Page title to set when active.
315
330
  * @param {string} [props.description] Meta description to set when active.
316
331
  * @param {any} [props.children] Content to render.
332
+ * @param {object} [props.head] Advanced head configuration including links and meta tags.
333
+ * @param {string} [props.head.title] Title of the page.
334
+ * @param {string} [props.head.links] Links to include in the head.
335
+ * @param {string} [props.head.icon] Icon to include in the head.
336
+ * @param {string} [props.head.favicon] Favicon to include in the head.
317
337
  */
318
338
  export function Route(props = {}) {
319
339
  ensureListener();
@@ -321,7 +341,6 @@ export function Route(props = {}) {
321
341
  return createElement('span', { style: { display: 'contents' } }, () => {
322
342
  const parentPath = readContext(RoutingContext) || '';
323
343
  const pathname = normalizePathname(currentPath());
324
- beginPathEvaluation(pathname);
325
344
 
326
345
  const routeProp = props.route ?? '/';
327
346
  if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
@@ -344,9 +363,45 @@ export function Route(props = {}) {
344
363
 
345
364
  const isRoot = fullRoute === '/';
346
365
  const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
366
+ const isMatch = matchRoute(fullRoute, pathname, exact);
367
+
368
+ if (props.memo) {
369
+ if (isMatch) {
370
+ if (matchRoute(fullRoute, pathname, true)) {
371
+ hasMatchForPath = true;
372
+ pathHasMatch(true);
373
+ }
374
+
375
+ const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
376
+ const meta = props.description
377
+ ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
378
+ : (mergedHead.meta ?? props.meta);
379
+ const links = mergedHead.links ?? props.links;
380
+ const title = mergedHead.title ?? props.title;
381
+ const icon = mergedHead.icon ?? props.icon;
382
+ const favicon = mergedHead.favicon ?? props.favicon;
383
+
384
+ applyHead({ title, meta, links, icon, favicon });
385
+ }
386
+
387
+ if (routeMemoCache.has(fullRoute)) {
388
+ const cached = routeMemoCache.get(fullRoute);
389
+ cached.style.display = isMatch ? 'contents' : 'none';
390
+ return cached;
391
+ }
392
+
393
+ if (!isMatch) return null;
394
+
395
+ const memoContainer = createElement('div', { style: { display: 'contents' } },
396
+ createElement(RoutingContext.Provider, { value: fullRoute }, props.children)
397
+ );
398
+ memoContainer._roundKeepAlive = true;
399
+ routeMemoCache.set(fullRoute, memoContainer);
400
+ return memoContainer;
401
+ }
347
402
 
348
403
  // For nested routing, we match as a prefix so parents stay rendered while children are active
349
- if (!matchRoute(fullRoute, pathname, exact)) return null;
404
+ if (!isMatch) return null;
350
405
 
351
406
  // If it's an exact match of the FULL segments, mark as matched for 404 purposes
352
407
  if (matchRoute(fullRoute, pathname, true)) {
@@ -375,55 +430,7 @@ export function Route(props = {}) {
375
430
  * @param {object} props Page properties (same as Route).
376
431
  */
377
432
  export function Page(props = {}) {
378
- ensureListener();
379
-
380
- return createElement('span', { style: { display: 'contents' } }, () => {
381
- const parentPath = readContext(RoutingContext) || '';
382
- const pathname = normalizePathname(currentPath());
383
- beginPathEvaluation(pathname);
384
-
385
- const routeProp = props.route ?? '/';
386
- if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
387
- throw new Error(`Invalid route: "${routeProp}". All routes must start with a forward slash "/". (Nested under: "${parentPath || 'root'}")`);
388
- }
389
-
390
- let fullRoute = '';
391
- if (parentPath && parentPath !== '/') {
392
- const cleanParent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
393
- const cleanChild = routeProp.startsWith('/') ? routeProp : '/' + routeProp;
394
-
395
- if (cleanChild.startsWith(cleanParent + '/') || cleanChild === cleanParent) {
396
- fullRoute = normalizePathname(cleanChild);
397
- } else {
398
- fullRoute = normalizePathname(cleanParent + cleanChild);
399
- }
400
- } else {
401
- fullRoute = normalizePathname(routeProp);
402
- }
403
-
404
- const isRoot = fullRoute === '/';
405
- const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
406
-
407
- if (!matchRoute(fullRoute, pathname, exact)) return null;
408
-
409
- if (matchRoute(fullRoute, pathname, true)) {
410
- hasMatchForPath = true;
411
- pathHasMatch(true);
412
- }
413
-
414
- const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
415
- const meta = props.description
416
- ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
417
- : (mergedHead.meta ?? props.meta);
418
- const links = mergedHead.links ?? props.links;
419
- const title = mergedHead.title ?? props.title;
420
- const icon = mergedHead.icon ?? props.icon;
421
- const favicon = mergedHead.favicon ?? props.favicon;
422
-
423
- applyHead({ title, meta, links, icon, favicon });
424
-
425
- return createElement(RoutingContext.Provider, { value: fullRoute }, props.children);
426
- });
433
+ return Route(props);
427
434
  }
428
435
 
429
436
  /**
@@ -436,7 +443,6 @@ export function NotFound(props = {}) {
436
443
 
437
444
  return createElement('span', { style: { display: 'contents' } }, () => {
438
445
  const pathname = normalizePathname(currentPath());
439
- beginPathEvaluation(pathname);
440
446
 
441
447
  const ready = pathEvalReady();
442
448
  const hasMatch = pathHasMatch();