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