round-core 0.1.3 → 0.1.4

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.
@@ -385,6 +428,25 @@ Notes:
385
428
  - 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
429
  - Each case handles its own rendering without re-running the parent component.
387
430
 
431
+ ### `try / catch`
432
+
433
+ Round supports both static and **reactive** `try/catch` blocks inside JSX.
434
+
435
+ - **Static**: Just like standard JS, but renders fragments.
436
+ - **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.
437
+
438
+ ```jsx
439
+ {try(user()) {
440
+ {if(user() && user().name) {
441
+ <div>Hello {user().name}</div>
442
+ } else if(user.pending()) {
443
+ <div>⏳ Loading...</div>
444
+ }}
445
+ } catch(e) {
446
+ <div className="error"> Failed to load user: {e.message} </div>
447
+ }}
448
+ ```
449
+
388
450
  ## Routing
389
451
 
390
452
  Round includes router primitives intended for SPA navigation. All route paths must start with a forward slash `/`.
@@ -452,26 +514,14 @@ const LazyWidget = lazy(() => import('./Widget'));
452
514
 
453
515
  ## Error handling
454
516
 
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.
517
+ Round JS favors individual error control and standard browser debugging:
463
518
 
464
- ```jsx
465
- import { ErrorBoundary } from 'round-core';
466
-
467
- function DangerousComponent() {
468
- throw new Error('Boom!');
469
- }
519
+ 1. **Explict `try/catch`**: Use the JSX `try/catch` syntax to handle local component failures gracefully.
520
+ 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.
521
+ 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
522
 
471
- <ErrorBoundary fallback={(err) => <div className="error">Something went wrong: {err.message}</div>}>
472
- <DangerousComponent />
473
- </ErrorBoundary>
474
- ```
523
+ Example of a descriptive console log:
524
+ `[round] Error in phase "component.render" of component <UserProfile />: TypeError: Cannot read property 'avatar' of undefined`
475
525
 
476
526
  ## CLI
477
527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "round-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",
@@ -262,6 +262,81 @@ export function transform(code, initialDepth = 0) {
262
262
  return { end: endIdx, replacement };
263
263
  }
264
264
 
265
+ function handleTry(currI, isBare = false) {
266
+ let ptr = currI;
267
+ if (!isBare) ptr = consumeWhitespace(code, currI + 1);
268
+
269
+ if (!code.startsWith('try', ptr)) return null;
270
+ ptr += 3;
271
+ ptr = consumeWhitespace(code, ptr);
272
+
273
+ // Check for reactive try: try(expr) {...}
274
+ let reactiveExpr = null;
275
+ if (code[ptr] === '(') {
276
+ const condRes = extractCondition(code, ptr);
277
+ if (condRes) {
278
+ reactiveExpr = condRes.cond;
279
+ ptr = consumeWhitespace(code, condRes.end);
280
+ }
281
+ }
282
+
283
+ // Must have opening brace for try block
284
+ if (code[ptr] !== '{') return null;
285
+
286
+ const tryBlock = parseBlock(code, ptr);
287
+ if (!tryBlock) return null;
288
+
289
+ const tryContent = code.substring(tryBlock.start + 1, tryBlock.end);
290
+ const transformedTry = transform(tryContent, 1);
291
+
292
+ ptr = tryBlock.end + 1;
293
+ ptr = consumeWhitespace(code, ptr);
294
+
295
+ // Must have catch
296
+ if (!code.startsWith('catch', ptr)) return null;
297
+ ptr += 5;
298
+ ptr = consumeWhitespace(code, ptr);
299
+
300
+ // Extract catch parameter (e) or (err)
301
+ let catchParam = 'e';
302
+ if (code[ptr] === '(') {
303
+ const catchCondRes = extractCondition(code, ptr);
304
+ if (catchCondRes) {
305
+ catchParam = catchCondRes.cond.trim() || 'e';
306
+ ptr = consumeWhitespace(code, catchCondRes.end);
307
+ }
308
+ }
309
+
310
+ // Must have catch block
311
+ if (code[ptr] !== '{') return null;
312
+
313
+ const catchBlock = parseBlock(code, ptr);
314
+ if (!catchBlock) return null;
315
+
316
+ const catchContent = code.substring(catchBlock.start + 1, catchBlock.end);
317
+ const transformedCatch = transform(catchContent, 1);
318
+
319
+ let endIdx = catchBlock.end + 1;
320
+
321
+ // If not bare, consume closing '}'
322
+ if (!isBare) {
323
+ endIdx = consumeWhitespace(code, endIdx);
324
+ if (code[endIdx] !== '}') return null;
325
+ endIdx++;
326
+ }
327
+
328
+ let replacement;
329
+ if (reactiveExpr) {
330
+ // Reactive try: return a THUNK (function) so dom.js handles it as a reactive child (effect)
331
+ replacement = `{() => { try { ${reactiveExpr}; return (<Fragment>${transformedTry}</Fragment>); } catch(${catchParam}) { return (<Fragment>${transformedCatch}</Fragment>); } }}`;
332
+ } else {
333
+ // Static try: simple IIFE
334
+ replacement = `{(() => { try { return (<Fragment>${transformedTry}</Fragment>); } catch(${catchParam}) { return (<Fragment>${transformedCatch}</Fragment>); } })()}`;
335
+ }
336
+
337
+ return { end: endIdx, replacement };
338
+ }
339
+
265
340
  // --- Main Parser Loop ---
266
341
 
267
342
  let inSingle = false, inDouble = false, inTemplate = false;
@@ -397,6 +472,9 @@ export function transform(code, initialDepth = 0) {
397
472
  } else if (code.startsWith('switch', ptr)) {
398
473
  const res = handleSwitch(i, false);
399
474
  if (res) { result += res.replacement; i = res.end; processed = true; }
475
+ } else if (code.startsWith('try', ptr)) {
476
+ const res = handleTry(i, false);
477
+ if (res) { result += res.replacement; i = res.end; processed = true; }
400
478
  }
401
479
  }
402
480
 
@@ -421,6 +499,13 @@ export function transform(code, initialDepth = 0) {
421
499
  const res = handleSwitch(i, true);
422
500
  if (res) { result += res.replacement; i = res.end; processed = true; }
423
501
  }
502
+ } else if (ch === 't' && code.startsWith('try', i)) {
503
+ // Bare try: try { ... } catch { ... } or try(expr) { ... } catch { ... }
504
+ let ptr = consumeWhitespace(code, i + 3);
505
+ if (code[ptr] === '{' || code[ptr] === '(') {
506
+ const res = handleTry(i, true);
507
+ if (res) { result += res.replacement; i = res.end; processed = true; }
508
+ }
424
509
  }
425
510
 
426
511
  if (processed) continue;
package/src/index.d.ts CHANGED
@@ -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
  */
@@ -319,25 +405,6 @@ export function readContext<T>(ctx: Context<T>): T;
319
405
  */
320
406
  export function bindContext<T>(ctx: Context<T>): () => T;
321
407
 
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
408
 
342
409
  /**
343
410
  * Async & Code Splitting
package/src/index.js CHANGED
@@ -2,9 +2,6 @@ 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';
@@ -13,20 +10,16 @@ import * as Signals from './runtime/signals.js';
13
10
  import * as DOM from './runtime/dom.js';
14
11
  import * as Lifecycle from './runtime/lifecycle.js';
15
12
  import * as Router from './runtime/router.js';
16
- import * as Errors from './runtime/errors.js';
17
13
  import * as Suspense from './runtime/suspense.js';
18
14
  import * as Context from './runtime/context.js';
19
15
  import * as Store from './runtime/store.js';
20
16
 
21
17
  export function render(Component, container) {
22
18
  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
- }
19
+ // Errors are no longer handled by a global overlay.
20
+ // They propagate to the console or local try/catch.
21
+ const root = DOM.createElement(Component);
22
+ container.appendChild(root);
30
23
  }
31
24
 
32
25
  export default {
@@ -34,7 +27,6 @@ export default {
34
27
  ...DOM,
35
28
  ...Lifecycle,
36
29
  ...Router,
37
- ...Errors,
38
30
  ...Suspense,
39
31
  ...Context,
40
32
  ...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
  }
@@ -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
  }
@@ -311,6 +312,98 @@ export function bindable(initialValue) {
311
312
  return attachHelpers(s);
312
313
  }
313
314
 
315
+ /**
316
+ * Create an async signal that loads data from an async function.
317
+ * Provides pending, error, and refetch capabilities.
318
+ * @param {Function} asyncFn - Async function that returns a promise
319
+ * @param {Object} options - Options: { immediate: true }
320
+ */
321
+ export function asyncSignal(asyncFn, options = {}) {
322
+ if (typeof asyncFn !== 'function') {
323
+ throw new Error('[round] asyncSignal() expects an async function.');
324
+ }
325
+
326
+ const immediate = options.immediate !== false;
327
+ const data = signal(undefined);
328
+ const pending = signal(immediate);
329
+ const error = signal(null);
330
+
331
+ let currentPromise = null;
332
+
333
+ async function execute() {
334
+ pending(true);
335
+ error(null);
336
+
337
+ try {
338
+ const promise = asyncFn();
339
+ currentPromise = promise;
340
+
341
+ if (!isPromiseLike(promise)) {
342
+ // Sync result
343
+ data(promise);
344
+ pending(false);
345
+ return promise;
346
+ }
347
+
348
+ const result = await promise;
349
+
350
+ // Only update if this is still the current request
351
+ if (currentPromise === promise) {
352
+ data(result);
353
+ pending(false);
354
+ }
355
+
356
+ return result;
357
+ } catch (e) {
358
+ if (currentPromise !== null) {
359
+ error(e);
360
+ pending(false);
361
+ }
362
+ return undefined;
363
+ }
364
+ }
365
+
366
+ // The main signal function - returns current data value
367
+ const s = function (newValue) {
368
+ if (arguments.length > 0) {
369
+ return data(newValue);
370
+ }
371
+ if (context) {
372
+ // Subscribe to all internal signals so any change triggers a re-run
373
+ data();
374
+ pending();
375
+ error();
376
+ }
377
+ return data.peek();
378
+ };
379
+
380
+ s.peek = () => data.peek();
381
+
382
+ Object.defineProperty(s, 'value', {
383
+ enumerable: true,
384
+ configurable: true,
385
+ get() { return s(); },
386
+ set(v) { data(v); }
387
+ });
388
+
389
+ // Expose pending and error as signals
390
+ s.pending = pending;
391
+ s.error = error;
392
+
393
+ // Refetch function
394
+ s.refetch = execute;
395
+
396
+ // Mark as async signal
397
+ s.__asyncSignal = true;
398
+
399
+ // Execute immediately if requested
400
+ if (immediate) {
401
+ execute();
402
+ }
403
+
404
+ return s;
405
+ }
406
+
314
407
  function getIn(obj, path) {
315
408
  let cur = obj;
316
409
  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) {