@derivesome/tree 0.1.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/.package.json.~undo-tree~ +4 -0
- package/.tsconfig.json.~undo-tree~ +4 -0
- package/dist/cjs/brands.d.ts +3 -0
- package/dist/cjs/brands.d.ts.map +1 -0
- package/dist/cjs/brands.js +5 -0
- package/dist/cjs/brands.js.map +1 -0
- package/dist/cjs/context.d.ts +28 -0
- package/dist/cjs/context.d.ts.map +1 -0
- package/dist/cjs/context.js +48 -0
- package/dist/cjs/context.js.map +1 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/mount.d.ts +6 -0
- package/dist/cjs/mount.d.ts.map +1 -0
- package/dist/cjs/mount.js +209 -0
- package/dist/cjs/mount.js.map +1 -0
- package/dist/cjs/props.d.ts +12 -0
- package/dist/cjs/props.d.ts.map +1 -0
- package/dist/cjs/props.js +80 -0
- package/dist/cjs/props.js.map +1 -0
- package/dist/cjs/renderer.d.ts +29 -0
- package/dist/cjs/renderer.d.ts.map +1 -0
- package/dist/cjs/renderer.js +3 -0
- package/dist/cjs/renderer.js.map +1 -0
- package/dist/cjs/tree-node-like.d.ts +8 -0
- package/dist/cjs/tree-node-like.d.ts.map +1 -0
- package/dist/cjs/tree-node-like.js +4 -0
- package/dist/cjs/tree-node-like.js.map +1 -0
- package/dist/cjs/tree.d.ts +46 -0
- package/dist/cjs/tree.d.ts.map +1 -0
- package/dist/cjs/tree.js +154 -0
- package/dist/cjs/tree.js.map +1 -0
- package/dist/cjs/velement.d.ts +185 -0
- package/dist/cjs/velement.d.ts.map +1 -0
- package/dist/cjs/velement.js +874 -0
- package/dist/cjs/velement.js.map +1 -0
- package/dist/esm/brands.d.ts +3 -0
- package/dist/esm/brands.d.ts.map +1 -0
- package/dist/esm/brands.js +5 -0
- package/dist/esm/brands.js.map +1 -0
- package/dist/esm/context.d.ts +28 -0
- package/dist/esm/context.d.ts.map +1 -0
- package/dist/esm/context.js +48 -0
- package/dist/esm/context.js.map +1 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +22 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/mount.d.ts +6 -0
- package/dist/esm/mount.d.ts.map +1 -0
- package/dist/esm/mount.js +209 -0
- package/dist/esm/mount.js.map +1 -0
- package/dist/esm/props.d.ts +12 -0
- package/dist/esm/props.d.ts.map +1 -0
- package/dist/esm/props.js +80 -0
- package/dist/esm/props.js.map +1 -0
- package/dist/esm/renderer.d.ts +29 -0
- package/dist/esm/renderer.d.ts.map +1 -0
- package/dist/esm/renderer.js +3 -0
- package/dist/esm/renderer.js.map +1 -0
- package/dist/esm/tree-node-like.d.ts +8 -0
- package/dist/esm/tree-node-like.d.ts.map +1 -0
- package/dist/esm/tree-node-like.js +4 -0
- package/dist/esm/tree-node-like.js.map +1 -0
- package/dist/esm/tree.d.ts +46 -0
- package/dist/esm/tree.d.ts.map +1 -0
- package/dist/esm/tree.js +154 -0
- package/dist/esm/tree.js.map +1 -0
- package/dist/esm/velement.d.ts +185 -0
- package/dist/esm/velement.d.ts.map +1 -0
- package/dist/esm/velement.js +874 -0
- package/dist/esm/velement.js.map +1 -0
- package/package.json +46 -0
- package/package.json~ +52 -0
- package/src/#mount.test.ts# +372 -0
- package/src/.brands.ts.~undo-tree~ +6 -0
- package/src/.context.ts.~undo-tree~ +6 -0
- package/src/.index.ts.~undo-tree~ +11 -0
- package/src/.mount.test.ts.~undo-tree~ +438 -0
- package/src/.mount.ts.~undo-tree~ +70 -0
- package/src/.node-like.ts.~undo-tree~ +8 -0
- package/src/.props.ts.~undo-tree~ +125 -0
- package/src/.renderer.ts.~undo-tree~ +18 -0
- package/src/.tree-node-like.ts.~undo-tree~ +12 -0
- package/src/.tree.ts.~undo-tree~ +46 -0
- package/src/.velement.ts.~undo-tree~ +1739 -0
- package/src/brands.ts +2 -0
- package/src/brands.ts~ +0 -0
- package/src/context.ts +61 -0
- package/src/context.ts~ +0 -0
- package/src/index.ts +5 -0
- package/src/index.ts~ +4 -0
- package/src/mount.test.ts +405 -0
- package/src/mount.test.ts~ +375 -0
- package/src/mount.ts +332 -0
- package/src/mount.ts~ +306 -0
- package/src/node-like.ts~ +0 -0
- package/src/props.ts +99 -0
- package/src/props.ts~ +86 -0
- package/src/renderer.ts +37 -0
- package/src/renderer.ts~ +37 -0
- package/src/tree-node-like.ts +8 -0
- package/src/tree-node-like.ts~ +6 -0
- package/src/tree.ts +226 -0
- package/src/tree.ts~ +227 -0
- package/src/velement.ts +990 -0
- package/src/velement.ts~ +966 -0
- package/tsconfig.cjs.json +10 -0
- package/tsconfig.esm.json +10 -0
- package/tsconfig.json +23 -0
- package/tsconfig.json~ +23 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import * as F from './velement';
|
|
2
|
+
import { Renderer } from './renderer';
|
|
3
|
+
import { describe, expect, assert } from 'vitest';
|
|
4
|
+
import { mount } from './mount';
|
|
5
|
+
import { tn } from './tree';
|
|
6
|
+
import { derived, ref } from '@derivesome/core';
|
|
7
|
+
|
|
8
|
+
type BrowserEnv = {
|
|
9
|
+
doc: F.VDocument;
|
|
10
|
+
root: F.VElement;
|
|
11
|
+
renderer: Renderer<F.VNode>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
const createEnv = (): BrowserEnv => {
|
|
16
|
+
const doc = F.createDocument();
|
|
17
|
+
const root = doc.createElement('div');
|
|
18
|
+
|
|
19
|
+
const renderer: Renderer<F.VNode> = {
|
|
20
|
+
createComment: (content) => doc.createComment(content),
|
|
21
|
+
createText: (content) => doc.createTextNode(content),
|
|
22
|
+
createElement: (tag) => doc.createElement(tag),
|
|
23
|
+
createMarker: (name) => doc.createComment(name || '?'),
|
|
24
|
+
insertBefore: (parent, child, before) => parent.insertBefore(child, before),
|
|
25
|
+
removeChild: (parent, child) => parent.removeChild(child),
|
|
26
|
+
setProp: (node, key, value) => (node as F.VElement).setAttribute(key, value + ''),
|
|
27
|
+
removeProp: (node, key) => (node as F.VElement).removeAttribute(key),
|
|
28
|
+
setText: (node, text) => (node as F.VText).data = text,
|
|
29
|
+
moveRange: (parent, startMarker, endMarker, before) => {
|
|
30
|
+
const list = parent.childNodes;
|
|
31
|
+
const startIdx = list.indexOf(startMarker);
|
|
32
|
+
const endIdx = list.indexOf(endMarker);
|
|
33
|
+
if (startIdx === -1 || endIdx === -1) return;
|
|
34
|
+
const range = list.splice(startIdx, endIdx - startIdx + 1);
|
|
35
|
+
const insertAt =
|
|
36
|
+
before === null ? list.length : list.indexOf(before);
|
|
37
|
+
list.splice(insertAt, 0, ...range);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
doc,
|
|
44
|
+
renderer,
|
|
45
|
+
root
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
describe('mount', it => {
|
|
51
|
+
it('mounts simple text node', () => {
|
|
52
|
+
const env = createEnv();
|
|
53
|
+
mount(tn.value('foobar'), env.renderer, env.root);
|
|
54
|
+
|
|
55
|
+
expect(env.root.childNodes.length === 1);
|
|
56
|
+
const child = env.root.childNodes[0]!;
|
|
57
|
+
expect(child).toBeInstanceOf(F.VText);
|
|
58
|
+
expect(child).toMatchObject({ data: 'foobar' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('mounts simple number node', () => {
|
|
62
|
+
const env = createEnv();
|
|
63
|
+
mount(tn.value(203), env.renderer, env.root);
|
|
64
|
+
|
|
65
|
+
expect(env.root.childNodes.length === 1);
|
|
66
|
+
const child = env.root.childNodes[0]!;
|
|
67
|
+
expect(child).toBeInstanceOf(F.VText);
|
|
68
|
+
expect(child).toMatchObject({ data: '203' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('mounts simple true node', () => {
|
|
72
|
+
const env = createEnv();
|
|
73
|
+
mount(tn.value(true), env.renderer, env.root);
|
|
74
|
+
|
|
75
|
+
expect(env.root.childNodes.length === 1);
|
|
76
|
+
const child = env.root.childNodes[0]!;
|
|
77
|
+
expect(child).toBeInstanceOf(F.VText);
|
|
78
|
+
expect(child).toMatchObject({ data: '' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('mounts simple false node', () => {
|
|
82
|
+
const env = createEnv();
|
|
83
|
+
mount(tn.value(false), env.renderer, env.root);
|
|
84
|
+
|
|
85
|
+
expect(env.root.childNodes.length === 1);
|
|
86
|
+
const child = env.root.childNodes[0]!;
|
|
87
|
+
expect(child).toBeInstanceOf(F.VText);
|
|
88
|
+
expect(child).toMatchObject({ data: '' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('mounts reactive number and response to updates', () => {
|
|
92
|
+
const env = createEnv();
|
|
93
|
+
const count = ref<number>(0);
|
|
94
|
+
mount(tn.value(count), env.renderer, env.root);
|
|
95
|
+
|
|
96
|
+
expect(env.root.childNodes.length === 1);
|
|
97
|
+
expect(env.root.childNodes[0]!).toBeInstanceOf(F.VText);
|
|
98
|
+
expect(env.root.childNodes[0]!).toMatchObject({ data: '0' });
|
|
99
|
+
|
|
100
|
+
count.set(x => x + 1);
|
|
101
|
+
|
|
102
|
+
expect(env.root.childNodes[0]!).toMatchObject({ data: '1' });
|
|
103
|
+
|
|
104
|
+
count.set(x => x + 1);
|
|
105
|
+
|
|
106
|
+
expect(env.root.childNodes[0]!).toMatchObject({ data: '2' });
|
|
107
|
+
|
|
108
|
+
count.set(x => x + 1);
|
|
109
|
+
|
|
110
|
+
expect(env.root.childNodes[0]!).toMatchObject({ data: '3' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('mounts a list', () => {
|
|
114
|
+
const env = createEnv();
|
|
115
|
+
mount(tn.element('ul', {
|
|
116
|
+
children: [
|
|
117
|
+
tn.element('li', { children: 'item 1', key: 0 }),
|
|
118
|
+
tn.element('li', { children: 'item 2', key: 1 }),
|
|
119
|
+
tn.element('li', { children: 'item 3', key: 2 }),
|
|
120
|
+
tn.element('li', { children: 'item 4', key: 3 }),
|
|
121
|
+
]
|
|
122
|
+
}), env.renderer, env.root);
|
|
123
|
+
expect(env.root.childNodes.length === 1);
|
|
124
|
+
const child = env.root.childNodes[0]!;
|
|
125
|
+
expect(child).toBeInstanceOf(F.VElement);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('mounts a reactive list', () => {
|
|
129
|
+
const env = createEnv();
|
|
130
|
+
|
|
131
|
+
let idCounter: number = 1;
|
|
132
|
+
type TodoItem = { title: string, id: number };
|
|
133
|
+
const todos = ref<TodoItem[]>([]);
|
|
134
|
+
|
|
135
|
+
const makeTodo = () => todos.set((items) => [...items, { title: `Todo number ${idCounter}`, id: idCounter++ }]);
|
|
136
|
+
const removeTodo = (id: number) => todos.set(items => items.filter(x => x.id !== id));
|
|
137
|
+
|
|
138
|
+
mount(tn.element('ul', {
|
|
139
|
+
children: tn.list(todos, (todo) => tn.element('li', { children: todo.title }))
|
|
140
|
+
}), env.renderer, env.root);
|
|
141
|
+
expect(env.root.childNodes.length === 1);
|
|
142
|
+
const child = env.root.childNodes[0]!;
|
|
143
|
+
expect(child).toBeInstanceOf(F.VElement);
|
|
144
|
+
const ul = child as F.VElement;
|
|
145
|
+
|
|
146
|
+
expect(ul.childNodes.length === 0);
|
|
147
|
+
makeTodo();
|
|
148
|
+
expect(ul.childNodes.length === 1);
|
|
149
|
+
makeTodo();
|
|
150
|
+
expect(ul.childNodes.length === 2);
|
|
151
|
+
makeTodo();
|
|
152
|
+
expect(ul.childNodes.length === 3);
|
|
153
|
+
|
|
154
|
+
// The expected sequence of todo id's right now is [1, 2, 3] (since id's start at 1)
|
|
155
|
+
const expectedIds: number[] = [1, 2, 3]
|
|
156
|
+
for (let i = 0; i < ul.children.length; i++) {
|
|
157
|
+
const el = ul.children[i]!;
|
|
158
|
+
expect(el.textContent).toBe(`Todo number ${expectedIds[i]}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
removeTodo(2);
|
|
162
|
+
|
|
163
|
+
expect(ul.childNodes.length === 2);
|
|
164
|
+
|
|
165
|
+
// The expected sequence of todo id's right now is [1, 3] (since we removed 2)
|
|
166
|
+
const nextExpectedIds: number[] = [1, 3];
|
|
167
|
+
for (let i = 0; i < ul.children.length; i++) {
|
|
168
|
+
const el = ul.children[i]!;
|
|
169
|
+
expect(el.textContent).toBe(`Todo number ${nextExpectedIds[i]}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('infers a reactive list from a derived and mounts', () => {
|
|
175
|
+
const env = createEnv();
|
|
176
|
+
|
|
177
|
+
let idCounter: number = 1;
|
|
178
|
+
type TodoItem = { title: string, id: number };
|
|
179
|
+
const todos = ref<TodoItem[]>([]);
|
|
180
|
+
|
|
181
|
+
const makeTodo = () => todos.set((items) => [...items, { title: `Todo number ${idCounter}`, id: idCounter++ }]);
|
|
182
|
+
const removeTodo = (id: number) => todos.set(items => items.filter(x => x.id !== id));
|
|
183
|
+
|
|
184
|
+
mount(tn.element('ul', {
|
|
185
|
+
children: derived(() => todos.get().map(todo => tn.element('li', { children: todo.title })))
|
|
186
|
+
}), env.renderer, env.root);
|
|
187
|
+
expect(env.root.childNodes.length === 1);
|
|
188
|
+
const child = env.root.childNodes[0]!;
|
|
189
|
+
expect(child).toBeInstanceOf(F.VElement);
|
|
190
|
+
const ul = child as F.VElement;
|
|
191
|
+
|
|
192
|
+
expect(ul.childNodes.length === 0);
|
|
193
|
+
makeTodo();
|
|
194
|
+
expect(ul.childNodes.length === 1);
|
|
195
|
+
makeTodo();
|
|
196
|
+
expect(ul.childNodes.length === 2);
|
|
197
|
+
makeTodo();
|
|
198
|
+
expect(ul.childNodes.length === 3);
|
|
199
|
+
|
|
200
|
+
// The expected sequence of todo id's right now is [1, 2, 3] (since id's start at 1)
|
|
201
|
+
const expectedIds: number[] = [1, 2, 3]
|
|
202
|
+
for (let i = 0; i < ul.children.length; i++) {
|
|
203
|
+
const el = ul.children[i]!;
|
|
204
|
+
expect(el.textContent).toBe(`Todo number ${expectedIds[i]}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
removeTodo(2);
|
|
208
|
+
|
|
209
|
+
expect(ul.childNodes.length === 2);
|
|
210
|
+
|
|
211
|
+
// The expected sequence of todo id's right now is [1, 3] (since we removed 2)
|
|
212
|
+
const nextExpectedIds: number[] = [1, 3];
|
|
213
|
+
for (let i = 0; i < ul.children.length; i++) {
|
|
214
|
+
const el = ul.children[i]!;
|
|
215
|
+
expect(el.textContent).toBe(`Todo number ${nextExpectedIds[i]}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(F.toHTML(ul, ' '));
|
|
219
|
+
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('sets static element attributes', () => {
|
|
223
|
+
const env = createEnv();
|
|
224
|
+
mount(tn.element('a', { href: 'https://example.com', id: 'link' }), env.renderer, env.root);
|
|
225
|
+
|
|
226
|
+
const el = env.root.childNodes[0]! as F.VElement;
|
|
227
|
+
expect(el).toBeInstanceOf(F.VElement);
|
|
228
|
+
expect(el.getAttribute('href')).toBe('https://example.com');
|
|
229
|
+
expect(el.getAttribute('id')).toBe('link');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('updates reactive element attribute when ref changes', () => {
|
|
233
|
+
const env = createEnv();
|
|
234
|
+
const disabled = ref<boolean | null>(null);
|
|
235
|
+
mount(tn.element('button', { disabled }), env.renderer, env.root);
|
|
236
|
+
|
|
237
|
+
const el = env.root.childNodes[0]! as F.VElement;
|
|
238
|
+
expect(el.hasAttribute('disabled')).toBe(false);
|
|
239
|
+
|
|
240
|
+
disabled.set(true);
|
|
241
|
+
expect(el.getAttribute('disabled')).toBe('true');
|
|
242
|
+
|
|
243
|
+
disabled.set(null);
|
|
244
|
+
expect(el.hasAttribute('disabled')).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('mounts nested elements', () => {
|
|
248
|
+
const env = createEnv();
|
|
249
|
+
mount(
|
|
250
|
+
tn.element('div', {
|
|
251
|
+
children: tn.element('p', {
|
|
252
|
+
children: tn.element('span', { children: 'hello' })
|
|
253
|
+
})
|
|
254
|
+
}),
|
|
255
|
+
env.renderer,
|
|
256
|
+
env.root,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const div = env.root.childNodes[0]! as F.VElement;
|
|
260
|
+
expect(div).toBeInstanceOf(F.VElement);
|
|
261
|
+
const p = div.children[0]!;
|
|
262
|
+
expect(p.tagName).toBe('P');
|
|
263
|
+
const span = p.children[0]!;
|
|
264
|
+
expect(span.tagName).toBe('SPAN');
|
|
265
|
+
expect(span.textContent).toBe('hello');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('mounts a void node without adding children', () => {
|
|
269
|
+
const env = createEnv();
|
|
270
|
+
mount(tn.none(), env.renderer, env.root);
|
|
271
|
+
expect(env.root.childNodes.length).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('mounts a function component', () => {
|
|
275
|
+
const env = createEnv();
|
|
276
|
+
const Greeting = (props: { name: string }) =>
|
|
277
|
+
tn.element('span', { children: `Hello, ${props.name}` });
|
|
278
|
+
|
|
279
|
+
mount(tn.fn(Greeting as any, { name: 'world' }), env.renderer, env.root);
|
|
280
|
+
|
|
281
|
+
const span = env.root.childNodes[0]! as F.VElement;
|
|
282
|
+
expect(span).toBeInstanceOf(F.VElement);
|
|
283
|
+
expect(span.textContent).toBe('Hello, world');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('cleanup removes mounted nodes from the DOM', () => {
|
|
287
|
+
const env = createEnv();
|
|
288
|
+
const cleanup = mount(tn.element('p', { children: 'bye' }), env.renderer, env.root);
|
|
289
|
+
|
|
290
|
+
expect(env.root.childNodes.length).toBe(1);
|
|
291
|
+
cleanup();
|
|
292
|
+
expect(env.root.childNodes.length).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('cleanup stops reactive updates', () => {
|
|
296
|
+
const env = createEnv();
|
|
297
|
+
const count = ref(0);
|
|
298
|
+
const cleanup = mount(tn.value(count), env.renderer, env.root);
|
|
299
|
+
|
|
300
|
+
const textNode = env.root.childNodes[0]! as F.VText;
|
|
301
|
+
expect(textNode.data).toBe('0');
|
|
302
|
+
|
|
303
|
+
count.set(1);
|
|
304
|
+
expect(textNode.data).toBe('1');
|
|
305
|
+
|
|
306
|
+
cleanup();
|
|
307
|
+
expect(env.root.childNodes.length).toBe(0);
|
|
308
|
+
|
|
309
|
+
// Updating the ref after cleanup should not throw and should not
|
|
310
|
+
// affect the (already removed) text node.
|
|
311
|
+
count.set(2);
|
|
312
|
+
expect(textNode.data).toBe('1');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('reorders list items when source order changes (explicit keys)', () => {
|
|
316
|
+
const env = createEnv();
|
|
317
|
+
type Item = { id: number; label: string };
|
|
318
|
+
const items = ref<Item[]>([
|
|
319
|
+
{ id: 1, label: 'A' },
|
|
320
|
+
{ id: 2, label: 'B' },
|
|
321
|
+
{ id: 3, label: 'C' },
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
mount(
|
|
325
|
+
tn.element('ul', {
|
|
326
|
+
children: tn.list(items, (item) =>
|
|
327
|
+
tn.element('li', { children: item.label, key: item.id }),
|
|
328
|
+
),
|
|
329
|
+
}),
|
|
330
|
+
env.renderer,
|
|
331
|
+
env.root,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const ul = env.root.childNodes[0]! as F.VElement;
|
|
335
|
+
const labelsOf = () => ul.children.map((c) => c.textContent);
|
|
336
|
+
|
|
337
|
+
expect(labelsOf()).toEqual(['A', 'B', 'C']);
|
|
338
|
+
|
|
339
|
+
// Reverse the order
|
|
340
|
+
items.set([
|
|
341
|
+
{ id: 3, label: 'C' },
|
|
342
|
+
{ id: 1, label: 'A' },
|
|
343
|
+
{ id: 2, label: 'B' },
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
expect(labelsOf()).toEqual(['C', 'A', 'B']);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('preserves existing DOM nodes when prepending to a keyed list', () => {
|
|
350
|
+
const env = createEnv();
|
|
351
|
+
type Item = { id: number; label: string };
|
|
352
|
+
const items = ref<Item[]>([{ id: 2, label: 'B' }]);
|
|
353
|
+
|
|
354
|
+
mount(
|
|
355
|
+
tn.element('ul', {
|
|
356
|
+
children: tn.list(items, (item) =>
|
|
357
|
+
tn.element('li', { children: item.label, key: item.id }),
|
|
358
|
+
),
|
|
359
|
+
}),
|
|
360
|
+
env.renderer,
|
|
361
|
+
env.root,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const ul = env.root.childNodes[0]! as F.VElement;
|
|
365
|
+
const originalB = ul.children[0]!;
|
|
366
|
+
|
|
367
|
+
items.set([{ id: 1, label: 'A' }, { id: 2, label: 'B' }]);
|
|
368
|
+
|
|
369
|
+
expect(ul.children.length).toBe(2);
|
|
370
|
+
expect(ul.children[0]!.textContent).toBe('A');
|
|
371
|
+
expect(ul.children[1]!.textContent).toBe('B');
|
|
372
|
+
// The B element should be the same DOM node (not recreated)
|
|
373
|
+
expect(ul.children[1]).toBe(originalB);
|
|
374
|
+
});
|
|
375
|
+
})
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { derived, isReference } from "@derivesome/core";
|
|
2
|
+
import { TreeContext, withCleanupScope } from "./context";
|
|
3
|
+
import { Renderer } from "./renderer";
|
|
4
|
+
import {
|
|
5
|
+
tnu,
|
|
6
|
+
TreeNode,
|
|
7
|
+
TreeNodeElement,
|
|
8
|
+
TreeNodeFunction,
|
|
9
|
+
TreeNodeList,
|
|
10
|
+
TreeNodeValue,
|
|
11
|
+
TreeNodeVoid,
|
|
12
|
+
} from "./tree";
|
|
13
|
+
import { forwardProps, TreeNodeProps } from "./props";
|
|
14
|
+
|
|
15
|
+
type Cleanup = () => void;
|
|
16
|
+
const noop: Cleanup = () => {};
|
|
17
|
+
|
|
18
|
+
type MountContext = {
|
|
19
|
+
forwardedProps: TreeNodeProps | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const createMountContext = (): MountContext => ({
|
|
23
|
+
forwardedProps: null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function isEventKey(key: string): boolean {
|
|
27
|
+
return key.length > 2 && key.startsWith("on") && /^[A-Z]/.test(key[2]!);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function applyProp<N>(
|
|
31
|
+
renderer: Renderer<N>,
|
|
32
|
+
el: N,
|
|
33
|
+
key: string,
|
|
34
|
+
value: unknown,
|
|
35
|
+
): void {
|
|
36
|
+
if (value === null || value === undefined || value === false) {
|
|
37
|
+
renderer.removeProp(el, key);
|
|
38
|
+
} else {
|
|
39
|
+
renderer.setProp(el, key, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setupProp<N>(
|
|
44
|
+
renderer: Renderer<N>,
|
|
45
|
+
el: N,
|
|
46
|
+
key: string,
|
|
47
|
+
value: unknown,
|
|
48
|
+
): Cleanup | null {
|
|
49
|
+
if (key === "children") return null;
|
|
50
|
+
if (key === "ref") return null;
|
|
51
|
+
if (isReference(value)) {
|
|
52
|
+
const r = value;
|
|
53
|
+
applyProp(renderer, el, key, r.peek());
|
|
54
|
+
return r.observe((val) => applyProp(renderer, el, key, val));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
const cleanups = value
|
|
59
|
+
.map((x) => setupProp(renderer, el, key, x))
|
|
60
|
+
.filter((v) => v !== null);
|
|
61
|
+
return () => {
|
|
62
|
+
cleanups.forEach((fn) => fn());
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof value === "function" && !isEventKey(key)) {
|
|
67
|
+
// Computed prop — wrap in derived so dependency tracking works
|
|
68
|
+
const d = derived(value as () => unknown);
|
|
69
|
+
return d.observe((val) => applyProp(renderer, el, key, val), {
|
|
70
|
+
immediate: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Static value or event handler
|
|
75
|
+
applyProp(renderer, el, key, value);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mountElement<N>(
|
|
80
|
+
ctx: MountContext,
|
|
81
|
+
node: TreeNodeElement,
|
|
82
|
+
renderer: Renderer<N>,
|
|
83
|
+
parent: N,
|
|
84
|
+
before: N | null,
|
|
85
|
+
): Cleanup {
|
|
86
|
+
const props = ctx.forwardedProps
|
|
87
|
+
? forwardProps(node.props, ctx.forwardedProps)
|
|
88
|
+
: node.props;
|
|
89
|
+
|
|
90
|
+
const el = renderer.createElement(node.tag);
|
|
91
|
+
const cleanups: Cleanup[] = [];
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(props)) {
|
|
94
|
+
const cleanup = setupProp(renderer, el, key, value);
|
|
95
|
+
if (cleanup) cleanups.push(cleanup);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
cleanups.push(
|
|
99
|
+
mountChildren(
|
|
100
|
+
{ ...ctx, forwardedProps: null },
|
|
101
|
+
node.children,
|
|
102
|
+
renderer,
|
|
103
|
+
el,
|
|
104
|
+
null,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
renderer.insertBefore(parent, el, before);
|
|
108
|
+
|
|
109
|
+
if (typeof props["ref"] === "function") {
|
|
110
|
+
(props["ref"] as (el: N) => void)(el);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
cleanups.forEach((c) => c());
|
|
115
|
+
renderer.removeChild(parent, el);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type ItemState<N> = {
|
|
120
|
+
itemStart: N;
|
|
121
|
+
itemEnd: N;
|
|
122
|
+
cleanup: Cleanup;
|
|
123
|
+
node: TreeNode;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function mountList<N>(
|
|
127
|
+
ctx: MountContext,
|
|
128
|
+
node: TreeNodeList,
|
|
129
|
+
renderer: Renderer<N>,
|
|
130
|
+
parent: N,
|
|
131
|
+
before: N | null,
|
|
132
|
+
): Cleanup {
|
|
133
|
+
const startMarker = renderer.createMarker("each");
|
|
134
|
+
const endMarker = renderer.createMarker("/each");
|
|
135
|
+
renderer.insertBefore(parent, startMarker, before);
|
|
136
|
+
renderer.insertBefore(parent, endMarker, before);
|
|
137
|
+
|
|
138
|
+
const itemMap = new Map<string | number, ItemState<N>>();
|
|
139
|
+
|
|
140
|
+
const update = (list: TreeNode[]): void => {
|
|
141
|
+
const newKeys = list.map((item, i) => {
|
|
142
|
+
const k = item.props.key;
|
|
143
|
+
return typeof k === "string" || typeof k === "number" ? k : i;
|
|
144
|
+
});
|
|
145
|
+
const newKeySet = new Set(newKeys);
|
|
146
|
+
|
|
147
|
+
// Remove items that dropped out of the list
|
|
148
|
+
for (const [key, state] of itemMap) {
|
|
149
|
+
if (!newKeySet.has(key)) {
|
|
150
|
+
state.cleanup();
|
|
151
|
+
renderer.removeChild(parent, state.itemStart);
|
|
152
|
+
renderer.removeChild(parent, state.itemEnd);
|
|
153
|
+
itemMap.delete(key);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Create newly-appeared items (appended before endMarker for now; reordered below)
|
|
158
|
+
for (let i = 0; i < list.length; i++) {
|
|
159
|
+
const key = newKeys[i]!;
|
|
160
|
+
const item = list[i]!;
|
|
161
|
+
if (!itemMap.has(key)) {
|
|
162
|
+
const itemStart = renderer.createMarker(`[${String(key)}]`);
|
|
163
|
+
const itemEnd = renderer.createMarker(`[/${String(key)}]`);
|
|
164
|
+
renderer.insertBefore(parent, itemStart, endMarker);
|
|
165
|
+
renderer.insertBefore(parent, itemEnd, endMarker);
|
|
166
|
+
const cleanup = mountNode(ctx, item, renderer, parent, itemEnd);
|
|
167
|
+
itemMap.set(key, { itemStart, itemEnd, cleanup, node: item });
|
|
168
|
+
} else {
|
|
169
|
+
// Index-based keys (no explicit key prop) mean the same position may
|
|
170
|
+
// now hold different content. Remount so the DOM stays in sync.
|
|
171
|
+
const isIndexKey = key === i;
|
|
172
|
+
if (isIndexKey) {
|
|
173
|
+
const state = itemMap.get(key)!;
|
|
174
|
+
if (state.node !== item) {
|
|
175
|
+
state.cleanup();
|
|
176
|
+
state.cleanup = mountNode(
|
|
177
|
+
ctx,
|
|
178
|
+
item,
|
|
179
|
+
renderer,
|
|
180
|
+
parent,
|
|
181
|
+
state.itemEnd,
|
|
182
|
+
);
|
|
183
|
+
state.node = item;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Reorder to match the new key sequence.
|
|
190
|
+
// Process from the end so we can use each item's start marker as the
|
|
191
|
+
// insertion cursor for the preceding item.
|
|
192
|
+
let cursor: N = endMarker;
|
|
193
|
+
for (let i = newKeys.length - 1; i >= 0; i--) {
|
|
194
|
+
const key = newKeys[i]!;
|
|
195
|
+
const state = itemMap.get(key)!;
|
|
196
|
+
renderer.moveRange(parent, state.itemStart, state.itemEnd, cursor);
|
|
197
|
+
cursor = state.itemStart;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const unsub = node.items.observe(update, { immediate: true });
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
unsub();
|
|
205
|
+
for (const [, state] of itemMap) {
|
|
206
|
+
state.cleanup();
|
|
207
|
+
renderer.removeChild(parent, state.itemStart);
|
|
208
|
+
renderer.removeChild(parent, state.itemEnd);
|
|
209
|
+
}
|
|
210
|
+
itemMap.clear();
|
|
211
|
+
renderer.removeChild(parent, startMarker);
|
|
212
|
+
renderer.removeChild(parent, endMarker);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveText(content: unknown): string {
|
|
217
|
+
if (typeof content === "boolean") return "";
|
|
218
|
+
if (isReference(content)) return String(content.peek());
|
|
219
|
+
if (typeof content === "function")
|
|
220
|
+
return String((content as () => string | number | boolean)());
|
|
221
|
+
return String(content);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mountValue<N>(
|
|
225
|
+
_ctx: MountContext,
|
|
226
|
+
node: TreeNodeValue,
|
|
227
|
+
renderer: Renderer<N>,
|
|
228
|
+
parent: N,
|
|
229
|
+
before: N | null,
|
|
230
|
+
): Cleanup {
|
|
231
|
+
const domText = renderer.createText(resolveText(node.value));
|
|
232
|
+
renderer.insertBefore(parent, domText, before);
|
|
233
|
+
|
|
234
|
+
let unsub: Cleanup | null = null;
|
|
235
|
+
|
|
236
|
+
const content = node.value;
|
|
237
|
+
|
|
238
|
+
if (isReference(content)) {
|
|
239
|
+
unsub = content.observe((val) => {
|
|
240
|
+
renderer.setText(domText, typeof val === "boolean" ? "" : String(val));
|
|
241
|
+
});
|
|
242
|
+
} else if (typeof content === "function") {
|
|
243
|
+
const d = derived(content as () => string | number | boolean);
|
|
244
|
+
unsub = d.observe((val) => {
|
|
245
|
+
renderer.setText(domText, typeof val === "boolean" ? "" : String(val));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return () => {
|
|
250
|
+
unsub?.();
|
|
251
|
+
renderer.removeChild(parent, domText);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function mountFn<N>(
|
|
256
|
+
ctx: MountContext,
|
|
257
|
+
node: TreeNodeFunction,
|
|
258
|
+
renderer: Renderer<N>,
|
|
259
|
+
parent: N,
|
|
260
|
+
before: N | null,
|
|
261
|
+
): Cleanup {
|
|
262
|
+
return TreeContext.scoped(() => {
|
|
263
|
+
const [childNode, scopeCleanup] = withCleanupScope(() =>
|
|
264
|
+
tnu.normalizeOne(node.fn(node.props)),
|
|
265
|
+
);
|
|
266
|
+
const mountCleanup = mountNode(
|
|
267
|
+
{ ...ctx, forwardedProps: forwardProps({}, node.props) },
|
|
268
|
+
childNode,
|
|
269
|
+
renderer,
|
|
270
|
+
parent,
|
|
271
|
+
before,
|
|
272
|
+
);
|
|
273
|
+
return () => {
|
|
274
|
+
mountCleanup();
|
|
275
|
+
scopeCleanup();
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function mountVoid<N>(
|
|
281
|
+
_ctx: MountContext,
|
|
282
|
+
_node: TreeNodeVoid,
|
|
283
|
+
_renderer: Renderer<N>,
|
|
284
|
+
_parent: N,
|
|
285
|
+
_before: N | null,
|
|
286
|
+
): Cleanup {
|
|
287
|
+
return noop;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mountChildren<N>(
|
|
291
|
+
ctx: MountContext,
|
|
292
|
+
children: TreeNode[],
|
|
293
|
+
renderer: Renderer<N>,
|
|
294
|
+
parent: N,
|
|
295
|
+
before: N | null,
|
|
296
|
+
): Cleanup {
|
|
297
|
+
const cleanups = children.map((child) =>
|
|
298
|
+
mountNode(ctx, child, renderer, parent, before),
|
|
299
|
+
);
|
|
300
|
+
return () => cleanups.forEach((c) => c());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function mountNode<N>(
|
|
304
|
+
ctx: MountContext,
|
|
305
|
+
node: TreeNode,
|
|
306
|
+
renderer: Renderer<N>,
|
|
307
|
+
parent: N,
|
|
308
|
+
before: N | null,
|
|
309
|
+
): Cleanup {
|
|
310
|
+
switch (node.type) {
|
|
311
|
+
case "element":
|
|
312
|
+
return mountElement(ctx, node, renderer, parent, before);
|
|
313
|
+
case "list":
|
|
314
|
+
return mountList(ctx, node, renderer, parent, before);
|
|
315
|
+
case "value":
|
|
316
|
+
return mountValue(ctx, node, renderer, parent, before);
|
|
317
|
+
case "function":
|
|
318
|
+
return mountFn(ctx, node, renderer, parent, before);
|
|
319
|
+
case "void":
|
|
320
|
+
return mountVoid(ctx, node, renderer, parent, before);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function mount<N>(
|
|
325
|
+
node: TreeNode,
|
|
326
|
+
renderer: Renderer<N>,
|
|
327
|
+
parent: N,
|
|
328
|
+
): Cleanup {
|
|
329
|
+
const ctx = createMountContext();
|
|
330
|
+
|
|
331
|
+
return mountNode(ctx, node, renderer, parent, null);
|
|
332
|
+
}
|