tannijs 0.1.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/README.md ADDED
@@ -0,0 +1 @@
1
+ # @tannijs/runtime
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "tannijs",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Signal-based reactive runtime for the Tanni framework",
6
+ "license": "MIT",
7
+ "author": "Sebastijan Zindl",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./internals": {
14
+ "types": "./src/internals.ts",
15
+ "default": "./src/internals.ts"
16
+ }
17
+ },
18
+ "main": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "files": [
21
+ "src",
22
+ "dist"
23
+ ]
24
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSignal } from './reactivity';
4
+ import { delegateEvents, insert, spread, template } from './dom';
5
+
6
+ describe('dom helpers', () => {
7
+ it('creates cloneable template nodes', () => {
8
+ const node = template('<button class="btn">Click</button>');
9
+ const clone = node.cloneNode(true) as HTMLElement;
10
+
11
+ expect(clone.tagName).toBe('BUTTON');
12
+ expect(clone.className).toBe('btn');
13
+ expect(clone.textContent).toBe('Click');
14
+ });
15
+
16
+ it('inserts reactive text updates', () => {
17
+ const host = document.createElement('div');
18
+ const [count, setCount] = createSignal(0);
19
+
20
+ insert(host, () => `Count: ${count()}`);
21
+ expect(host.textContent).toBe('Count: 0');
22
+
23
+ setCount(2);
24
+ expect(host.textContent).toBe('Count: 2');
25
+ });
26
+
27
+ it('replaces only content before marker in reactive inserts', () => {
28
+ const host = document.createElement('div');
29
+ const marker = document.createComment('marker');
30
+ host.append(document.createTextNode('prefix-'));
31
+ host.append(marker);
32
+ host.append(document.createTextNode('-suffix'));
33
+
34
+ const [value, setValue] = createSignal('one');
35
+ insert(host, () => value(), marker);
36
+
37
+ expect(host.textContent).toBe('prefix-one-suffix');
38
+ setValue('two');
39
+ expect(host.textContent).toBe('prefix-two-suffix');
40
+ expect(host.lastChild).not.toBe(marker);
41
+ expect(host.childNodes[1]?.nodeType).toBe(Node.TEXT_NODE);
42
+ });
43
+
44
+ it('applies properties and delegated events via spread', () => {
45
+ delegateEvents(['click']);
46
+
47
+ const host = document.createElement('div');
48
+ const button = document.createElement('button');
49
+ const onClick = vi.fn();
50
+
51
+ spread(button, {
52
+ id: 'counter-btn',
53
+ '@click': onClick,
54
+ children: 'Tap',
55
+ });
56
+
57
+ host.append(button);
58
+ document.body.append(host);
59
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
60
+
61
+ expect(button.id).toBe('counter-btn');
62
+ expect(button.textContent).toBe('Tap');
63
+ expect(onClick).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('supports direct event listeners and replaces previous handlers', () => {
67
+ const button = document.createElement('button');
68
+ const first = vi.fn();
69
+ const second = vi.fn();
70
+
71
+ spread(button, { onMouseover: first });
72
+ button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
73
+
74
+ spread(button, { onMouseover: second });
75
+ button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
76
+
77
+ expect(first).toHaveBeenCalledTimes(1);
78
+ expect(second).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it('supports delegated bubbling and stopPropagation semantics', () => {
82
+ delegateEvents(['click']);
83
+ const host = document.createElement('div');
84
+ const parent = document.createElement('div');
85
+ const button = document.createElement('button');
86
+ const parentHandler = vi.fn();
87
+ const childHandler = vi.fn((event: Event) => event.stopPropagation());
88
+
89
+ spread(parent, { '@click': parentHandler });
90
+ spread(button, { '@click': childHandler });
91
+
92
+ parent.append(button);
93
+ host.append(parent);
94
+ document.body.append(host);
95
+
96
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
97
+
98
+ expect(childHandler).toHaveBeenCalledTimes(1);
99
+ expect(parentHandler).toHaveBeenCalledTimes(0);
100
+ });
101
+ });
package/src/dom.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { createEffect } from './reactivity';
2
+
3
+ export type InsertValue =
4
+ | Node
5
+ | string
6
+ | number
7
+ | boolean
8
+ | null
9
+ | undefined
10
+ | InsertValue[]
11
+ | (() => InsertValue);
12
+
13
+ type EventHandler = (event: Event) => void;
14
+
15
+ const delegatedEvents = new Set<string>();
16
+ const listeningEvents = new Set<string>();
17
+ const directListeners = new WeakMap<Element, Map<string, EventHandler>>();
18
+
19
+ export function template(html: string): Node {
20
+ const tpl = document.createElement('template');
21
+ tpl.innerHTML = html.trim();
22
+
23
+ if (tpl.content.childNodes.length === 1) {
24
+ return tpl.content.firstChild as Node;
25
+ }
26
+
27
+ return tpl.content;
28
+ }
29
+
30
+ export function insert(parent: Node, value: InsertValue, marker: Node | null = null): void {
31
+ if (typeof value === 'function') {
32
+ let currentNodes: Node[] = [];
33
+ createEffect(() => {
34
+ const resolved = (value as () => InsertValue)();
35
+ const nextNodes = normalizeNodes(resolved);
36
+ currentNodes = replaceNodes(parent, currentNodes, nextNodes, marker);
37
+ });
38
+ return;
39
+ }
40
+
41
+ const nextNodes = normalizeNodes(value);
42
+ replaceNodes(parent, [], nextNodes, marker);
43
+ }
44
+
45
+ export function spread(element: Element, props: Record<string, unknown>): void {
46
+ for (const [key, value] of Object.entries(props)) {
47
+ if (key === 'children') {
48
+ insert(element, value as InsertValue);
49
+ continue;
50
+ }
51
+
52
+ if (key === 'style' && value && typeof value === 'object') {
53
+ Object.assign((element as HTMLElement).style, value as Record<string, string>);
54
+ continue;
55
+ }
56
+
57
+ const eventName = toEventName(key);
58
+ if (eventName) {
59
+ applyEventHandler(element, eventName, value);
60
+ continue;
61
+ }
62
+
63
+ applyProperty(element, key, value);
64
+ }
65
+ }
66
+
67
+ export function delegateEvents(eventNames: string[]): void {
68
+ for (const eventName of eventNames) {
69
+ const normalized = eventName.toLowerCase();
70
+ delegatedEvents.add(normalized);
71
+
72
+ if (listeningEvents.has(normalized)) {
73
+ continue;
74
+ }
75
+
76
+ listeningEvents.add(normalized);
77
+ document.addEventListener(normalized, handleDelegatedEvent);
78
+ }
79
+ }
80
+
81
+ function handleDelegatedEvent(event: Event): void {
82
+ const type = event.type.toLowerCase();
83
+ let node: Node | null = event.target as Node | null;
84
+
85
+ while (node && node !== document) {
86
+ if (node instanceof Element) {
87
+ const handlers = (node as DelegatedElement).__tnDelegatedHandlers;
88
+ const handler = handlers?.[type];
89
+ if (handler) {
90
+ handler(event);
91
+ if (event.cancelBubble) {
92
+ return;
93
+ }
94
+ }
95
+ }
96
+ node = node.parentNode;
97
+ }
98
+ }
99
+
100
+ function applyEventHandler(element: Element, eventName: string, value: unknown): void {
101
+ if (typeof value !== 'function') {
102
+ return;
103
+ }
104
+
105
+ const handler = value as EventHandler;
106
+ if (delegatedEvents.has(eventName)) {
107
+ const delegatedElement = element as DelegatedElement;
108
+ delegatedElement.__tnDelegatedHandlers ??= {};
109
+ delegatedElement.__tnDelegatedHandlers[eventName] = handler;
110
+ return;
111
+ }
112
+
113
+ let listenersForElement = directListeners.get(element);
114
+ if (!listenersForElement) {
115
+ listenersForElement = new Map<string, EventHandler>();
116
+ directListeners.set(element, listenersForElement);
117
+ }
118
+
119
+ const previous = listenersForElement.get(eventName);
120
+ if (previous) {
121
+ element.removeEventListener(eventName, previous);
122
+ }
123
+
124
+ listenersForElement.set(eventName, handler);
125
+ element.addEventListener(eventName, handler);
126
+ }
127
+
128
+ function applyProperty(element: Element, key: string, value: unknown): void {
129
+ const writableElement = element as unknown as Record<string, unknown>;
130
+
131
+ if (value === false || value == null) {
132
+ element.removeAttribute(key);
133
+ if (key in element) {
134
+ writableElement[key] = '';
135
+ }
136
+ return;
137
+ }
138
+
139
+ if (key in element && !key.startsWith('aria-') && !key.startsWith('data-')) {
140
+ writableElement[key] = value;
141
+ return;
142
+ }
143
+
144
+ element.setAttribute(key, String(value));
145
+ }
146
+
147
+ function toEventName(key: string): string | null {
148
+ if (key.startsWith('on:')) {
149
+ return key.slice(3).toLowerCase();
150
+ }
151
+
152
+ if (key.startsWith('@')) {
153
+ return key.slice(1).toLowerCase();
154
+ }
155
+
156
+ if (key.startsWith('on') && key.length > 2) {
157
+ return key.slice(2).toLowerCase();
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ function normalizeNodes(value: InsertValue): Node[] {
164
+ if (value == null || value === false || value === true) {
165
+ return [];
166
+ }
167
+
168
+ if (Array.isArray(value)) {
169
+ return value.flatMap((entry) => normalizeNodes(entry));
170
+ }
171
+
172
+ if (value instanceof Node) {
173
+ return [value];
174
+ }
175
+
176
+ return [document.createTextNode(String(value))];
177
+ }
178
+
179
+ function replaceNodes(parent: Node, currentNodes: Node[], nextNodes: Node[], marker: Node | null): Node[] {
180
+ for (const node of currentNodes) {
181
+ if (node.parentNode === parent) {
182
+ parent.removeChild(node);
183
+ }
184
+ }
185
+
186
+ for (const node of nextNodes) {
187
+ parent.insertBefore(node, marker);
188
+ }
189
+
190
+ return nextNodes;
191
+ }
192
+
193
+ interface DelegatedElement extends Element {
194
+ __tnDelegatedHandlers?: Record<string, EventHandler>;
195
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ batch,
3
+ createEffect,
4
+ createMemo,
5
+ createSignal,
6
+ onCleanup,
7
+ untrack,
8
+ type Accessor,
9
+ type Setter,
10
+ } from './reactivity';
@@ -0,0 +1,3 @@
1
+ export { createEffect } from './reactivity';
2
+ export { delegateEvents, template, insert, spread } from './dom';
3
+ export type { InsertValue } from './dom';
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { batch, createEffect, createMemo, createSignal, onCleanup, untrack } from './reactivity';
4
+
5
+ describe('reactivity core', () => {
6
+ it('tracks signal reads and reruns effects on updates', () => {
7
+ const [count, setCount] = createSignal(0);
8
+ const values: number[] = [];
9
+
10
+ createEffect(() => {
11
+ values.push(count());
12
+ });
13
+
14
+ setCount(1);
15
+ setCount((prev) => prev + 1);
16
+
17
+ expect(values).toEqual([0, 1, 2]);
18
+ });
19
+
20
+ it('batches updates and runs effects once', () => {
21
+ const [count, setCount] = createSignal(0);
22
+ const spy = vi.fn();
23
+
24
+ createEffect(() => {
25
+ count();
26
+ spy();
27
+ });
28
+
29
+ batch(() => {
30
+ setCount(1);
31
+ setCount(2);
32
+ setCount(3);
33
+ });
34
+
35
+ expect(spy).toHaveBeenCalledTimes(2);
36
+ expect(count()).toBe(3);
37
+ });
38
+
39
+ it('supports memoized derived values', () => {
40
+ const [count, setCount] = createSignal(2);
41
+ const doubled = createMemo(() => count() * 2);
42
+
43
+ expect(doubled()).toBe(4);
44
+ setCount(5);
45
+ expect(doubled()).toBe(10);
46
+ });
47
+
48
+ it('recomputes memo only when dependencies change', () => {
49
+ const [count, setCount] = createSignal(1);
50
+ const compute = vi.fn(() => count() * 10);
51
+ const value = createMemo(compute);
52
+ const effectSpy = vi.fn();
53
+
54
+ createEffect(() => {
55
+ value();
56
+ effectSpy();
57
+ });
58
+
59
+ expect(value()).toBe(10);
60
+ expect(compute).toHaveBeenCalledTimes(1);
61
+ expect(effectSpy).toHaveBeenCalledTimes(1);
62
+
63
+ setCount(1);
64
+ expect(compute).toHaveBeenCalledTimes(1);
65
+ expect(effectSpy).toHaveBeenCalledTimes(1);
66
+
67
+ setCount(2);
68
+ expect(value()).toBe(20);
69
+ expect(compute).toHaveBeenCalledTimes(2);
70
+ expect(effectSpy).toHaveBeenCalledTimes(2);
71
+ });
72
+
73
+ it('supports untrack and effect cleanups', () => {
74
+ const [count, setCount] = createSignal(0);
75
+ const sideEffect = vi.fn();
76
+ const cleanup = vi.fn();
77
+
78
+ createEffect(() => {
79
+ sideEffect(untrack(() => count()));
80
+ onCleanup(cleanup);
81
+ count();
82
+ });
83
+
84
+ setCount(1);
85
+
86
+ expect(sideEffect).toHaveBeenCalledTimes(2);
87
+ expect(cleanup).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('runs previous cleanup before the next effect pass', () => {
91
+ const [value, setValue] = createSignal('a');
92
+ const callOrder: string[] = [];
93
+
94
+ createEffect(() => {
95
+ const current = value();
96
+ callOrder.push(`effect:${current}`);
97
+ onCleanup(() => {
98
+ callOrder.push(`cleanup:${current}`);
99
+ });
100
+ });
101
+
102
+ setValue('b');
103
+ setValue('c');
104
+
105
+ expect(callOrder).toEqual([
106
+ 'effect:a',
107
+ 'cleanup:a',
108
+ 'effect:b',
109
+ 'cleanup:b',
110
+ 'effect:c',
111
+ ]);
112
+ });
113
+
114
+ it('supports nested batching with a single downstream rerun', () => {
115
+ const [count, setCount] = createSignal(0);
116
+ const spy = vi.fn();
117
+
118
+ createEffect(() => {
119
+ count();
120
+ spy();
121
+ });
122
+
123
+ batch(() => {
124
+ setCount(1);
125
+ batch(() => {
126
+ setCount(2);
127
+ setCount(3);
128
+ });
129
+ setCount(4);
130
+ });
131
+
132
+ expect(count()).toBe(4);
133
+ expect(spy).toHaveBeenCalledTimes(2);
134
+ });
135
+ });
@@ -0,0 +1,199 @@
1
+ export type Accessor<T> = () => T;
2
+ export type Setter<T> = (value: T | ((prev: T) => T)) => T;
3
+
4
+ type CleanupFn = () => void;
5
+
6
+ interface Computation {
7
+ execute: () => void;
8
+ deps: Set<Source>;
9
+ cleanups: CleanupFn[];
10
+ }
11
+
12
+ interface Source {
13
+ subscribers: Set<Computation>;
14
+ }
15
+
16
+ class Signal<T> implements Source {
17
+ public readonly subscribers = new Set<Computation>();
18
+
19
+ public constructor(private value: T) {}
20
+
21
+ public read(): T {
22
+ trackDependency(this);
23
+ return this.value;
24
+ }
25
+
26
+ public peek(): T {
27
+ return this.value;
28
+ }
29
+
30
+ public write(next: T): T {
31
+ if (Object.is(this.value, next)) {
32
+ return this.value;
33
+ }
34
+
35
+ this.value = next;
36
+ notifySubscribers(this.subscribers);
37
+ return this.value;
38
+ }
39
+ }
40
+
41
+ class Memo<T> implements Source, Computation {
42
+ public readonly subscribers = new Set<Computation>();
43
+ public readonly deps = new Set<Source>();
44
+ public cleanups: CleanupFn[] = [];
45
+ public value!: T;
46
+
47
+ public constructor(private readonly fn: () => T) {
48
+ this.execute();
49
+ }
50
+
51
+ public read(): T {
52
+ trackDependency(this);
53
+ return this.value;
54
+ }
55
+
56
+ public execute(): void {
57
+ cleanupComputation(this);
58
+ const previous = currentComputation;
59
+ currentComputation = this;
60
+
61
+ let nextValue!: T;
62
+ try {
63
+ nextValue = this.fn();
64
+ } finally {
65
+ currentComputation = previous;
66
+ }
67
+
68
+ if (!Object.is(nextValue, this.value)) {
69
+ this.value = nextValue;
70
+ notifySubscribers(this.subscribers);
71
+ }
72
+ }
73
+ }
74
+
75
+ const pendingComputations = new Set<Computation>();
76
+ let batchDepth = 0;
77
+ let currentComputation: Computation | null = null;
78
+
79
+ function trackDependency(source: Source): void {
80
+ if (!currentComputation) {
81
+ return;
82
+ }
83
+
84
+ source.subscribers.add(currentComputation);
85
+ currentComputation.deps.add(source);
86
+ }
87
+
88
+ function cleanupComputation(computation: Computation): void {
89
+ for (const source of computation.deps) {
90
+ source.subscribers.delete(computation);
91
+ }
92
+ computation.deps.clear();
93
+
94
+ const cleanups = computation.cleanups;
95
+ computation.cleanups = [];
96
+ for (const cleanup of cleanups) {
97
+ cleanup();
98
+ }
99
+ }
100
+
101
+ function runComputation(computation: Computation): void {
102
+ if (batchDepth > 0) {
103
+ pendingComputations.add(computation);
104
+ return;
105
+ }
106
+
107
+ computation.execute();
108
+ }
109
+
110
+ function notifySubscribers(subscribers: Set<Computation>): void {
111
+ const queue = Array.from(subscribers);
112
+ for (const subscriber of queue) {
113
+ runComputation(subscriber);
114
+ }
115
+ }
116
+
117
+ function flushPending(): void {
118
+ if (pendingComputations.size === 0) {
119
+ return;
120
+ }
121
+
122
+ const queue = Array.from(pendingComputations);
123
+ pendingComputations.clear();
124
+ for (const computation of queue) {
125
+ computation.execute();
126
+ }
127
+
128
+ if (pendingComputations.size > 0) {
129
+ flushPending();
130
+ }
131
+ }
132
+
133
+ export function createSignal<T>(initialValue: T): [Accessor<T>, Setter<T>] {
134
+ const signal = new Signal(initialValue);
135
+
136
+ const accessor: Accessor<T> = () => signal.read();
137
+ const setter: Setter<T> = (value) => {
138
+ const nextValue = typeof value === 'function' ? (value as (prev: T) => T)(signal.peek()) : value;
139
+ return signal.write(nextValue);
140
+ };
141
+
142
+ return [accessor, setter];
143
+ }
144
+
145
+ export function createEffect(fn: () => void): void {
146
+ const effect: Computation = {
147
+ deps: new Set<Source>(),
148
+ cleanups: [],
149
+ execute() {
150
+ cleanupComputation(effect);
151
+ const previous = currentComputation;
152
+ currentComputation = effect;
153
+ try {
154
+ fn();
155
+ } finally {
156
+ currentComputation = previous;
157
+ }
158
+ },
159
+ };
160
+
161
+ effect.execute();
162
+ }
163
+
164
+ export function createMemo<T>(fn: () => T): Accessor<T> {
165
+ const memo = new Memo(fn);
166
+ return () => memo.read();
167
+ }
168
+
169
+ export function batch<T>(fn: () => T): T {
170
+ batchDepth += 1;
171
+ try {
172
+ return fn();
173
+ } finally {
174
+ batchDepth -= 1;
175
+ if (batchDepth === 0) {
176
+ flushPending();
177
+ }
178
+ }
179
+ }
180
+
181
+ export function untrack<T>(fn: () => T): T {
182
+ const previous = currentComputation;
183
+ currentComputation = null;
184
+ try {
185
+ return fn();
186
+ } finally {
187
+ currentComputation = previous;
188
+ }
189
+ }
190
+
191
+ export function onCleanup(fn: CleanupFn): void {
192
+ if (!currentComputation) {
193
+ throw new Error('onCleanup must be called inside a tracked computation.');
194
+ }
195
+
196
+ currentComputation.cleanups.push(fn);
197
+ }
198
+
199
+ export const effect = createEffect;