janux 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.
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { batch, computed, effect, signal, untrack } from './index';
3
+
4
+ describe('signals', () => {
5
+ it('reads and writes values', () => {
6
+ const count = signal(0);
7
+
8
+ count.value = 2;
9
+ expect(count.value).toBe(2);
10
+ expect(count.peek()).toBe(2);
11
+ });
12
+
13
+ it('re-runs effects on change and supports cleanup', () => {
14
+ const count = signal(0);
15
+ const cleanup = mock(() => {});
16
+ const runs = mock(() => {});
17
+ const dispose = effect(() => {
18
+ runs();
19
+ count.value;
20
+
21
+ return cleanup;
22
+ });
23
+
24
+ count.value = 1;
25
+ expect(runs).toHaveBeenCalledTimes(2);
26
+ expect(cleanup).toHaveBeenCalledTimes(1);
27
+ dispose();
28
+ count.value = 2;
29
+ expect(runs).toHaveBeenCalledTimes(2);
30
+ expect(cleanup).toHaveBeenCalledTimes(2);
31
+ });
32
+
33
+ it('does not notify on same value (Object.is)', () => {
34
+ const count = signal(1);
35
+ const runs = mock(() => {});
36
+
37
+ effect(() => {
38
+ runs();
39
+ count.value;
40
+ });
41
+ count.value = 1;
42
+ expect(runs).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('derives computed values reactively', () => {
46
+ const qty = signal(2);
47
+ const price = signal(100);
48
+ const total = computed(() => qty.value * price.value);
49
+
50
+ expect(total.value).toBe(200);
51
+ qty.value = 3;
52
+ expect(total.value).toBe(300);
53
+ });
54
+
55
+ it('tracks dynamic dependencies only', () => {
56
+ const flag = signal(true);
57
+ const a = signal('a');
58
+ const b = signal('b');
59
+ const runs = mock(() => {});
60
+
61
+ effect(() => {
62
+ runs();
63
+ flag.value ? a.value : b.value;
64
+ });
65
+ b.value = 'B';
66
+ expect(runs).toHaveBeenCalledTimes(1);
67
+ flag.value = false;
68
+ b.value = 'BB';
69
+ expect(runs).toHaveBeenCalledTimes(3);
70
+ });
71
+
72
+ it('batches multiple writes into one notification', () => {
73
+ const a = signal(1);
74
+ const b = signal(2);
75
+ const runs = mock(() => {});
76
+
77
+ effect(() => {
78
+ runs();
79
+ a.value + b.value;
80
+ });
81
+ batch(() => {
82
+ a.value = 10;
83
+ b.value = 20;
84
+ });
85
+ expect(runs).toHaveBeenCalledTimes(2);
86
+ });
87
+
88
+ it('untrack reads without subscribing', () => {
89
+ const a = signal(1);
90
+ const runs = mock(() => {});
91
+
92
+ effect(() => {
93
+ runs();
94
+ untrack(() => a.value);
95
+ });
96
+ a.value = 2;
97
+ expect(runs).toHaveBeenCalledTimes(1);
98
+ });
99
+ });
@@ -0,0 +1,129 @@
1
+ type Cleanup = (() => void) | undefined;
2
+
3
+ interface Runner {
4
+ run: () => void;
5
+ deps: Set<Set<Runner>>;
6
+ }
7
+
8
+ export interface Sig<T> {
9
+ value: T;
10
+ peek(): T;
11
+ }
12
+
13
+ export interface ReadonlySig<T> {
14
+ readonly value: T;
15
+ peek(): T;
16
+ dispose(): void;
17
+ }
18
+
19
+ let active: Runner | null = null;
20
+ let batching: Set<Runner> | null = null;
21
+
22
+ function track(subs: Set<Runner>): void {
23
+ if (active === null) return;
24
+
25
+ subs.add(active);
26
+ active.deps.add(subs);
27
+ }
28
+
29
+ function notify(subs: Set<Runner>): void {
30
+ const runners = [...subs];
31
+
32
+ if (batching !== null) {
33
+ runners.forEach((runner) => batching!.add(runner));
34
+
35
+ return;
36
+ }
37
+ runners.forEach((runner) => runner.run());
38
+ }
39
+
40
+ function detach(runner: Runner): void {
41
+ runner.deps.forEach((subs) => subs.delete(runner));
42
+ runner.deps.clear();
43
+ }
44
+
45
+ export function signal<T>(initial: T): Sig<T> {
46
+ const subs = new Set<Runner>();
47
+ let current = initial;
48
+
49
+ return {
50
+ get value(): T {
51
+ track(subs);
52
+
53
+ return current;
54
+ },
55
+ set value(next: T) {
56
+ if (Object.is(next, current)) return;
57
+ current = next;
58
+ notify(subs);
59
+ },
60
+ peek: () => current,
61
+ };
62
+ }
63
+
64
+ function runTracked(runner: Runner, fn: () => Cleanup): Cleanup {
65
+ const previous = active;
66
+
67
+ detach(runner);
68
+ active = runner;
69
+ try {
70
+ return fn();
71
+ } finally {
72
+ active = previous;
73
+ }
74
+ }
75
+
76
+ export function effect(fn: () => Cleanup | void): () => void {
77
+ let cleanup: Cleanup;
78
+ const runner: Runner = { deps: new Set(), run: () => {} };
79
+
80
+ runner.run = function runEffect() {
81
+ cleanup?.();
82
+ cleanup = runTracked(runner, fn as () => Cleanup) ?? undefined;
83
+ };
84
+ runner.run();
85
+
86
+ return function dispose() {
87
+ cleanup?.();
88
+ detach(runner);
89
+ };
90
+ }
91
+
92
+ export function computed<T>(fn: () => T): ReadonlySig<T> {
93
+ const out = signal<T>(undefined as T);
94
+ const dispose = effect(() => {
95
+ out.value = fn();
96
+ });
97
+
98
+ return {
99
+ get value(): T {
100
+ return out.value;
101
+ },
102
+ peek: () => out.peek(),
103
+ dispose,
104
+ };
105
+ }
106
+
107
+ export function batch<T>(fn: () => T): T {
108
+ if (batching !== null) return fn();
109
+ batching = new Set();
110
+ try {
111
+ return fn();
112
+ } finally {
113
+ const queued = batching;
114
+
115
+ batching = null;
116
+ queued.forEach((runner) => runner.run());
117
+ }
118
+ }
119
+
120
+ export function untrack<T>(fn: () => T): T {
121
+ const previous = active;
122
+
123
+ active = null;
124
+ try {
125
+ return fn();
126
+ } finally {
127
+ active = previous;
128
+ }
129
+ }
@@ -0,0 +1,20 @@
1
+ let mutationDepth = 0;
2
+
3
+ /** Runs `fn` with state mutations enabled (used by intent/effect/event runners). */
4
+ export function allowMutations<T>(fn: () => T): T {
5
+ mutationDepth += 1;
6
+ try {
7
+ return fn();
8
+ } finally {
9
+ mutationDepth -= 1;
10
+ }
11
+ }
12
+
13
+ export function assertMutable(path: string): void {
14
+ if (mutationDepth > 0) return;
15
+
16
+ throw new Error(
17
+ `Janux: illegal mutation of "${path}" outside an intent, effect or event handler. ` +
18
+ 'State can only change inside declared run() bodies (RFC §4.4).',
19
+ );
20
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { effect } from '../signals';
3
+ import { allowMutations } from './mutation-gate';
4
+ import { createReactiveState } from './reactive-state';
5
+
6
+ const initial = () => ({
7
+ items: [{ id: 'a', qty: 1 }],
8
+ coupon: null as string | null,
9
+ });
10
+
11
+ describe('reactive state', () => {
12
+ it('reads initial data through the proxy', () => {
13
+ const state = createReactiveState(initial());
14
+
15
+ expect(state.proxy.items[0]!.qty).toBe(1);
16
+ expect(state.proxy.coupon).toBe(null);
17
+ });
18
+
19
+ it('throws on mutation outside a run context', () => {
20
+ const state = createReactiveState(initial());
21
+
22
+ expect(() => {
23
+ state.proxy.coupon = 'SAVE10';
24
+ }).toThrow(/illegal mutation of "coupon"/);
25
+ expect(() => state.proxy.items.push({ id: 'b', qty: 2 })).toThrow(/illegal mutation/);
26
+ });
27
+
28
+ it('mutates inside allowMutations and notifies path readers', () => {
29
+ const state = createReactiveState(initial());
30
+ const runs = mock(() => {});
31
+
32
+ effect(() => {
33
+ runs();
34
+ state.proxy.items[0]!.qty;
35
+ });
36
+ allowMutations(() => {
37
+ state.proxy.items[0]!.qty = 5;
38
+ });
39
+ expect(runs).toHaveBeenCalledTimes(2);
40
+ expect(state.snapshot().items[0]!.qty).toBe(5);
41
+ });
42
+
43
+ it('notifies list readers on push and filter-reassign', () => {
44
+ const state = createReactiveState(initial());
45
+ const lengths: number[] = [];
46
+
47
+ effect(() => {
48
+ lengths.push(state.proxy.items.length);
49
+ });
50
+ allowMutations(() => {
51
+ state.proxy.items.push({ id: 'b', qty: 2 });
52
+ });
53
+ allowMutations(() => {
54
+ state.proxy.items = state.proxy.items.filter((item) => item.id === 'b');
55
+ });
56
+ expect(lengths).toEqual([1, 2, 1]);
57
+ expect(state.snapshot().items).toEqual([{ id: 'b', qty: 2 }]);
58
+ });
59
+
60
+ it('does not notify readers of untouched sibling paths', () => {
61
+ const state = createReactiveState(initial());
62
+ const runs = mock(() => {});
63
+
64
+ effect(() => {
65
+ runs();
66
+ state.proxy.coupon;
67
+ });
68
+ allowMutations(() => {
69
+ state.proxy.items[0]!.qty = 9;
70
+ });
71
+ expect(runs).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('snapshot is plain data detached from the proxy', () => {
75
+ const state = createReactiveState(initial());
76
+ const snap = state.snapshot();
77
+
78
+ snap.items[0]!.qty = 99;
79
+ expect(state.proxy.items[0]!.qty).toBe(1);
80
+ expect(JSON.parse(JSON.stringify(snap)).items[0].qty).toBe(99);
81
+ });
82
+
83
+ });
@@ -0,0 +1,139 @@
1
+ import { batch, signal, untrack, type Sig } from '../signals';
2
+ import { assertMutable } from './mutation-gate';
3
+
4
+ const MUTATING_ARRAY_METHODS = new Set([
5
+ 'push',
6
+ 'pop',
7
+ 'shift',
8
+ 'unshift',
9
+ 'splice',
10
+ 'sort',
11
+ 'reverse',
12
+ 'fill',
13
+ 'copyWithin',
14
+ ]);
15
+
16
+ export interface ReactiveState<T extends object = Record<string, unknown>> {
17
+ proxy: T;
18
+ snapshot(): T;
19
+ }
20
+
21
+ function isPlainContainer(value: unknown): value is object {
22
+ return typeof value === 'object' && value !== null;
23
+ }
24
+
25
+ /** Deep-clones through proxies into plain JSON data (state is JSON-safe by schema). */
26
+ function plainify<T>(value: T): T {
27
+ if (Array.isArray(value)) return untrack(() => value.map(plainify)) as T;
28
+ if (isPlainContainer(value)) return untrack(() => plainObject(value)) as T;
29
+
30
+ return value;
31
+ }
32
+
33
+ function plainObject(value: object): Record<string, unknown> {
34
+ return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, plainify(v)]));
35
+ }
36
+
37
+ export function createReactiveState<T extends object>(initial: T): ReactiveState<T> {
38
+ const versions = new Map<string, Sig<number>>();
39
+ let data = structuredClone(initial);
40
+
41
+ const versionOf = (path: string): Sig<number> => {
42
+ const existing = versions.get(path);
43
+
44
+ if (existing) return existing;
45
+ const created = signal(0);
46
+
47
+ versions.set(path, created);
48
+
49
+ return created;
50
+ };
51
+
52
+ const bump = (path: string): void => {
53
+ versionOf(path).value += 1;
54
+ };
55
+
56
+ const bumpDescendants = (path: string): void => {
57
+ const prefix = path === '' ? '' : `${path}.`;
58
+
59
+ [...versions.keys()].filter((key) => key !== path && key.startsWith(prefix)).forEach(bump);
60
+ };
61
+
62
+ const bumpAncestors = (path: string): void => {
63
+ const parts = path.split('.').slice(0, -1);
64
+
65
+ parts.forEach((_, index) => bump(parts.slice(0, index + 1).join('.')));
66
+ bump('');
67
+ };
68
+
69
+ const touch = (path: string): void => {
70
+ batch(() => {
71
+ bump(path);
72
+ bumpDescendants(path);
73
+ bumpAncestors(path);
74
+ });
75
+ };
76
+
77
+ const childPath = (path: string, key: string): string => (path === '' ? key : `${path}.${key}`);
78
+
79
+ const wrapArrayMethod = (target: unknown[], path: string, method: string) => {
80
+ return (...args: unknown[]) => {
81
+ assertMutable(path);
82
+ const result = (target as any)[method](...args.map(plainify));
83
+
84
+ touch(path);
85
+
86
+ return result;
87
+ };
88
+ };
89
+
90
+ const proxyFor = (target: object, path: string): object => {
91
+ return new Proxy(target, {
92
+ get: (raw, key) => readTrap(raw, path, key),
93
+ set: (raw, key, value) => writeTrap(raw, path, key, value),
94
+ deleteProperty: (raw, key) => deleteTrap(raw, path, key),
95
+ });
96
+ };
97
+
98
+ const readTrap = (raw: object, path: string, key: string | symbol): unknown => {
99
+ if (typeof key === 'symbol') return Reflect.get(raw, key);
100
+ if (Array.isArray(raw) && MUTATING_ARRAY_METHODS.has(key)) {
101
+ return wrapArrayMethod(raw as unknown[], path, key);
102
+ }
103
+ const value = Reflect.get(raw, key);
104
+
105
+ if (typeof value === 'function') return value.bind(proxyFor(raw, path));
106
+ versionOf(childPath(path, key)).value;
107
+
108
+ return isPlainContainer(value) ? proxyFor(value, childPath(path, key)) : value;
109
+ };
110
+
111
+ const writeTrap = (raw: object, path: string, key: string | symbol, value: unknown): boolean => {
112
+ if (typeof key === 'symbol') return Reflect.set(raw, key, value);
113
+ const target = childPath(path, key);
114
+
115
+ assertMutable(target);
116
+ Reflect.set(raw, key, plainify(value));
117
+ touch(target);
118
+
119
+ return true;
120
+ };
121
+
122
+ const deleteTrap = (raw: object, path: string, key: string | symbol): boolean => {
123
+ if (typeof key === 'symbol') return Reflect.deleteProperty(raw, key);
124
+ const target = childPath(path, key);
125
+
126
+ assertMutable(target);
127
+ Reflect.deleteProperty(raw, key);
128
+ touch(target);
129
+
130
+ return true;
131
+ };
132
+
133
+ return {
134
+ get proxy(): T {
135
+ return proxyFor(data, '') as T;
136
+ },
137
+ snapshot: () => structuredClone(data),
138
+ };
139
+ }