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 +75 -21
- package/package.json +1 -1
- package/src/compiler/transformer.js +114 -1
- package/src/compiler/vite-plugin.js +2 -2
- package/src/index.d.ts +92 -20
- package/src/index.js +5 -12
- package/src/runtime/context-shared.js +63 -0
- package/src/runtime/context.js +27 -58
- package/src/runtime/dom.js +5 -4
- package/src/runtime/error-reporter.js +12 -4
- package/src/runtime/keyed.js +83 -0
- package/src/runtime/signals.js +99 -5
- package/src/runtime/suspense.js +1 -1
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
|
|
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
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
24
|
-
try
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|
package/src/runtime/context.js
CHANGED
|
@@ -1,62 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pushContext, popContext, contextStack, generateContextId, readContext, SuspenseContext, runInContext } from './context-shared.js';
|
|
2
|
+
import { createElement, Fragment } from './dom.js';
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
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]:
|
|
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
|
|
21
|
+
const val = (typeof value === 'function' && value.peek) ? value() : value;
|
|
54
22
|
|
|
55
|
-
// Push it during the effect run too!
|
|
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
|
-
|
|
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
|
-
}
|
package/src/runtime/dom.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
}
|
package/src/runtime/signals.js
CHANGED
|
@@ -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 (
|
|
149
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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++) {
|
package/src/runtime/suspense.js
CHANGED
|
@@ -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
|
-
|
|
9
|
+
import { SuspenseContext } from './context-shared.js';
|
|
10
10
|
export { SuspenseContext };
|
|
11
11
|
|
|
12
12
|
export function lazy(loader) {
|