@zoijs/core 1.1.0 → 1.3.0
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/CHANGELOG.md +33 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/core/boundary.js +34 -0
- package/src/core/each.js +2 -2
- package/src/core/renderer.js +1 -1
- package/src/index.d.ts +55 -0
- package/src/index.js +2 -0
- package/src/reactivity/core.js +22 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to Zoijs are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and Zoijs follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/) (see `VERSIONING.md`).
|
|
6
6
|
|
|
7
|
+
## [1.3.0] — 2026-06-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`boundary(child, fallback)`.** A render-time error boundary: it renders
|
|
11
|
+
`child`, and if `child` throws **synchronously while building its markup** (a
|
|
12
|
+
setup/render error that would otherwise break the whole `mount`), it disposes the
|
|
13
|
+
partial work — so an `effect` created before the throw can't leak — and renders
|
|
14
|
+
`fallback` (a value, or `(error) => value`) instead. Catches synchronous
|
|
15
|
+
setup/render throws only; errors in reactive *updates* are already contained per
|
|
16
|
+
binding, and *async* errors belong to `@zoijs/resource` / `@zoijs/action`'s
|
|
17
|
+
`error()` state. Logs in dev, silent in production. The public surface is now
|
|
18
|
+
**nine** functions (additive MINOR). See [RFC 0004](docs/rfcs/0004-error-boundary.md).
|
|
19
|
+
|
|
20
|
+
## [1.2.0] — 2026-06-26
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`effect(fn)`.** A public reactive effect — runs a side effect immediately and
|
|
24
|
+
re-runs whenever a reactive value it reads changes (automatic dependency
|
|
25
|
+
tracking, microtask-batched). The function may return a cleanup that runs before
|
|
26
|
+
the next run and on dispose (same convention as a `ref`); `effect` auto-disposes
|
|
27
|
+
with its owner (component / list item) and returns `{ dispose }` for early
|
|
28
|
+
teardown. This is the public completion of the reactive trio (`createState` /
|
|
29
|
+
`computed` / `effect`) — the engine already used it internally for bindings. Use
|
|
30
|
+
it for side effects *outside* the view (persist on change, sync `document.title`,
|
|
31
|
+
drive a non-Zoijs widget); for on-screen content, keep using a binding
|
|
32
|
+
(`${() => …}`). The public surface is now **eight** functions (additive MINOR per
|
|
33
|
+
`VERSIONING.md`). See [RFC 0003](docs/rfcs/0003-effect-and-svg.md).
|
|
34
|
+
|
|
35
|
+
### Notes
|
|
36
|
+
- The **`svg`** helper considered alongside `effect` was **deferred**: templates
|
|
37
|
+
rooted at `<svg>` already render correctly, and only dynamic-SVG *composition* is
|
|
38
|
+
affected — a minority need. See [RFC 0003](docs/rfcs/0003-effect-and-svg.md) §6.
|
|
39
|
+
|
|
7
40
|
## [1.1.0] — 2026-06-25
|
|
8
41
|
|
|
9
42
|
### Added
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ No build step is required — the framework is plain ES modules.
|
|
|
54
54
|
|
|
55
55
|
# run the counter example (serves the project root over http so ES modules resolve)
|
|
56
56
|
npm run dev
|
|
57
|
-
# then open: http://localhost:
|
|
57
|
+
# then open: http://localhost:7310/examples/counter/ (keep the trailing slash)
|
|
58
58
|
|
|
59
59
|
# run the tests (DOM tests run automatically via jsdom)
|
|
60
60
|
npm test
|
|
@@ -81,7 +81,7 @@ npx playwright install chromium firefox webkit
|
|
|
81
81
|
npm run test:browser
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
Playwright starts a static server automatically (`npx serve` on port
|
|
84
|
+
Playwright starts a static server automatically (`npx serve` on port 7310) — still no build step.
|
|
85
85
|
|
|
86
86
|
## Browser support
|
|
87
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zoijs/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Zoijs — a beginner-friendly, no-build, no-Virtual-DOM frontend framework. Plain HTML, CSS, and JavaScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test:unit": "node --test",
|
|
54
54
|
"test:types": "tsc --noEmit -p tsconfig.json",
|
|
55
55
|
"test:browser": "playwright test",
|
|
56
|
-
"dev": "npx serve -l
|
|
56
|
+
"dev": "npx serve -l 7310 ."
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@playwright/test": "^1.61.1",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// boundary.js — render-time error containment (RFC 0004).
|
|
2
|
+
//
|
|
3
|
+
// boundary(child, fallback) renders `child`; if it throws SYNCHRONOUSLY while
|
|
4
|
+
// building its markup (a setup/render throw that would otherwise break the whole
|
|
5
|
+
// mount), the partial work is torn down and `fallback(error)` is rendered instead.
|
|
6
|
+
//
|
|
7
|
+
// Errors in reactive UPDATES are already contained per-binding by the core; async
|
|
8
|
+
// errors belong to @zoijs/resource / @zoijs/action. This catches the one
|
|
9
|
+
// remaining case — the synchronous setup/render throw.
|
|
10
|
+
|
|
11
|
+
import { createOwner, runWithOwner, disposeOwner } from "../reactivity/owner.js";
|
|
12
|
+
import { isDev } from "../reactivity/env.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @template C, F
|
|
16
|
+
* @param {(() => C) | C} child a component (or value) to render
|
|
17
|
+
* @param {((error: unknown) => F) | F} fallback a value, or (error) => value, shown if child throws
|
|
18
|
+
* @returns {C | F} the child's result, or the fallback's, for a template slot
|
|
19
|
+
*/
|
|
20
|
+
export function boundary(child, fallback) {
|
|
21
|
+
// Run setup in a child scope so anything it creates before a throw (notably an
|
|
22
|
+
// effect, which runs immediately) can be disposed — no zombies. On success the
|
|
23
|
+
// scope nests under the surrounding owner and is disposed with it.
|
|
24
|
+
const owner = createOwner();
|
|
25
|
+
try {
|
|
26
|
+
return runWithOwner(owner, () => (typeof child === "function" ? child() : child));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
disposeOwner(owner);
|
|
29
|
+
if (isDev()) {
|
|
30
|
+
console.error("Zoijs: boundary caught an error during render; showing fallback.", err);
|
|
31
|
+
}
|
|
32
|
+
return typeof fallback === "function" ? fallback(err) : fallback;
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/core/each.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* @param {Function|Array} items a function returning the array, or an array
|
|
18
18
|
* @param {Function} keyFn item -> unique key
|
|
19
19
|
* @param {Function} renderFn item -> html() result
|
|
20
|
-
* @returns {{
|
|
20
|
+
* @returns {{ __zoijsEach: true, items: any, keyFn: Function, renderFn: Function }}
|
|
21
21
|
*/
|
|
22
22
|
export function each(items, keyFn, renderFn) {
|
|
23
|
-
return {
|
|
23
|
+
return { __zoijsEach: true, items, keyFn, renderFn };
|
|
24
24
|
}
|
package/src/core/renderer.js
CHANGED
|
@@ -239,7 +239,7 @@ function renderChild(value) {
|
|
|
239
239
|
// ---- keyed list binding ------------------------------------------------------
|
|
240
240
|
|
|
241
241
|
function isEach(v) {
|
|
242
|
-
return v != null && typeof v === "object" && v.
|
|
242
|
+
return v != null && typeof v === "object" && v.__zoijsEach === true;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
function setupKeyedList(anchor, marker) {
|
package/src/index.d.ts
CHANGED
|
@@ -91,6 +91,40 @@ export function createState<T>(initial: T, equals?: (a: T, b: T) => boolean): St
|
|
|
91
91
|
*/
|
|
92
92
|
export function computed<T>(fn: () => T, equals?: (a: T, b: T) => boolean): Computed<T>;
|
|
93
93
|
|
|
94
|
+
/** A disposable handle returned by {@link effect}. */
|
|
95
|
+
export interface EffectHandle {
|
|
96
|
+
/** Dispose the effect now. It also auto-disposes with its owner (component/list item). */
|
|
97
|
+
dispose(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run a side effect that re-runs whenever a reactive value it reads changes.
|
|
102
|
+
* Runs once immediately, then again (batched on a microtask) on any change.
|
|
103
|
+
* Dependencies are tracked automatically — there is no dependency array.
|
|
104
|
+
*
|
|
105
|
+
* The function may return a cleanup function (same convention as a `ref`); it
|
|
106
|
+
* runs **before the next run** and **on dispose**. Use the return value for
|
|
107
|
+
* per-run teardown — `onCleanup` is component-scoped (fires on unmount), not
|
|
108
|
+
* per-run.
|
|
109
|
+
*
|
|
110
|
+
* Created inside a component or list item, it auto-disposes with that scope;
|
|
111
|
+
* created at module top level, it lives until you call `dispose()`.
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* // persist on change
|
|
115
|
+
* effect(() => localStorage.setItem("theme", theme.get()));
|
|
116
|
+
*
|
|
117
|
+
* // per-run cleanup
|
|
118
|
+
* effect(() => {
|
|
119
|
+
* const id = setInterval(() => poll(query.get()), 1000);
|
|
120
|
+
* return () => clearInterval(id);
|
|
121
|
+
* });
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* For reactive *content on screen*, use a binding (`${() => …}`), not an effect.
|
|
125
|
+
*/
|
|
126
|
+
export function effect(fn: () => void | (() => void)): EffectHandle;
|
|
127
|
+
|
|
94
128
|
/**
|
|
95
129
|
* Keyed list rendering. `items` may be a reactive function or a plain array;
|
|
96
130
|
* `keyFn` returns a stable unique key; `renderFn` returns the template for one item.
|
|
@@ -110,6 +144,27 @@ export function each<T>(
|
|
|
110
144
|
renderFn: (item: T) => TemplateResult
|
|
111
145
|
): EachResult;
|
|
112
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Render `child`; if it **throws synchronously while building its markup** (a
|
|
149
|
+
* setup/render error that would otherwise break the whole `mount`), tear down the
|
|
150
|
+
* partial work and render `fallback` instead. Place the call in a template slot.
|
|
151
|
+
*
|
|
152
|
+
* Catches synchronous setup/render throws only. Errors in reactive *updates* are
|
|
153
|
+
* already contained per binding by the core; *async* errors belong to
|
|
154
|
+
* `@zoijs/resource` / `@zoijs/action`'s `error()` state. It renders once (no reset
|
|
155
|
+
* — re-mount the subtree to retry), logs in dev, and is silent in production.
|
|
156
|
+
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* html`<section>
|
|
159
|
+
* ${boundary(() => RiskyWidget(), (err) => html`<p>Couldn't load this.</p>`)}
|
|
160
|
+
* </section>`
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export function boundary<C, F>(
|
|
164
|
+
child: (() => C) | C,
|
|
165
|
+
fallback: ((error: unknown) => F) | F
|
|
166
|
+
): C | F;
|
|
167
|
+
|
|
113
168
|
/** Toggle development warnings (default: `dev` is `true`). */
|
|
114
169
|
export function configure(options: { dev?: boolean }): void;
|
|
115
170
|
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
export { html } from "./core/html.js";
|
|
9
9
|
export { mount } from "./core/mount.js";
|
|
10
10
|
export { each } from "./core/each.js";
|
|
11
|
+
export { boundary } from "./core/boundary.js";
|
|
11
12
|
export { createState } from "./reactivity/state.js";
|
|
12
13
|
export { computed } from "./reactivity/computed.js";
|
|
14
|
+
export { effect } from "./reactivity/effect.js";
|
|
13
15
|
export { configure } from "./reactivity/env.js";
|
|
14
16
|
export { onCleanup } from "./reactivity/owner.js";
|
package/src/reactivity/core.js
CHANGED
|
@@ -105,6 +105,7 @@ function runComputation(node) {
|
|
|
105
105
|
node.state = CLEAN;
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
|
+
if (node.isEffect) runEffectCleanup(node); // per-run teardown before re-running
|
|
108
109
|
cleanupSources(node);
|
|
109
110
|
const previousObserver = currentObserver;
|
|
110
111
|
currentObserver = node;
|
|
@@ -119,7 +120,13 @@ function runComputation(node) {
|
|
|
119
120
|
} finally {
|
|
120
121
|
currentObserver = previousObserver;
|
|
121
122
|
}
|
|
122
|
-
if (
|
|
123
|
+
if (node.isEffect) {
|
|
124
|
+
// An effect may return a cleanup function (same convention as a ref): it runs
|
|
125
|
+
// before the next run and on dispose. Anything else is ignored.
|
|
126
|
+
if (!threw) node.cleanup = typeof result === "function" ? result : null;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (threw) return;
|
|
123
130
|
if (!node.equals(node.value, result)) {
|
|
124
131
|
node.value = result;
|
|
125
132
|
// Value changed → promote observers (currently CHECK) to DIRTY so they update.
|
|
@@ -132,10 +139,23 @@ function cleanupSources(node) {
|
|
|
132
139
|
node.sources.clear();
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
/** Run (and clear) an effect's returned cleanup — before a re-run and on dispose. */
|
|
143
|
+
function runEffectCleanup(node) {
|
|
144
|
+
const cleanup = node.cleanup;
|
|
145
|
+
if (!cleanup) return;
|
|
146
|
+
node.cleanup = null;
|
|
147
|
+
try {
|
|
148
|
+
cleanup();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("Zoijs: an effect cleanup threw (other bindings keep working):", err);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
function disposeNode(node) {
|
|
136
155
|
if (node.disposed) return;
|
|
137
156
|
node.disposed = true;
|
|
138
157
|
cleanupSources(node);
|
|
158
|
+
if (node.isEffect) runEffectCleanup(node); // final cleanup on dispose
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
const warned = new WeakSet();
|
|
@@ -186,6 +206,7 @@ export function effect(fn) {
|
|
|
186
206
|
isEffect: true,
|
|
187
207
|
disposed: false,
|
|
188
208
|
equals: Object.is,
|
|
209
|
+
cleanup: null,
|
|
189
210
|
};
|
|
190
211
|
onCleanup(() => disposeNode(node)); // disposed with its owner scope
|
|
191
212
|
runComputation(node);
|