@thyn/core 0.0.1

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/src/element.ts ADDED
@@ -0,0 +1,408 @@
1
+ import { $effect, cleanup } from "./signals";
2
+
3
+ export function mount(app, parent) {
4
+ parent.appendChild(component(app));
5
+ }
6
+
7
+ const TEXT_NODE_TEMPLATE = document.createTextNode("");
8
+ export const effects = new Map();
9
+ export let currentEffects: any | null = null;
10
+
11
+ export function createReactiveTextNode(v) {
12
+ let n;
13
+ $effect(() => {
14
+ if (n) {
15
+ n.nodeValue = v();
16
+ } else {
17
+ n = document.createTextNode(v());
18
+ }
19
+ });
20
+ return n;
21
+ }
22
+
23
+ export function component(name, props?: any) {
24
+ const prevEffects = currentEffects;
25
+ currentEffects = [];
26
+ const e = name(props);
27
+ const existing = effects.get(e);
28
+ if (existing) {
29
+ existing.push(...currentEffects);
30
+ } else {
31
+ effects.set(e, currentEffects);
32
+ }
33
+ currentEffects = prevEffects;
34
+ return e;
35
+ }
36
+
37
+ export function setAttribute(el, key, val) {
38
+ if (val) el.setAttribute(key, val);
39
+ return el;
40
+ }
41
+ export function setProperty(el, key, val) {
42
+ if (val) el[key] = val;
43
+ return el;
44
+ }
45
+ export function setReactiveAttribute(el, key, val) {
46
+ let first = true;
47
+ addEffect(
48
+ el,
49
+ $effect(() => {
50
+ const v = val();
51
+ if (first) {
52
+ if (v !== undefined) el.setAttribute(key, val());
53
+ first = false;
54
+ return;
55
+ }
56
+ if (v === undefined) el.removeAttribute(key);
57
+ else el.setAttribute(key, v);
58
+ }),
59
+ );
60
+ return el;
61
+ }
62
+ export function setReactiveProperty(el, key, val) {
63
+ let first = true;
64
+ addEffect(
65
+ el,
66
+ $effect(() => {
67
+ const v = val();
68
+ if (first) {
69
+ if (v !== undefined) el[key] = v;
70
+ first = false;
71
+ return;
72
+ }
73
+ if (v === undefined) delete el[key];
74
+ else el[key] = v;
75
+ }),
76
+ );
77
+ return el;
78
+ }
79
+
80
+ export function addChildren(e, children) {
81
+ for (const ch of children) {
82
+ e.appendChild(ch);
83
+ }
84
+ return e;
85
+ }
86
+
87
+ export function markAsReactive(el) {
88
+ if (!effects.has(el)) effects.set(el, []);
89
+ return el;
90
+ }
91
+
92
+ export function addEffect(el, ef) {
93
+ if (effects.has(el)) {
94
+ effects.get(el).push(ef);
95
+ } else {
96
+ effects.set(el, [ef]);
97
+ }
98
+ return el;
99
+ }
100
+
101
+ function teardown(elem, iterating = false) {
102
+ let end;
103
+ let start;
104
+ if (elem.nodeType === 8) { // COMMENT_NODE
105
+ const bookends = fragments.get(elem);
106
+ if (!bookends) {
107
+ return;
108
+ }
109
+ fragments.delete(elem);
110
+ start = elem;
111
+ [elem, end] = bookends;
112
+ }
113
+ const fx = effects.get(elem);
114
+ if (fx) {
115
+ for (const eff of fx) {
116
+ cleanup(eff);
117
+ }
118
+ effects.delete(elem);
119
+ if (end && elem.nodeType === 11) { // DOCUMENT_FRAGMENT_NODE
120
+ if (iterating) return;
121
+ while (end.previousSibling !== start) {
122
+ teardown(end.previousSibling);
123
+ end = end.previousSibling;
124
+ }
125
+ } else {
126
+ for (const ch of elem.childNodes) {
127
+ teardown(ch, true);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ function remove(elem) {
134
+ if (!elem.parentNode) {
135
+ const [, end] = fragments.get(elem);
136
+ if (!elem.previousSibling && !end.nextSibling) {
137
+ elem.parentNode.textContent = "";
138
+ return;
139
+ }
140
+ while (end.previousSibling !== elem) {
141
+ end.previousSibling.remove();
142
+ }
143
+ end.remove();
144
+ }
145
+ elem.remove();
146
+ }
147
+
148
+ // export function first(props) {
149
+ // let prevIndex = -1;
150
+ // let prevElem: Element | null = null;
151
+
152
+ // const eff = $effect(() => {
153
+ // const currIndex = props.cases.findIndex((c) => c.condition());
154
+ // if (currIndex === prevIndex) {
155
+ // if (!prevElem) {
156
+ // prevElem = props.default?.() ?? document.createTextNode("");
157
+ // }
158
+ // return;
159
+ // }
160
+ // let newElem;
161
+ // if (currIndex < 0) {
162
+ // if (props.default) newElem = props.default();
163
+ // else newElem = document.createTextNode("");
164
+ // } else {
165
+ // newElem = props.cases[currIndex].element();
166
+ // }
167
+ // if (prevElem) {
168
+ // effects.get(prevElem)?.delete(eff);
169
+ // const fx = effects.get(newElem) ?? new Set();
170
+ // fx.add(eff);
171
+ // effects.set(newElem, fx);
172
+ // prevElem.replaceWith(newElem);
173
+ // const td = prevElem;
174
+ // queueMicrotask(() => {
175
+ // teardown(td);
176
+ // });
177
+ // }
178
+ // prevElem = newElem;
179
+ // prevIndex = currIndex;
180
+ // });
181
+
182
+ // return prevElem;
183
+ // }
184
+
185
+ export function show(props) {
186
+ let prevElem: Element | null = null;
187
+ let prevCondition = null;
188
+ const eff = $effect(() => {
189
+ const newCondition = props.if();
190
+ if (prevCondition === null) {
191
+ prevCondition = newCondition;
192
+ prevElem = newCondition
193
+ ? props.then()
194
+ : props.else?.() ?? TEXT_NODE_TEMPLATE.cloneNode();
195
+ return;
196
+ }
197
+ if (newCondition !== prevCondition) {
198
+ prevCondition = newCondition;
199
+ let newElem = newCondition
200
+ ? props.then()
201
+ : props.else?.() ?? TEXT_NODE_TEMPLATE.cloneNode();
202
+ const prevFx = effects.get(prevElem);
203
+ if (prevFx) {
204
+ effects.set(prevElem, prevFx.filter((f) => f !== eff));
205
+ }
206
+ const fx = effects.get(newElem);
207
+ if (fx) {
208
+ fx.push(eff);
209
+ } else {
210
+ effects.set(newElem, [eff]);
211
+ }
212
+ let td = prevElem;
213
+ queueMicrotask(() => {
214
+ teardown(td);
215
+ td.replaceWith(newElem);
216
+ td = null;
217
+ });
218
+ prevElem = newElem;
219
+ }
220
+ });
221
+ return prevElem;
222
+ }
223
+
224
+ const replaceWith = (nextItem, prevElement, render) => {
225
+ teardown(prevElement);
226
+ prevElement.replaceWith(render(nextItem));
227
+ };
228
+
229
+ const fragments = new Map();
230
+ const COMMENT_NODE_TEMPLATE = document.createComment("");
231
+
232
+ export function terminalList(props) {
233
+ return list(props, true);
234
+ }
235
+
236
+ export function list(props, terminal = false) {
237
+ let prevItems;
238
+ let outlet = document.createDocumentFragment();
239
+ const startBookend = COMMENT_NODE_TEMPLATE.cloneNode() as ChildNode;
240
+ const endBookend = COMMENT_NODE_TEMPLATE.cloneNode() as ChildNode;
241
+ const render = props.render;
242
+ const keyMap = new Map();
243
+ let isolated = false;
244
+
245
+ $effect(() => {
246
+ const parent = startBookend.parentNode;
247
+ if (!parent) {
248
+ prevItems = props.items();
249
+ for (const i of prevItems) {
250
+ outlet.appendChild(render(i));
251
+ }
252
+ return;
253
+ }
254
+ let nextItems = props.items();
255
+ let newLength = nextItems.length;
256
+ let oldLength = prevItems.length;
257
+ if (!oldLength && newLength) {
258
+ for (const i of nextItems) {
259
+ parent.insertBefore(render(i), endBookend);
260
+ }
261
+ prevItems = nextItems;
262
+ return;
263
+ }
264
+ if (!newLength && isolated) {
265
+ const end = parent.childNodes.length - 1;
266
+ if (terminal) {
267
+ for (let i = 1; i < end; i++) {
268
+ const ch = parent.childNodes[i];
269
+ for (const f of effects.get(ch)) cleanup(f);
270
+ effects.delete(ch);
271
+ }
272
+ } else {
273
+ for (let i = 1; i < parent.childNodes.length - 1; i++) {
274
+ teardown(parent.childNodes[i]);
275
+ }
276
+ }
277
+ parent.textContent = "";
278
+ parent.append(startBookend, endBookend);
279
+ prevItems = nextItems;
280
+ return;
281
+ }
282
+
283
+ let start = nextItems.findIndex((item, index) => prevItems[index] !== item);
284
+ if (start === oldLength) {
285
+ for (let i = start; i < newLength; i++) {
286
+ parent.insertBefore(render(nextItems[i]), endBookend);
287
+ }
288
+ prevItems = nextItems;
289
+ return;
290
+ }
291
+
292
+ const childNodes = Array.from(parent.childNodes);
293
+ const offset = childNodes.indexOf(startBookend) + 1;
294
+ if (start >= newLength) {
295
+ while (start < oldLength) {
296
+ const e = childNodes[offset + --oldLength];
297
+ teardown(e);
298
+ remove(e);
299
+ }
300
+ prevItems = nextItems;
301
+ return;
302
+ }
303
+
304
+ // suffix
305
+ oldLength--;
306
+ newLength--;
307
+ while (
308
+ newLength > start &&
309
+ oldLength >= start &&
310
+ (nextItems[newLength] === prevItems[oldLength])
311
+ ) {
312
+ oldLength--;
313
+ newLength--;
314
+ }
315
+
316
+ const nextKeys = new Set(nextItems);
317
+ let rem = [];
318
+ for (let i = start; i <= oldLength; i++) {
319
+ if (!nextKeys.has(prevItems[i])) {
320
+ const ch = childNodes[i + offset];
321
+ if (terminal) {
322
+ for (const f of effects.get(ch)) cleanup(f);
323
+ effects.delete(ch);
324
+ } else {
325
+ teardown(ch);
326
+ }
327
+ rem.push(ch);
328
+ childNodes[i + offset] = null;
329
+ }
330
+ }
331
+ if (isolated && rem.length === prevItems.length) {
332
+ parent.textContent = "";
333
+ parent.appendChild(startBookend);
334
+ for (const i of nextItems) {
335
+ parent.appendChild(render(i));
336
+ }
337
+ parent.appendChild(endBookend);
338
+ prevItems = nextItems;
339
+ rem = null;
340
+ return;
341
+ }
342
+ for (const e of rem) {
343
+ remove(e);
344
+ }
345
+ rem = null;
346
+ for (let i = start; i <= oldLength; i++) {
347
+ if (
348
+ childNodes[i + offset] &&
349
+ (!nextItems[i] ||
350
+ prevItems[i] !== nextItems[i])
351
+ ) {
352
+ keyMap.set(prevItems[i], {
353
+ element: childNodes[i + offset],
354
+ item: prevItems[i],
355
+ });
356
+ }
357
+ }
358
+ if (newLength === oldLength && keyMap.size > (newLength - start + 1) / 2) {
359
+ const lastOrdered = childNodes[start + offset - 1];
360
+ const set = [];
361
+ for (let i = start; i <= newLength; i++) {
362
+ set.push(
363
+ keyMap.get(nextItems[i])?.element ?? childNodes[i + offset],
364
+ );
365
+ }
366
+ lastOrdered.after(...set);
367
+ prevItems = nextItems;
368
+ keyMap.clear();
369
+ return;
370
+ }
371
+
372
+ while (start <= newLength) {
373
+ const newChd = nextItems[start];
374
+ const oldChd = prevItems[start];
375
+ const oldDom = parent!.childNodes[start + offset];
376
+ const mappedOld = keyMap.get(newChd);
377
+ if (oldChd === undefined) {
378
+ endBookend.before(render(newChd));
379
+ } else if (mappedOld) {
380
+ if (oldDom !== mappedOld.element) {
381
+ const tmp = mappedOld.element.nextSibling;
382
+ parent.insertBefore(mappedOld.element, oldDom);
383
+ parent.insertBefore(oldDom, tmp);
384
+ }
385
+ if (mappedOld.item !== newChd) {
386
+ replaceWith(newChd, mappedOld.element, render);
387
+ }
388
+ keyMap.delete(newChd);
389
+ } else if (oldChd !== newChd) {
390
+ parent.insertBefore(render(newChd), oldDom);
391
+ }
392
+ start++;
393
+ }
394
+ for (const v of keyMap.values()) {
395
+ const el = v.element;
396
+ teardown(el);
397
+ remove(el);
398
+ }
399
+ keyMap.clear();
400
+ prevItems = nextItems;
401
+ nextItems = null;
402
+ });
403
+ outlet.prepend(startBookend);
404
+ outlet.append(endBookend);
405
+ fragments.set(startBookend, [outlet, endBookend]);
406
+ isolated = !startBookend.previousSibling && !endBookend.nextSibling;
407
+ return outlet;
408
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export {
2
+ addChildren,
3
+ addEffect,
4
+ component,
5
+ createReactiveTextNode,
6
+ list,
7
+ markAsReactive,
8
+ mount,
9
+ setAttribute,
10
+ setProperty,
11
+ setReactiveAttribute,
12
+ setReactiveProperty,
13
+ show,
14
+ terminalList,
15
+ } from "./element";
16
+ export { $compare, $computed, $effect, $state } from "./signals";
package/src/signals.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { currentEffects } from "./element";
2
+
3
+ let currentEffect = null;
4
+
5
+ let isBatching = false;
6
+ const pendingEffects = new Set<any>();
7
+
8
+ function scheduleEffect(effectFn) {
9
+ pendingEffects.add(effectFn);
10
+ if (!isBatching) {
11
+ isBatching = true;
12
+ queueMicrotask(() => {
13
+ for (const ef of pendingEffects) ef.run();
14
+ pendingEffects.clear();
15
+ isBatching = false;
16
+ });
17
+ }
18
+ }
19
+
20
+ export function $state<T>(
21
+ initialValue: T,
22
+ ): [() => T, ((action: T | ((prev: T) => T)) => void)] {
23
+ let value = initialValue;
24
+ const subscribers = new Set<any>();
25
+ return [
26
+ () => {
27
+ if (currentEffect) {
28
+ subscribers.add(currentEffect);
29
+ currentEffect.deps.add(subscribers);
30
+ }
31
+ return value;
32
+ },
33
+ (action) => {
34
+ const newValue = typeof action === "function"
35
+ ? (action as Function)(value)
36
+ : action;
37
+ if (newValue !== value) {
38
+ value = newValue;
39
+ for (const sub of subscribers) {
40
+ scheduleEffect(sub);
41
+ }
42
+ }
43
+ },
44
+ ];
45
+ }
46
+
47
+ /**
48
+ * Creates a reactive equality checker function based on a reactive source.
49
+ *
50
+ * This is useful when you want to conditionally react to equality against a selected value,
51
+ * such as highlighting a selected item in a list. Only effects that call the returned function
52
+ * with the **current** value will re-run when the source value changes.
53
+ *
54
+ * Example:
55
+ *
56
+ * ```ts
57
+ * const [selectedId, setSelectedId] = $state(null);
58
+ * const isSelected = $compare(selectedId);
59
+ *
60
+ * $effect(() => {
61
+ * if (isSelected(row.id)) {
62
+ * // React only if row.id === selectedId()
63
+ * }
64
+ * });
65
+ * ```
66
+ *
67
+ * Internally, only effects that compare against the current or previous selected value
68
+ * are re-evaluated when the source changes. This is especially efficient in large lists.
69
+ *
70
+ * @template T The type of the reactive value being compared.
71
+ * @param {() => T} fn A reactive function returning the current value to compare against.
72
+ * @returns {(value: T) => boolean} A function that returns true if the provided value
73
+ * matches the current value from `fn()`. Automatically subscribes the calling effect
74
+ * to changes in that specific value.
75
+ */
76
+ export function $compare<T>(fn: () => T): (value: T) => boolean {
77
+ const map = new Map<T, Set<any>>();
78
+ let current: T = fn();
79
+
80
+ $effect(() => {
81
+ const newValue = fn();
82
+ if (newValue === current) return;
83
+
84
+ const prevSubs = map.get(current);
85
+ const nextSubs = map.get(newValue);
86
+
87
+ current = newValue;
88
+
89
+ // Only notify subscribers for new and old values
90
+ if (prevSubs) {
91
+ for (const sub of prevSubs) scheduleEffect(sub);
92
+ }
93
+ if (nextSubs) {
94
+ for (const sub of nextSubs) scheduleEffect(sub);
95
+ }
96
+ });
97
+
98
+ return (value: T) => {
99
+ if (currentEffect) {
100
+ let subs = map.get(value);
101
+ if (!subs) map.set(value, subs = new Set());
102
+ subs.add(currentEffect);
103
+ currentEffect.deps.add(subs);
104
+ currentEffect.teardown ??= [];
105
+ currentEffect.teardown.push(() => {
106
+ if (!subs.size) {
107
+ map.delete(value);
108
+ }
109
+ });
110
+ }
111
+ return current === value;
112
+ };
113
+ }
114
+
115
+ export function $effect(fn) {
116
+ let ran = false;
117
+ const runEffect = () => {
118
+ if (ran) cleanup(effectFn);
119
+ else ran = true;
120
+ const prev = currentEffect;
121
+ currentEffect = effectFn;
122
+ const td = fn();
123
+ if (td) {
124
+ effectFn.teardown ??= [];
125
+ effectFn.teardown.push(td);
126
+ }
127
+ currentEffect = prev;
128
+ };
129
+ const effectFn: {
130
+ teardown?: (() => void)[];
131
+ run: () => void;
132
+ deps: Set<any>;
133
+ } = {
134
+ run: runEffect,
135
+ deps: new Set(),
136
+ };
137
+ runEffect();
138
+ currentEffects?.push(effectFn);
139
+ return effectFn;
140
+ }
141
+
142
+ export function cleanup(effectFn) {
143
+ for (const subs of effectFn.deps) {
144
+ subs.delete(effectFn);
145
+ }
146
+ effectFn.deps.clear();
147
+ if (effectFn.teardown) {
148
+ for (const f of effectFn.teardown) f();
149
+ }
150
+ }
151
+
152
+ export function $computed(fn) {
153
+ const [result, setResult] = $state(undefined);
154
+ $effect(() => setResult(fn()));
155
+ return result;
156
+ }