@thyn/core 0.0.39 → 0.0.41

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