round-core 0.1.3 → 0.1.5

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
@@ -73,9 +73,19 @@ A **signal** is a small reactive container.
73
73
  Round includes a CLI with a project initializer.
74
74
 
75
75
  ```bash
76
+ # Install the CLI
77
+ npm install round
78
+
79
+ # Create a new app
76
80
  round init myapp
81
+
82
+ # Navigate to the app directory
77
83
  cd myapp
84
+
85
+ # Install dependencies
78
86
  npm install
87
+
88
+ # Run the app
79
89
  npm run dev
80
90
  ```
81
91
 
@@ -163,6 +173,39 @@ effect(() => {
163
173
  name('Grace');
164
174
  ```
165
175
 
176
+ ### `asyncSignal(fetcher)`
177
+
178
+ Create a signal that manages asynchronous data fetching.
179
+
180
+ - It returns a signal that resolves to the data once fetched.
181
+ - **`.pending`**: A reactive signal (boolean) indicating if the fetch is in progress.
182
+ - **`.error`**: A reactive signal containing any error that occurred during fetching.
183
+ - **`.refetch()`**: A method to manually trigger a re-fetch.
184
+
185
+ ```jsx
186
+ import { asyncSignal } from 'round-core';
187
+
188
+ const user = asyncSignal(async () => {
189
+ const res = await fetch('/api/user');
190
+ return res.json();
191
+ });
192
+
193
+ export function UserProfile() {
194
+ return (
195
+ <div>
196
+ {if(user.pending()){
197
+ <div>Loading...</div>
198
+ } else if(user.error()){
199
+ <div>Error: {user.error().message}</div>
200
+ } else {
201
+ <div>Welcome, {user().name}</div>
202
+ }}
203
+ <button onClick={() => user.refetch()}>Reload</button>
204
+ </div>
205
+ );
206
+ }
207
+ ```
208
+
166
209
  ### `untrack(fn)`
167
210
 
168
211
  Run a function without tracking any signals it reads.
@@ -364,12 +407,16 @@ Notes:
364
407
  ### `for (... in ...)`
365
408
 
366
409
  ```jsx
367
- {for(item in items){
368
- <div className="row">{item}</div>
410
+ {for(item in items()) key=item.id {
411
+ <div className="row">{item.name}</div>
369
412
  }}
370
413
  ```
371
414
 
372
- This compiles roughly to a `.map(...)` under the hood.
415
+ This compiles to efficient **keyed reconciliation** using the `ForKeyed` runtime component.
416
+
417
+ #### Keyed vs Unkeyed
418
+ - **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).
419
+ - **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.
373
420
 
374
421
  ### `switch`
375
422
 
@@ -385,6 +432,25 @@ Notes:
385
432
  - The `switch` expression is automatically wrapped in a reactive tracker, ensuring that the view updates surgically when the condition (e.g., a signal) changes.
386
433
  - Each case handles its own rendering without re-running the parent component.
387
434
 
435
+ ### `try / catch`
436
+
437
+ Round supports both static and **reactive** `try/catch` blocks inside JSX.
438
+
439
+ - **Static**: Just like standard JS, but renders fragments.
440
+ - **Reactive**: By passing a signal to `try(signal)`, the block will **automatically re-run** if the signal (or its dependencies) update. This is perfect for handling transient errors in async data.
441
+
442
+ ```jsx
443
+ {try(user()) {
444
+ {if(user() && user().name) {
445
+ <div>Hello {user().name}</div>
446
+ } else if(user.pending()) {
447
+ <div>⏳ Loading...</div>
448
+ }}
449
+ } catch(e) {
450
+ <div className="error"> Failed to load user: {e.message} </div>
451
+ }}
452
+ ```
453
+
388
454
  ## Routing
389
455
 
390
456
  Round includes router primitives intended for SPA navigation. All route paths must start with a forward slash `/`.
@@ -452,26 +518,14 @@ const LazyWidget = lazy(() => import('./Widget'));
452
518
 
453
519
  ## Error handling
454
520
 
455
- Round aims to provide strong developer feedback:
456
-
457
- - Runtime error reporting with safe boundaries.
458
- - `ErrorBoundary` to catch render-time errors and show a fallback.
459
-
460
- ### `ErrorBoundary`
461
-
462
- Wrap components to prevent the whole app from crashing if a child fails to render.
521
+ Round JS favors individual error control and standard browser debugging:
463
522
 
464
- ```jsx
465
- import { ErrorBoundary } from 'round-core';
466
-
467
- function DangerousComponent() {
468
- throw new Error('Boom!');
469
- }
523
+ 1. **Explict `try/catch`**: Use the JSX `try/catch` syntax to handle local component failures gracefully.
524
+ 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.
525
+ 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.
470
526
 
471
- <ErrorBoundary fallback={(err) => <div className="error">Something went wrong: {err.message}</div>}>
472
- <DangerousComponent />
473
- </ErrorBoundary>
474
- ```
527
+ Example of a descriptive console log:
528
+ `[round] Error in phase "component.render" of component <UserProfile />: TypeError: Cannot read property 'avatar' of undefined`
475
529
 
476
530
  ## CLI
477
531
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "round-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A lightweight frontend framework for SPA with signals and fine grained reactivity",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -202,6 +202,29 @@ export function transform(code, initialDepth = 0) {
202
202
  const list = inMatch[2].trim();
203
203
 
204
204
  ptr = consumeWhitespace(code, condRes.end);
205
+
206
+ // --- KEY PARSING ---
207
+ let keyExpr = null;
208
+ if (code.startsWith('key', ptr)) {
209
+ let kPtr = consumeWhitespace(code, ptr + 3);
210
+ if (code[kPtr] === '=') {
211
+ kPtr = consumeWhitespace(code, kPtr + 1);
212
+ if (code[kPtr] === '{') {
213
+ const keyBlock = parseBlock(code, kPtr);
214
+ if (keyBlock) {
215
+ keyExpr = code.substring(keyBlock.start + 1, keyBlock.end);
216
+ ptr = consumeWhitespace(code, keyBlock.end + 1);
217
+ }
218
+ } else {
219
+ // Bare key: parse until whitespace or {
220
+ let start = kPtr;
221
+ while (kPtr < code.length && !/\s/.test(code[kPtr]) && code[kPtr] !== '{') kPtr++;
222
+ keyExpr = code.substring(start, kPtr);
223
+ ptr = consumeWhitespace(code, kPtr);
224
+ }
225
+ }
226
+ }
227
+
205
228
  if (code[ptr] !== '{') return null;
206
229
 
207
230
  const block = parseBlock(code, ptr);
@@ -217,7 +240,12 @@ export function transform(code, initialDepth = 0) {
217
240
  endIdx++;
218
241
  }
219
242
 
220
- const replacement = `{(() => ${list}.map(${item} => (<Fragment>${transformedContent}</Fragment>)))}`;
243
+ let replacement;
244
+ if (keyExpr) {
245
+ replacement = `{createElement(ForKeyed, { each: () => ${list}, key: (${item}) => ${keyExpr} }, (${item}) => (<Fragment>${transformedContent}</Fragment>))}`;
246
+ } else {
247
+ replacement = `{(() => ${list}.map(${item} => (<Fragment>${transformedContent}</Fragment>)))}`;
248
+ }
221
249
  return { end: endIdx, replacement };
222
250
  }
223
251
 
@@ -262,6 +290,81 @@ export function transform(code, initialDepth = 0) {
262
290
  return { end: endIdx, replacement };
263
291
  }
264
292
 
293
+ function handleTry(currI, isBare = false) {
294
+ let ptr = currI;
295
+ if (!isBare) ptr = consumeWhitespace(code, currI + 1);
296
+
297
+ if (!code.startsWith('try', ptr)) return null;
298
+ ptr += 3;
299
+ ptr = consumeWhitespace(code, ptr);
300
+
301
+ // Check for reactive try: try(expr) {...}
302
+ let reactiveExpr = null;
303
+ if (code[ptr] === '(') {
304
+ const condRes = extractCondition(code, ptr);
305
+ if (condRes) {
306
+ reactiveExpr = condRes.cond;
307
+ ptr = consumeWhitespace(code, condRes.end);
308
+ }
309
+ }
310
+
311
+ // Must have opening brace for try block
312
+ if (code[ptr] !== '{') return null;
313
+
314
+ const tryBlock = parseBlock(code, ptr);
315
+ if (!tryBlock) return null;
316
+
317
+ const tryContent = code.substring(tryBlock.start + 1, tryBlock.end);
318
+ const transformedTry = transform(tryContent, 1);
319
+
320
+ ptr = tryBlock.end + 1;
321
+ ptr = consumeWhitespace(code, ptr);
322
+
323
+ // Must have catch
324
+ if (!code.startsWith('catch', ptr)) return null;
325
+ ptr += 5;
326
+ ptr = consumeWhitespace(code, ptr);
327
+
328
+ // Extract catch parameter (e) or (err)
329
+ let catchParam = 'e';
330
+ if (code[ptr] === '(') {
331
+ const catchCondRes = extractCondition(code, ptr);
332
+ if (catchCondRes) {
333
+ catchParam = catchCondRes.cond.trim() || 'e';
334
+ ptr = consumeWhitespace(code, catchCondRes.end);
335
+ }
336
+ }
337
+
338
+ // Must have catch block
339
+ if (code[ptr] !== '{') return null;
340
+
341
+ const catchBlock = parseBlock(code, ptr);
342
+ if (!catchBlock) return null;
343
+
344
+ const catchContent = code.substring(catchBlock.start + 1, catchBlock.end);
345
+ const transformedCatch = transform(catchContent, 1);
346
+
347
+ let endIdx = catchBlock.end + 1;
348
+
349
+ // If not bare, consume closing '}'
350
+ if (!isBare) {
351
+ endIdx = consumeWhitespace(code, endIdx);
352
+ if (code[endIdx] !== '}') return null;
353
+ endIdx++;
354
+ }
355
+
356
+ let replacement;
357
+ if (reactiveExpr) {
358
+ // Reactive try: return a THUNK (function) so dom.js handles it as a reactive child (effect)
359
+ replacement = `{() => { try { ${reactiveExpr}; return (<Fragment>${transformedTry}</Fragment>); } catch(${catchParam}) { return (<Fragment>${transformedCatch}</Fragment>); } }}`;
360
+ } else {
361
+ // Static try: simple IIFE
362
+ replacement = `{(() => { try { return (<Fragment>${transformedTry}</Fragment>); } catch(${catchParam}) { return (<Fragment>${transformedCatch}</Fragment>); } })()}`;
363
+ }
364
+
365
+ return { end: endIdx, replacement };
366
+ }
367
+
265
368
  // --- Main Parser Loop ---
266
369
 
267
370
  let inSingle = false, inDouble = false, inTemplate = false;
@@ -397,6 +500,9 @@ export function transform(code, initialDepth = 0) {
397
500
  } else if (code.startsWith('switch', ptr)) {
398
501
  const res = handleSwitch(i, false);
399
502
  if (res) { result += res.replacement; i = res.end; processed = true; }
503
+ } else if (code.startsWith('try', ptr)) {
504
+ const res = handleTry(i, false);
505
+ if (res) { result += res.replacement; i = res.end; processed = true; }
400
506
  }
401
507
  }
402
508
 
@@ -421,6 +527,13 @@ export function transform(code, initialDepth = 0) {
421
527
  const res = handleSwitch(i, true);
422
528
  if (res) { result += res.replacement; i = res.end; processed = true; }
423
529
  }
530
+ } else if (ch === 't' && code.startsWith('try', i)) {
531
+ // Bare try: try { ... } catch { ... } or try(expr) { ... } catch { ... }
532
+ let ptr = consumeWhitespace(code, i + 3);
533
+ if (code[ptr] === '{' || code[ptr] === '(') {
534
+ const res = handleTry(i, true);
535
+ if (res) { result += res.replacement; i = res.end; processed = true; }
536
+ }
424
537
  }
425
538
 
426
539
  if (processed) continue;
@@ -458,8 +458,8 @@ export default function RoundPlugin(pluginOptions = {}) {
458
458
 
459
459
  let transformedCode = transform(nextCode);
460
460
 
461
- if (!/^\s*import\s+\{\s*createElement\s*,\s*Fragment\s*\}\s+from\s+['"][^'"]+['"];?/m.test(transformedCode)) {
462
- transformedCode = `import { createElement, Fragment } from '${runtimeImport}';\n` + transformedCode;
461
+ if (!/^\s*import\s+\{\s*createElement\s*,\s*Fragment\s*,\s*ForKeyed\s*\}\s+from\s+['"][^'"]+['"];?/m.test(transformedCode)) {
462
+ transformedCode = `import { createElement, Fragment, ForKeyed } from '${runtimeImport}';\n` + transformedCode;
463
463
  }
464
464
 
465
465
  return {
package/src/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface RoundSignal<T> {
6
6
  /**
7
7
  * Get or set the current value.
8
8
  */
9
- (newValue?: T): T;
9
+ (newValue?: T | ((prev: T) => T)): T;
10
10
 
11
11
  /**
12
12
  * Get the current value (reactive).
@@ -101,6 +101,92 @@ export function effect(deps: any[], fn: () => void | (() => void), options?: {
101
101
  */
102
102
  export function derive<T>(fn: () => T): () => T;
103
103
 
104
+ /**
105
+ * Async signal that loads data from an async function.
106
+ * Provides reactive pending/error states and refetch capability.
107
+ */
108
+ export interface AsyncSignal<T> {
109
+ /**
110
+ * Get the current resolved value, or set a new value.
111
+ * Returns undefined while pending or if an error occurred.
112
+ */
113
+ (newValue?: T): T | undefined;
114
+
115
+ /**
116
+ * Get the current value (reactive).
117
+ */
118
+ value: T | undefined;
119
+
120
+ /**
121
+ * Get the current value without tracking dependencies.
122
+ */
123
+ peek(): T | undefined;
124
+
125
+ /**
126
+ * Signal indicating whether the async function is currently executing.
127
+ * @example
128
+ * if (user.pending()) {
129
+ * return <Spinner />;
130
+ * }
131
+ */
132
+ pending: RoundSignal<boolean>;
133
+
134
+ /**
135
+ * Signal containing the error if the async function rejected.
136
+ * Returns null if no error occurred.
137
+ * @example
138
+ * if (user.error()) {
139
+ * return <div>Error: {user.error().message}</div>;
140
+ * }
141
+ */
142
+ error: RoundSignal<Error | null>;
143
+
144
+ /**
145
+ * Re-execute the async function to refresh the data.
146
+ * Resets pending to true and clears any previous error.
147
+ * @returns Promise that resolves to the new value
148
+ * @example
149
+ * <button onClick={() => user.refetch()}>Refresh</button>
150
+ */
151
+ refetch(): Promise<T | undefined>;
152
+ }
153
+
154
+ /**
155
+ * Options for asyncSignal.
156
+ */
157
+ export interface AsyncSignalOptions {
158
+ /**
159
+ * If true (default), executes the async function immediately on creation.
160
+ * If false, you must call refetch() to start loading.
161
+ */
162
+ immediate?: boolean;
163
+ }
164
+
165
+ /**
166
+ * Creates an async signal that loads data from an async function.
167
+ * The signal provides reactive pending and error states, plus a refetch method.
168
+ *
169
+ * @param asyncFn - Async function that returns a promise
170
+ * @param options - Configuration options
171
+ * @returns AsyncSignal with pending, error, and refetch properties
172
+ *
173
+ * @example
174
+ * const user = asyncSignal(() => fetch('/api/user').then(r => r.json()));
175
+ *
176
+ * // In component:
177
+ * {if(user.pending()) {
178
+ * <Spinner />
179
+ * } else if(user.error()) {
180
+ * <Error message={user.error().message} />
181
+ * } else {
182
+ * <Profile user={user()} />
183
+ * }}
184
+ *
185
+ * // Refetch on demand:
186
+ * <button onClick={() => user.refetch()}>Refresh</button>
187
+ */
188
+ export function asyncSignal<T>(asyncFn: () => Promise<T>, options?: AsyncSignalOptions): AsyncSignal<T>;
189
+
104
190
  /**
105
191
  * Creates a read/write view of a specific path within a signal object.
106
192
  */
@@ -295,6 +381,11 @@ export function createElement(tag: any, props?: any, ...children: any[]): any;
295
381
  */
296
382
  export function Fragment(props: { children?: any }): any;
297
383
 
384
+ /**
385
+ * A component for efficient keyed list reconciliation.
386
+ */
387
+ export function ForKeyed<T>(props: { each: T[] | (() => T[]), key: (item: T) => any, children: (item: T) => any }): any;
388
+
298
389
  export interface Context<T> {
299
390
  /** Internal identifier for the context. */
300
391
  id: number;
@@ -319,25 +410,6 @@ export function readContext<T>(ctx: Context<T>): T;
319
410
  */
320
411
  export function bindContext<T>(ctx: Context<T>): () => T;
321
412
 
322
- /**
323
- * Error Handling
324
- */
325
-
326
- export interface ErrorBoundaryProps {
327
- /** Content to render if an error occurs. Can be a signal or function. */
328
- fallback?: any | ((props: { error: any }) => any);
329
- /** Optional identifier for the boundary. */
330
- name?: string;
331
- /** Optional key that, when changed, resets the boundary error state. */
332
- resetKey?: any;
333
- /** Content that might throw an error. */
334
- children?: any;
335
- }
336
-
337
- /**
338
- * Component that catches runtime errors in its child tree and displays a fallback UI.
339
- */
340
- export function ErrorBoundary(props: ErrorBoundaryProps): any;
341
413
 
342
414
  /**
343
415
  * Async & Code Splitting
package/src/index.js CHANGED
@@ -2,31 +2,25 @@ export * from './runtime/signals.js';
2
2
  export * from './runtime/dom.js';
3
3
  export * from './runtime/lifecycle.js';
4
4
  export * from './runtime/router.js';
5
- export * from './runtime/errors.js';
6
- export * from './runtime/error-store.js';
7
- export * from './runtime/error-boundary.js';
8
5
  export * from './runtime/suspense.js';
9
6
  export * from './runtime/context.js';
10
7
  export * from './runtime/store.js';
8
+ export * from './runtime/keyed.js';
11
9
 
12
10
  import * as Signals from './runtime/signals.js';
13
11
  import * as DOM from './runtime/dom.js';
14
12
  import * as Lifecycle from './runtime/lifecycle.js';
15
13
  import * as Router from './runtime/router.js';
16
- import * as Errors from './runtime/errors.js';
17
14
  import * as Suspense from './runtime/suspense.js';
18
15
  import * as Context from './runtime/context.js';
19
16
  import * as Store from './runtime/store.js';
20
17
 
21
18
  export function render(Component, container) {
22
19
  Lifecycle.initLifecycleRoot(container);
23
- Errors.initErrorHandling(container);
24
- try {
25
- const root = DOM.createElement(Component);
26
- container.appendChild(root);
27
- } catch (e) {
28
- Errors.reportError(e, { phase: 'render', component: Component?.name ?? 'App' });
29
- }
20
+ // Errors are no longer handled by a global overlay.
21
+ // They propagate to the console or local try/catch.
22
+ const root = DOM.createElement(Component);
23
+ container.appendChild(root);
30
24
  }
31
25
 
32
26
  export default {
@@ -34,7 +28,6 @@ export default {
34
28
  ...DOM,
35
29
  ...Lifecycle,
36
30
  ...Router,
37
- ...Errors,
38
31
  ...Suspense,
39
32
  ...Context,
40
33
  ...Store,
@@ -0,0 +1,63 @@
1
+
2
+ let nextContextId = 1;
3
+ export const contextStack = [];
4
+
5
+ /**
6
+ * Internal helper to push context layers.
7
+ */
8
+ export function pushContext(values) {
9
+ contextStack.push(values);
10
+ }
11
+
12
+ /**
13
+ * Internal helper to pop context layers.
14
+ */
15
+ export function popContext() {
16
+ contextStack.pop();
17
+ }
18
+
19
+ /**
20
+ * Capture current context stack.
21
+ */
22
+ export function captureContext() {
23
+ return contextStack.slice();
24
+ }
25
+
26
+ /**
27
+ * Read context value from the stack.
28
+ */
29
+ export function readContext(ctx) {
30
+ for (let i = contextStack.length - 1; i >= 0; i--) {
31
+ const layer = contextStack[i];
32
+ if (layer && Object.prototype.hasOwnProperty.call(layer, ctx.id)) {
33
+ return layer[ctx.id];
34
+ }
35
+ }
36
+ return ctx.defaultValue;
37
+ }
38
+
39
+ /**
40
+ * Generate a new context ID.
41
+ */
42
+ export function generateContextId() {
43
+ return nextContextId++;
44
+ }
45
+
46
+
47
+ export const SuspenseContext = {
48
+ id: generateContextId(),
49
+ defaultValue: null,
50
+ Provider: null
51
+ };
52
+
53
+ export function runInContext(snapshot, fn) {
54
+ const prev = contextStack.slice();
55
+ contextStack.length = 0;
56
+ contextStack.push(...snapshot);
57
+ try {
58
+ return fn();
59
+ } finally {
60
+ contextStack.length = 0;
61
+ contextStack.push(...prev);
62
+ }
63
+ }
@@ -1,62 +1,29 @@
1
- import { createElement } from './dom.js';
1
+ import { pushContext, popContext, contextStack, generateContextId, readContext, SuspenseContext, runInContext } from './context-shared.js';
2
+ import { createElement, Fragment } from './dom.js';
2
3
 
3
- let nextContextId = 1;
4
- const contextStack = [];
5
-
6
- function pushContext(values) {
7
- contextStack.push(values);
8
- }
9
-
10
- function popContext() {
11
- contextStack.pop();
12
- }
4
+ export { pushContext, popContext, contextStack, generateContextId, readContext, SuspenseContext, runInContext };
13
5
 
14
6
  /**
15
- * Read the current value of a context from the tree.
16
- * @template T
17
- * @param {Context<T>} ctx The context object.
18
- * @returns {T} The current context value.
7
+ * Internal logic to create a Provider component for a context.
19
8
  */
20
- export function readContext(ctx) {
21
- for (let i = contextStack.length - 1; i >= 0; i--) {
22
- const layer = contextStack[i];
23
- if (layer && Object.prototype.hasOwnProperty.call(layer, ctx.id)) {
24
- return layer[ctx.id];
25
- }
26
- }
27
- return ctx.defaultValue;
28
- }
29
-
30
- /**
31
- * Create a new Context object for sharing state between components.
32
- * @template T
33
- * @param {T} [defaultValue] The value used when no provider is found.
34
- * @returns {Context<T>} The context object with a `Provider` component.
35
- */
36
- export function createContext(defaultValue) {
37
- const ctx = {
38
- id: nextContextId++,
39
- defaultValue,
40
- Provider: null
41
- };
42
-
43
- function Provider(props = {}) {
9
+ function createProvider(ctx) {
10
+ const Provider = function Provider(props = {}) {
44
11
  const children = props.children;
12
+ const value = props.value;
45
13
 
46
14
  // Push context now so that any createElement/appendChild called
47
15
  // during the instantiation of this Provider branch picks it up immediately.
48
- pushContext({ [ctx.id]: props.value });
16
+ pushContext({ [ctx.id]: value });
49
17
  try {
50
18
  // We use a span to handle reactive value updates and dynamic children.
51
19
  return createElement('span', { style: { display: 'contents' } }, () => {
52
20
  // Read current value (reactive if it's a signal)
53
- const val = (typeof props.value === 'function' && props.value.peek) ? props.value() : props.value;
21
+ const val = (typeof value === 'function' && value.peek) ? value() : value;
54
22
 
55
- // Push it during the effect run too! This ensures that anything returned
56
- // from this callback (which might trigger more appendChild calls) sees the context.
23
+ // Push it during the effect run too!
57
24
  pushContext({ [ctx.id]: val });
58
25
  try {
59
- return children;
26
+ return typeof children === 'function' ? children() : children;
60
27
  } finally {
61
28
  popContext();
62
29
  }
@@ -64,12 +31,26 @@ export function createContext(defaultValue) {
64
31
  } finally {
65
32
  popContext();
66
33
  }
67
- }
34
+ };
35
+ return Provider;
36
+ }
68
37
 
69
- ctx.Provider = Provider;
38
+ /**
39
+ * Create a new Context object for sharing state between components.
40
+ */
41
+ export function createContext(defaultValue) {
42
+ const ctx = {
43
+ id: generateContextId(),
44
+ defaultValue,
45
+ Provider: null
46
+ };
47
+ ctx.Provider = createProvider(ctx);
70
48
  return ctx;
71
49
  }
72
50
 
51
+ // Attach providers to built-in shared contexts
52
+ SuspenseContext.Provider = createProvider(SuspenseContext);
53
+
73
54
  export function bindContext(ctx) {
74
55
  return () => {
75
56
  const provided = readContext(ctx);
@@ -87,15 +68,3 @@ export function bindContext(ctx) {
87
68
  export function captureContext() {
88
69
  return contextStack.slice();
89
70
  }
90
-
91
- export function runInContext(snapshot, fn) {
92
- const prev = contextStack.slice();
93
- contextStack.length = 0;
94
- contextStack.push(...snapshot);
95
- try {
96
- return fn();
97
- } finally {
98
- contextStack.length = 0;
99
- contextStack.push(...prev);
100
- }
101
- }
@@ -1,8 +1,7 @@
1
1
  import { effect, untrack } from './signals.js';
2
2
  import { runInLifecycle, createComponentInstance, mountComponent, initLifecycleRoot } from './lifecycle.js';
3
3
  import { reportErrorSafe } from './error-reporter.js';
4
- import { captureContext, runInContext, readContext } from './context.js';
5
- import { SuspenseContext } from './suspense.js';
4
+ import { captureContext, runInContext, readContext, SuspenseContext } from './context-shared.js';
6
5
 
7
6
 
8
7
  const warnedSignals = new Set();
@@ -54,8 +53,9 @@ export function createElement(tag, props = {}, ...children) {
54
53
  }
55
54
  throw e;
56
55
  }
56
+
57
57
  reportErrorSafe(e, { phase: 'component.render', component: componentName });
58
- return createElement('div', { style: { padding: '16px' } }, `Error in ${componentName}`);
58
+ throw e;
59
59
  }
60
60
  });
61
61
 
@@ -341,8 +341,9 @@ function appendChild(parent, child) {
341
341
  }
342
342
  throw new Error("cannot instance a lazy component outside a suspense");
343
343
  }
344
+
344
345
  reportErrorSafe(e, { phase: 'child.dynamic' });
345
- val = createElement('div', { style: { padding: '16px' } }, 'Error');
346
+ throw e;
346
347
  }
347
348
 
348
349
  if (Array.isArray(val)) {
@@ -1,3 +1,4 @@
1
+
1
2
  let reporter = null;
2
3
 
3
4
  export function setErrorReporter(fn) {
@@ -5,9 +6,16 @@ export function setErrorReporter(fn) {
5
6
  }
6
7
 
7
8
  export function reportErrorSafe(error, info) {
8
- if (!reporter) return;
9
- try {
10
- reporter(error, info);
11
- } catch {
9
+ if (reporter) {
10
+ try {
11
+ reporter(error, info);
12
+ return;
13
+ } catch {
14
+ }
12
15
  }
16
+
17
+ // Default: Descriptive console logging
18
+ const phase = info?.phase ? ` in phase "${info.phase}"` : "";
19
+ const component = info?.component ? ` of component <${info.component} />` : "";
20
+ console.error(`[round] Error${phase}${component}:`, error);
13
21
  }
@@ -0,0 +1,83 @@
1
+ import { effect } from './signals.js';
2
+
3
+ /**
4
+ * ForKeyed Component
5
+ * Performs keyed reconciliation for lists to maintain DOM identity.
6
+ *
7
+ * @template T
8
+ * @param {{
9
+ * each: T[] | (() => T[]),
10
+ * key: (item: T) => any,
11
+ * children: (item: T) => any
12
+ * }} props
13
+ */
14
+ export function ForKeyed(props) {
15
+ const { each, key: keyFn, children } = props;
16
+ const renderFn = Array.isArray(children) ? children[0] : children;
17
+
18
+ if (typeof renderFn !== 'function') {
19
+ return null;
20
+ }
21
+
22
+ const container = document.createElement('span');
23
+ container.style.display = 'contents';
24
+
25
+ // Map of key -> Node
26
+ const cache = new Map();
27
+
28
+ effect(() => {
29
+ const list = typeof each === 'function' ? each() : each;
30
+ const items = Array.isArray(list) ? list : [];
31
+
32
+ // 1. Generate new keys and nodes
33
+ const newNodes = items.map(item => {
34
+ const k = keyFn(item);
35
+ if (cache.has(k)) {
36
+ return cache.get(k);
37
+ }
38
+ // Create new node if key doesn't exist
39
+ let node = renderFn(item);
40
+
41
+ // Handle Fragments (Arrays)
42
+ if (Array.isArray(node)) {
43
+ if (node.length === 1) {
44
+ node = node[0];
45
+ } else {
46
+ const wrapper = document.createElement('span');
47
+ wrapper.style.display = 'contents';
48
+ node.forEach(n => {
49
+ if (n instanceof Node) wrapper.appendChild(n);
50
+ else wrapper.appendChild(document.createTextNode(String(n)));
51
+ });
52
+ node = wrapper;
53
+ }
54
+ }
55
+
56
+ cache.set(k, node);
57
+ return node;
58
+ });
59
+
60
+ // 2. Remove nodes that are no longer in the list
61
+ const newNodesSet = new Set(newNodes);
62
+ for (const [k, node] of cache.entries()) {
63
+ if (!newNodesSet.has(node)) {
64
+ if (node.parentNode === container) {
65
+ container.removeChild(node);
66
+ }
67
+ cache.delete(k);
68
+ }
69
+ }
70
+
71
+ // 3. Reorder/Append nodes (Minimal Move)
72
+ // Iterate specifically up to the length of the new list.
73
+ newNodes.forEach((node, i) => {
74
+ const currentAtPos = container.childNodes[i];
75
+ if (currentAtPos !== node) {
76
+ // insertBefore moves the node if it's already in the DOM
77
+ container.insertBefore(node, currentAtPos || null);
78
+ }
79
+ });
80
+ });
81
+
82
+ return container;
83
+ }
@@ -1,5 +1,6 @@
1
1
  import { onMount, triggerUpdate, getCurrentComponent } from './lifecycle.js';
2
2
  import { reportErrorSafe } from './error-reporter.js';
3
+ import { readContext } from './context-shared.js';
3
4
 
4
5
  let context = null;
5
6
  let batchCount = 0;
@@ -143,10 +144,10 @@ export function effect(arg1, arg2, arg3) {
143
144
  }
144
145
  const res = callback();
145
146
  if (typeof res === 'function') this._cleanup = res;
146
- if (owner?.isMounted) triggerUpdate(owner);
147
147
  } catch (e) {
148
- if (!isPromiseLike(e)) reportErrorSafe(e, { phase: 'effect', component: owner?.name });
149
- else throw e;
148
+ if (isPromiseLike(e)) throw e;
149
+ reportErrorSafe(e, { phase: 'effect', component: owner?.name });
150
+ throw e;
150
151
  } finally {
151
152
  context = prev;
152
153
  }
@@ -276,8 +277,9 @@ export function signal(initialValue) {
276
277
 
277
278
  const s = function (newValue) {
278
279
  if (arguments.length > 0) {
279
- if (dep.value !== newValue) {
280
- dep.value = newValue;
280
+ const next = typeof newValue === 'function' ? newValue(dep.value) : newValue;
281
+ if (dep.value !== next) {
282
+ dep.value = next;
281
283
  dep.version = ++globalVersion;
282
284
  notify(dep);
283
285
  }
@@ -311,6 +313,98 @@ export function bindable(initialValue) {
311
313
  return attachHelpers(s);
312
314
  }
313
315
 
316
+ /**
317
+ * Create an async signal that loads data from an async function.
318
+ * Provides pending, error, and refetch capabilities.
319
+ * @param {Function} asyncFn - Async function that returns a promise
320
+ * @param {Object} options - Options: { immediate: true }
321
+ */
322
+ export function asyncSignal(asyncFn, options = {}) {
323
+ if (typeof asyncFn !== 'function') {
324
+ throw new Error('[round] asyncSignal() expects an async function.');
325
+ }
326
+
327
+ const immediate = options.immediate !== false;
328
+ const data = signal(undefined);
329
+ const pending = signal(immediate);
330
+ const error = signal(null);
331
+
332
+ let currentPromise = null;
333
+
334
+ async function execute() {
335
+ pending(true);
336
+ error(null);
337
+
338
+ try {
339
+ const promise = asyncFn();
340
+ currentPromise = promise;
341
+
342
+ if (!isPromiseLike(promise)) {
343
+ // Sync result
344
+ data(promise);
345
+ pending(false);
346
+ return promise;
347
+ }
348
+
349
+ const result = await promise;
350
+
351
+ // Only update if this is still the current request
352
+ if (currentPromise === promise) {
353
+ data(result);
354
+ pending(false);
355
+ }
356
+
357
+ return result;
358
+ } catch (e) {
359
+ if (currentPromise !== null) {
360
+ error(e);
361
+ pending(false);
362
+ }
363
+ return undefined;
364
+ }
365
+ }
366
+
367
+ // The main signal function - returns current data value
368
+ const s = function (newValue) {
369
+ if (arguments.length > 0) {
370
+ return data(newValue);
371
+ }
372
+ if (context) {
373
+ // Subscribe to all internal signals so any change triggers a re-run
374
+ data();
375
+ pending();
376
+ error();
377
+ }
378
+ return data.peek();
379
+ };
380
+
381
+ s.peek = () => data.peek();
382
+
383
+ Object.defineProperty(s, 'value', {
384
+ enumerable: true,
385
+ configurable: true,
386
+ get() { return s(); },
387
+ set(v) { data(v); }
388
+ });
389
+
390
+ // Expose pending and error as signals
391
+ s.pending = pending;
392
+ s.error = error;
393
+
394
+ // Refetch function
395
+ s.refetch = execute;
396
+
397
+ // Mark as async signal
398
+ s.__asyncSignal = true;
399
+
400
+ // Execute immediately if requested
401
+ if (immediate) {
402
+ execute();
403
+ }
404
+
405
+ return s;
406
+ }
407
+
314
408
  function getIn(obj, path) {
315
409
  let cur = obj;
316
410
  for (let i = 0; i < path.length; i++) {
@@ -6,7 +6,7 @@ function isPromiseLike(v) {
6
6
  return v && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function';
7
7
  }
8
8
 
9
- const SuspenseContext = createContext(null);
9
+ import { SuspenseContext } from './context-shared.js';
10
10
  export { SuspenseContext };
11
11
 
12
12
  export function lazy(loader) {