@zoijs/core 1.0.0 → 1.2.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 CHANGED
@@ -4,7 +4,40 @@ 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.0.0] — Unreleased
7
+ ## [1.2.0] — 2026-06-26
8
+
9
+ ### Added
10
+ - **`effect(fn)`.** A public reactive effect — runs a side effect immediately and
11
+ re-runs whenever a reactive value it reads changes (automatic dependency
12
+ tracking, microtask-batched). The function may return a cleanup that runs before
13
+ the next run and on dispose (same convention as a `ref`); `effect` auto-disposes
14
+ with its owner (component / list item) and returns `{ dispose }` for early
15
+ teardown. This is the public completion of the reactive trio (`createState` /
16
+ `computed` / `effect`) — the engine already used it internally for bindings. Use
17
+ it for side effects *outside* the view (persist on change, sync `document.title`,
18
+ drive a non-Zoijs widget); for on-screen content, keep using a binding
19
+ (`${() => …}`). The public surface is now **eight** functions (additive MINOR per
20
+ `VERSIONING.md`). See [RFC 0003](docs/rfcs/0003-effect-and-svg.md).
21
+
22
+ ### Notes
23
+ - The **`svg`** helper considered alongside `effect` was **deferred**: templates
24
+ rooted at `<svg>` already render correctly, and only dynamic-SVG *composition* is
25
+ affected — a minority need. See [RFC 0003](docs/rfcs/0003-effect-and-svg.md) §6.
26
+
27
+ ## [1.1.0] — 2026-06-25
28
+
29
+ ### Added
30
+ - **Element refs.** A new `ref` binding gives you the rendered DOM element:
31
+ `html\`<input ref=${(el) => el.focus()} />\``. The callback runs once, just after
32
+ the element is inserted (so `focus`/`scroll`/`measure`/`canvas` work), is not
33
+ reactive, and may return a cleanup function that runs on unmount or list-item
34
+ removal. Works inside keyed `each` lists. Non-function values are ignored with a
35
+ dev-mode warning and never become a DOM attribute. No new export — `ref` is a
36
+ binding semantic, so the seven-function public surface is unchanged (additive
37
+ MINOR per `VERSIONING.md`). See [Element refs](docs/concepts/refs.md) and
38
+ [RFC 0001](docs/rfcs/0001-element-refs.md).
39
+
40
+ ## [1.0.0] — 2026-06-24
8
41
 
9
42
  First stable release. The public API is frozen at seven functions.
10
43
 
@@ -33,4 +66,5 @@ First stable release. The public API is frozen at seven functions.
33
66
  (Playwright), and TypeScript type tests.
34
67
  - No build step required at any point.
35
68
 
36
- [1.0.0]: https://github.com/Zoijs/zoijs/releases/tag/v1.0.0
69
+ [1.1.0]: https://github.com/Zoijs/zoijs/releases/tag/core-v1.1.0
70
+ [1.0.0]: https://github.com/Zoijs/zoijs/releases/tag/core-v1.0.0
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Easy Framework contributors
3
+ Copyright (c) 2026 Zoijs contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A lightweight frontend framework you don't have to learn before you use it — **plain HTML, CSS, and JavaScript**, no JSX, no build step, no Virtual DOM.
4
4
 
5
- **[Website](https://zoijs.com)** · **[Documentation](https://zoijs.dev)** · **[GitHub](https://github.com/Zoijs)** · **[npm](https://www.npmjs.com/package/@zoijs/core)**
5
+ **[Documentation](https://zoijs.dev)** · **[GitHub](https://github.com/Zoijs)** · **[npm](https://www.npmjs.com/package/@zoijs/core)**
6
6
 
7
7
  ```bash
8
8
  npm install @zoijs/core
@@ -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:3000/examples/counter/ (keep the trailing slash)
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 3000) — still no build step.
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.0.0",
3
+ "version": "1.2.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",
@@ -21,9 +21,9 @@
21
21
  "engines": {
22
22
  "node": ">=18"
23
23
  },
24
- "author": "Zoijs contributors (https://zoijs.com)",
24
+ "author": "Zoijs contributors (https://zoijs.dev)",
25
25
  "license": "MIT",
26
- "homepage": "https://zoijs.com",
26
+ "homepage": "https://zoijs.dev",
27
27
  "repository": {
28
28
  "type": "git",
29
29
  "url": "git+https://github.com/Zoijs/zoijs.git",
@@ -49,11 +49,11 @@
49
49
  "spa"
50
50
  ],
51
51
  "scripts": {
52
- "test": "node --test --import ./tests/setup-dom.js \"tests/**/*.test.js\"",
53
- "test:unit": "node --test \"tests/**/*.test.js\"",
52
+ "test": "node --test --import ./tests/setup-dom.js",
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 3000 ."
56
+ "dev": "npx serve -l 7310 ."
57
57
  },
58
58
  "devDependencies": {
59
59
  "@playwright/test": "^1.61.1",
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 {{ __easyEach: true, items: any, keyFn: Function, renderFn: Function }}
20
+ * @returns {{ __zoijsEach: true, items: any, keyFn: Function, renderFn: Function }}
21
21
  */
22
22
  export function each(items, keyFn, renderFn) {
23
- return { __easyEach: true, items, keyFn, renderFn };
23
+ return { __zoijsEach: true, items, keyFn, renderFn };
24
24
  }
@@ -82,6 +82,13 @@ function bindChild(anchor, value) {
82
82
  }
83
83
 
84
84
  function bindAttribute(el, attr, values) {
85
+ if (attr.name === "ref") {
86
+ // A callback ref. Only a single ${fn} is meaningful; anything else (a string,
87
+ // a number, a multi-part value) is rejected by bindRef without touching the DOM.
88
+ bindRef(el, attr.whole ? values[attr.holes[0]] : undefined);
89
+ return;
90
+ }
91
+
85
92
  if (attr.event) {
86
93
  const handler = values[attr.holes[0]];
87
94
  // Only real functions are accepted as handlers — a string/object is ignored,
@@ -119,6 +126,35 @@ function bindAttribute(el, attr, values) {
119
126
  else applyAttribute(el, attr.name, compute());
120
127
  }
121
128
 
129
+ // A callback ref: hand the real element to user code AFTER the current render is
130
+ // inserted, so focus/scroll/measure see a CONNECTED node. We defer one microtask
131
+ // (render binds while the DOM is still a detached fragment; insertion happens
132
+ // right after render returns, synchronously, so a microtask runs once it's live).
133
+ // Not reactive — the function is read once. An optional returned function is an
134
+ // owner-scoped cleanup, disposed on unmount or list-item removal, exactly like a
135
+ // listener. A non-function value is ignored (with a dev warning) and never sets a
136
+ // "ref" attribute — so an inert string can't be wired up.
137
+ function bindRef(el, fn) {
138
+ if (typeof fn !== "function") {
139
+ if (isDev()) console.warn(`Zoijs: "ref" expects a function (el) => …; ignoring ${typeof fn} value`);
140
+ return;
141
+ }
142
+ let active = true;
143
+ let cleanup = null;
144
+ onCleanup(() => {
145
+ active = false;
146
+ if (cleanup) { cleanup(); cleanup = null; }
147
+ });
148
+ queueMicrotask(() => {
149
+ if (!active) return; // removed before the microtask fired
150
+ const c = fn(el);
151
+ if (typeof c === "function") {
152
+ if (active) cleanup = c;
153
+ else c(); // disposed during fn(): tear down immediately
154
+ }
155
+ });
156
+ }
157
+
122
158
  // ---- text / content bindings -------------------------------------------------
123
159
 
124
160
  function bindReactiveContent(anchor, getValue) {
@@ -203,7 +239,7 @@ function renderChild(value) {
203
239
  // ---- keyed list binding ------------------------------------------------------
204
240
 
205
241
  function isEach(v) {
206
- return v != null && typeof v === "object" && v.__easyEach === true;
242
+ return v != null && typeof v === "object" && v.__zoijsEach === true;
207
243
  }
208
244
 
209
245
  function setupKeyedList(anchor, marker) {
package/src/index.d.ts CHANGED
@@ -40,12 +40,29 @@ export interface EachResult {
40
40
  /** A component is a function that returns an `html` template. */
41
41
  export type Component = () => TemplateResult;
42
42
 
43
+ /**
44
+ * A callback ref. Place it on an element as `ref=${fn}`; it receives the real DOM
45
+ * element once the surrounding render has been inserted (deferred one microtask,
46
+ * so `focus()` / `scrollIntoView()` / `getBoundingClientRect()` work). It may
47
+ * return a cleanup function, which runs on unmount or list-item removal. It runs
48
+ * once and is not reactive.
49
+ *
50
+ * ```ts
51
+ * html`<input ref=${(el: HTMLInputElement) => el.focus()} />`
52
+ * html`<div ref=${(el) => { const c = chart(el); return () => c.destroy(); }}></div>`
53
+ * ```
54
+ */
55
+ export type Ref<E extends Element = Element> = (element: E) => void | (() => void);
56
+
43
57
  /**
44
58
  * Tagged-template function — write your markup as HTML.
45
59
  *
46
60
  * ```js
47
61
  * html`<button onclick=${() => count.set(count.get() + 1)}>${() => count.get()}</button>`
48
62
  * ```
63
+ *
64
+ * To reach the rendered DOM element, add a callback `ref` (see {@link Ref}):
65
+ * `html\`<input ref=${(el) => el.focus()} />\``.
49
66
  */
50
67
  export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
51
68
 
@@ -74,6 +91,40 @@ export function createState<T>(initial: T, equals?: (a: T, b: T) => boolean): St
74
91
  */
75
92
  export function computed<T>(fn: () => T, equals?: (a: T, b: T) => boolean): Computed<T>;
76
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
+
77
128
  /**
78
129
  * Keyed list rendering. `items` may be a reactive function or a plain array;
79
130
  * `keyFn` returns a stable unique key; `renderFn` returns the template for one item.
package/src/index.js CHANGED
@@ -10,5 +10,6 @@ export { mount } from "./core/mount.js";
10
10
  export { each } from "./core/each.js";
11
11
  export { createState } from "./reactivity/state.js";
12
12
  export { computed } from "./reactivity/computed.js";
13
+ export { effect } from "./reactivity/effect.js";
13
14
  export { configure } from "./reactivity/env.js";
14
15
  export { onCleanup } from "./reactivity/owner.js";
@@ -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 (threw || node.isEffect) return;
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);