@zoijs/core 1.3.2 → 1.4.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/CHANGELOG.md +117 -103
- package/README.md +154 -154
- package/package.json +67 -63
- package/src/core/each.js +24 -24
- package/src/core/renderer.js +473 -470
- package/src/devtools.d.ts +56 -0
- package/src/index.d.ts +185 -185
- package/src/reactivity/core.js +7 -0
- package/src/reactivity/devtools.js +77 -0
package/src/core/renderer.js
CHANGED
|
@@ -1,470 +1,473 @@
|
|
|
1
|
-
// renderer.js — fine-grained rendering engine.
|
|
2
|
-
//
|
|
3
|
-
// Turns an html() result into live DOM and wires each dynamic slot to a live
|
|
4
|
-
// binding. No Virtual DOM, no component re-execution.
|
|
5
|
-
//
|
|
6
|
-
// - A FUNCTION value is reactive (runs in an effect): text updates a Text node
|
|
7
|
-
// in place; attributes update in place.
|
|
8
|
-
// - An each() marker becomes a keyed LIST binding (reuse / move / remove).
|
|
9
|
-
// - A non-function value is static (set once, no effect).
|
|
10
|
-
// - An EVENT slot's value is the handler (addEventListener; never a string).
|
|
11
|
-
//
|
|
12
|
-
// Cleanup is owned: render() creates an owner scope; every effect, listener, and
|
|
13
|
-
// nested render registers into it, so disposing the owner tears everything down.
|
|
14
|
-
|
|
15
|
-
import { effect, untrack } from "../reactivity/effect.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
* @
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
p
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
else
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
seen.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
rec =
|
|
334
|
-
sources.push(
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
selStart =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1
|
+
// renderer.js — fine-grained rendering engine.
|
|
2
|
+
//
|
|
3
|
+
// Turns an html() result into live DOM and wires each dynamic slot to a live
|
|
4
|
+
// binding. No Virtual DOM, no component re-execution.
|
|
5
|
+
//
|
|
6
|
+
// - A FUNCTION value is reactive (runs in an effect): text updates a Text node
|
|
7
|
+
// in place; attributes update in place.
|
|
8
|
+
// - An each() marker becomes a keyed LIST binding (reuse / move / remove).
|
|
9
|
+
// - A non-function value is static (set once, no effect).
|
|
10
|
+
// - An EVENT slot's value is the handler (addEventListener; never a string).
|
|
11
|
+
//
|
|
12
|
+
// Cleanup is owned: render() creates an owner scope; every effect, listener, and
|
|
13
|
+
// nested render registers into it, so disposing the owner tears everything down.
|
|
14
|
+
|
|
15
|
+
import { effect, untrack } from "../reactivity/effect.js";
|
|
16
|
+
import { labelNext } from "../reactivity/devtools.js";
|
|
17
|
+
import { createState } from "../reactivity/state.js";
|
|
18
|
+
import { createOwner, runWithOwner, disposeOwner, onCleanup } from "../reactivity/owner.js";
|
|
19
|
+
import { isDev } from "../reactivity/env.js";
|
|
20
|
+
import { toText, isSafeUrl, isSafeAttributeName } from "../utils/security.js";
|
|
21
|
+
|
|
22
|
+
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
23
|
+
const URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "ping", "xlink:href"]);
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{ template: HTMLTemplateElement, parts: object[], values: any[] }} result
|
|
28
|
+
* @returns {{ node: DocumentFragment, dispose: Function }}
|
|
29
|
+
*/
|
|
30
|
+
export function render(result) {
|
|
31
|
+
const owner = createOwner(); // nested under the active owner
|
|
32
|
+
const fragment = result.template.content.cloneNode(true);
|
|
33
|
+
const { parts, values } = result;
|
|
34
|
+
|
|
35
|
+
// Collect every part's target node on the PRISTINE clone first (document
|
|
36
|
+
// order), so a binding that inserts children can't shadow a later part.
|
|
37
|
+
const nodes = collectNodes(fragment, parts, result.hasElements);
|
|
38
|
+
|
|
39
|
+
runWithOwner(owner, () => {
|
|
40
|
+
for (let i = 0; i < parts.length; i++) {
|
|
41
|
+
const part = parts[i];
|
|
42
|
+
const node = nodes[i];
|
|
43
|
+
if (!node) continue;
|
|
44
|
+
|
|
45
|
+
if (part.type === "child") {
|
|
46
|
+
bindChild(node, values[part.hole]);
|
|
47
|
+
} else {
|
|
48
|
+
node.removeAttribute("data-zoijs-bind");
|
|
49
|
+
for (const attr of part.attrs) bindAttribute(node, attr, values);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { node: fragment, dispose: () => disposeOwner(owner) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Match parts to nodes by document order: a child part ↔ the next marker comment,
|
|
58
|
+
// an element part ↔ the next element carrying data-zoijs-bind. Unique markers make
|
|
59
|
+
// this collision-proof across nested templates and list items.
|
|
60
|
+
function collectNodes(fragment, parts, hasElements) {
|
|
61
|
+
const nodes = new Array(parts.length);
|
|
62
|
+
if (!parts.length) return nodes;
|
|
63
|
+
// Child-only templates (common for list items) can walk comments only.
|
|
64
|
+
const filter = hasElements ? NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT : NodeFilter.SHOW_COMMENT;
|
|
65
|
+
const walker = document.createTreeWalker(fragment, filter);
|
|
66
|
+
let p = 0;
|
|
67
|
+
let node;
|
|
68
|
+
while (p < parts.length && (node = walker.nextNode())) {
|
|
69
|
+
const isChildMarker = node.nodeType === 8 && node.data === "zoijs";
|
|
70
|
+
const isElementMarker = node.nodeType === 1 && node.hasAttribute("data-zoijs-bind");
|
|
71
|
+
if (isChildMarker || isElementMarker) {
|
|
72
|
+
nodes[p] = node;
|
|
73
|
+
p++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return nodes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function bindChild(anchor, value) {
|
|
80
|
+
if (isEach(value)) setupKeyedList(anchor, value);
|
|
81
|
+
else if (typeof value === "function") bindReactiveContent(anchor, value);
|
|
82
|
+
else insertStaticContent(anchor, value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bindAttribute(el, attr, values) {
|
|
86
|
+
if (attr.name === "ref") {
|
|
87
|
+
// A callback ref. Only a single ${fn} is meaningful; anything else (a string,
|
|
88
|
+
// a number, a multi-part value) is rejected by bindRef without touching the DOM.
|
|
89
|
+
bindRef(el, attr.whole ? values[attr.holes[0]] : undefined);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (attr.event) {
|
|
94
|
+
const handler = values[attr.holes[0]];
|
|
95
|
+
// Only real functions are accepted as handlers — a string/object is ignored,
|
|
96
|
+
// so an inline-handler string can never be wired up or executed.
|
|
97
|
+
if (typeof handler !== "function") {
|
|
98
|
+
if (isDev()) console.warn(`Zoijs: ignoring non-function event handler for "${attr.name}"`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const eventName = attr.name.slice(2).toLowerCase();
|
|
102
|
+
el.addEventListener(eventName, handler);
|
|
103
|
+
onCleanup(() => el.removeEventListener(eventName, handler));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (attr.whole) {
|
|
108
|
+
// A single ${} as the whole value → pass the raw value (preserves booleans,
|
|
109
|
+
// numbers, property types for value/checked).
|
|
110
|
+
const raw = values[attr.holes[0]];
|
|
111
|
+
if (typeof raw === "function")
|
|
112
|
+
labelNext({ kind: "attr", el, name: attr.name }, () => effect(() => applyAttribute(el, attr.name, raw())));
|
|
113
|
+
else applyAttribute(el, attr.name, raw);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Multi-part value (static text + one or more holes) → always a joined string.
|
|
118
|
+
const compute = () => {
|
|
119
|
+
let result = attr.strings[0];
|
|
120
|
+
for (let i = 0; i < attr.holes.length; i++) {
|
|
121
|
+
const hv = values[attr.holes[i]];
|
|
122
|
+
result += (typeof hv === "function" ? hv() : hv) + attr.strings[i + 1];
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
};
|
|
126
|
+
const reactive = attr.holes.some((h) => typeof values[h] === "function");
|
|
127
|
+
if (reactive)
|
|
128
|
+
labelNext({ kind: "attr", el, name: attr.name }, () => effect(() => applyAttribute(el, attr.name, compute())));
|
|
129
|
+
else applyAttribute(el, attr.name, compute());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A callback ref: hand the real element to user code AFTER the current render is
|
|
133
|
+
// inserted, so focus/scroll/measure see a CONNECTED node. We defer one microtask
|
|
134
|
+
// (render binds while the DOM is still a detached fragment; insertion happens
|
|
135
|
+
// right after render returns, synchronously, so a microtask runs once it's live).
|
|
136
|
+
// Not reactive — the function is read once. An optional returned function is an
|
|
137
|
+
// owner-scoped cleanup, disposed on unmount or list-item removal, exactly like a
|
|
138
|
+
// listener. A non-function value is ignored (with a dev warning) and never sets a
|
|
139
|
+
// "ref" attribute — so an inert string can't be wired up.
|
|
140
|
+
function bindRef(el, fn) {
|
|
141
|
+
if (typeof fn !== "function") {
|
|
142
|
+
if (isDev()) console.warn(`Zoijs: "ref" expects a function (el) => …; ignoring ${typeof fn} value`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let active = true;
|
|
146
|
+
let cleanup = null;
|
|
147
|
+
onCleanup(() => {
|
|
148
|
+
active = false;
|
|
149
|
+
if (cleanup) { cleanup(); cleanup = null; }
|
|
150
|
+
});
|
|
151
|
+
queueMicrotask(() => {
|
|
152
|
+
if (!active) return; // removed before the microtask fired
|
|
153
|
+
const c = fn(el);
|
|
154
|
+
if (typeof c === "function") {
|
|
155
|
+
if (active) cleanup = c;
|
|
156
|
+
else c(); // disposed during fn(): tear down immediately
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- text / content bindings -------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function bindReactiveContent(anchor, getValue) {
|
|
164
|
+
let mode = null; // "text" | "nodes"
|
|
165
|
+
let textNode = null;
|
|
166
|
+
let items = []; // [{ nodes, dispose }]
|
|
167
|
+
|
|
168
|
+
const clearNodes = () => {
|
|
169
|
+
for (const it of items) {
|
|
170
|
+
it.dispose();
|
|
171
|
+
for (const n of it.nodes) n.remove();
|
|
172
|
+
}
|
|
173
|
+
items = [];
|
|
174
|
+
};
|
|
175
|
+
const clearText = () => {
|
|
176
|
+
if (textNode) {
|
|
177
|
+
textNode.remove();
|
|
178
|
+
textNode = null;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
labelNext({ kind: "text", el: anchor }, () => effect(() => {
|
|
183
|
+
const value = getValue();
|
|
184
|
+
const t = typeof value;
|
|
185
|
+
// null/undefined/booleans render NOTHING (matches the `cond && html\`...\``
|
|
186
|
+
// idiom); numbers/strings render as text; everything else is node content.
|
|
187
|
+
const asText = value == null || t === "boolean" || t === "number" || t === "string" || t === "bigint";
|
|
188
|
+
if (asText) {
|
|
189
|
+
if (mode !== "text") {
|
|
190
|
+
clearNodes();
|
|
191
|
+
textNode = document.createTextNode("");
|
|
192
|
+
anchor.parentNode.insertBefore(textNode, anchor);
|
|
193
|
+
mode = "text";
|
|
194
|
+
}
|
|
195
|
+
textNode.data = value == null || t === "boolean" ? "" : toText(value); // in-place update
|
|
196
|
+
} else {
|
|
197
|
+
if (mode === "text") clearText();
|
|
198
|
+
else clearNodes();
|
|
199
|
+
mode = "nodes";
|
|
200
|
+
insertItems(anchor, value, items);
|
|
201
|
+
}
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
onCleanup(() => {
|
|
205
|
+
clearNodes();
|
|
206
|
+
clearText();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function insertStaticContent(anchor, value) {
|
|
211
|
+
if (value == null) return;
|
|
212
|
+
const items = [];
|
|
213
|
+
insertItems(anchor, value, items);
|
|
214
|
+
onCleanup(() => {
|
|
215
|
+
for (const it of items) {
|
|
216
|
+
it.dispose();
|
|
217
|
+
for (const n of it.nodes) n.remove();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function insertItems(anchor, value, items) {
|
|
223
|
+
const parent = anchor.parentNode;
|
|
224
|
+
const list = Array.isArray(value) ? value : [value];
|
|
225
|
+
for (const v of list) {
|
|
226
|
+
const item = renderChild(v);
|
|
227
|
+
for (const n of item.nodes) parent.insertBefore(n, anchor);
|
|
228
|
+
items.push(item);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderChild(value) {
|
|
233
|
+
if (value == null || value === false || value === true) return { nodes: [], dispose: noop };
|
|
234
|
+
if (value instanceof Node) return { nodes: [value], dispose: noop };
|
|
235
|
+
if (isHtmlResult(value)) {
|
|
236
|
+
const r = render(value);
|
|
237
|
+
return { nodes: [...r.node.childNodes], dispose: r.dispose };
|
|
238
|
+
}
|
|
239
|
+
return { nodes: [document.createTextNode(toText(value))], dispose: noop };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- keyed list binding ------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function isEach(v) {
|
|
245
|
+
return v != null && typeof v === "object" && v.__zoijsEach === true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Longest strictly-increasing subsequence of the non-(-1) values; returns the SET
|
|
249
|
+
// of indices that belong to it. Those items are already in increasing relative
|
|
250
|
+
// order, so they need not move; -1 (new) items are excluded (always inserted).
|
|
251
|
+
// Patience-sorting with predecessor links — O(n log n).
|
|
252
|
+
function longestIncreasingSubsequence(arr) {
|
|
253
|
+
const piles = []; // piles[k] = index of the smallest tail of an LIS of length k+1
|
|
254
|
+
const prev = new Array(arr.length).fill(-1);
|
|
255
|
+
for (let i = 0; i < arr.length; i++) {
|
|
256
|
+
if (arr[i] === -1) continue;
|
|
257
|
+
let lo = 0;
|
|
258
|
+
let hi = piles.length;
|
|
259
|
+
while (lo < hi) {
|
|
260
|
+
const mid = (lo + hi) >> 1;
|
|
261
|
+
if (arr[piles[mid]] < arr[i]) lo = mid + 1;
|
|
262
|
+
else hi = mid;
|
|
263
|
+
}
|
|
264
|
+
if (lo > 0) prev[i] = piles[lo - 1];
|
|
265
|
+
piles[lo] = i;
|
|
266
|
+
}
|
|
267
|
+
const keep = new Set();
|
|
268
|
+
let k = piles.length ? piles[piles.length - 1] : -1;
|
|
269
|
+
while (k >= 0) {
|
|
270
|
+
keep.add(k);
|
|
271
|
+
k = prev[k];
|
|
272
|
+
}
|
|
273
|
+
return keep;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function setupKeyedList(anchor, marker) {
|
|
277
|
+
const { items, keyFn, renderFn } = marker;
|
|
278
|
+
const listOwner = createOwner(); // item subtrees nest here
|
|
279
|
+
let records = new Map();
|
|
280
|
+
let currentList = [];
|
|
281
|
+
|
|
282
|
+
const readItems = () => {
|
|
283
|
+
const raw = typeof items === "function" ? items() : items;
|
|
284
|
+
return raw == null ? [] : raw;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Build one item. The item is a reactive proxy over a state cell, so reusing a
|
|
288
|
+
// node later (itemCell.set) refreshes only its own bindings. render() opens a
|
|
289
|
+
// child owner (nested in listOwner) so the whole item subtree disposes together.
|
|
290
|
+
const createRecord = (key, item) => {
|
|
291
|
+
const isObj = item !== null && typeof item === "object";
|
|
292
|
+
const itemCell = isObj ? createState(item) : null;
|
|
293
|
+
const arg = isObj ? makeItemProxy(itemCell) : item;
|
|
294
|
+
// Run renderFn inside this item's own owner scope, so any onCleanup() it
|
|
295
|
+
// registers fires when the item is removed (not only on full unmount).
|
|
296
|
+
const itemOwner = createOwner();
|
|
297
|
+
let nodes;
|
|
298
|
+
untrack(() =>
|
|
299
|
+
runWithOwner(itemOwner, () => {
|
|
300
|
+
const r = render(renderFn(arg));
|
|
301
|
+
nodes = [...r.node.childNodes];
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
return { key, item, itemCell, nodes, dispose: () => disposeOwner(itemOwner) };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const reconcile = (newItems) => {
|
|
308
|
+
const parent = anchor.parentNode;
|
|
309
|
+
const oldRecords = records;
|
|
310
|
+
const newRecords = new Map();
|
|
311
|
+
const ordered = [];
|
|
312
|
+
const sources = []; // each item's previous DOM position, or -1 if newly created
|
|
313
|
+
const seen = isDev() ? new Set() : null;
|
|
314
|
+
|
|
315
|
+
// Previous DOM order, by key — lets us find which reused items are already in
|
|
316
|
+
// increasing relative order and can stay put.
|
|
317
|
+
const oldPos = new Map();
|
|
318
|
+
for (let i = 0; i < currentList.length; i++) oldPos.set(currentList[i].key, i);
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
321
|
+
const item = newItems[i];
|
|
322
|
+
const key = keyFn(item);
|
|
323
|
+
if (isDev()) {
|
|
324
|
+
if (seen.has(key)) {
|
|
325
|
+
console.warn(`Zoijs each(): duplicate key ${stringifyKey(key)} — keys must be unique; DOM for duplicates may be unstable.`);
|
|
326
|
+
}
|
|
327
|
+
seen.add(key);
|
|
328
|
+
}
|
|
329
|
+
let rec = oldRecords.get(key);
|
|
330
|
+
if (rec) {
|
|
331
|
+
oldRecords.delete(key);
|
|
332
|
+
if (rec.itemCell) rec.itemCell.set(item); // refresh this item's bindings only
|
|
333
|
+
rec.item = item;
|
|
334
|
+
sources.push(oldPos.has(key) ? oldPos.get(key) : -1);
|
|
335
|
+
} else {
|
|
336
|
+
rec = createRecord(key, item);
|
|
337
|
+
sources.push(-1); // new item — always (re)inserted
|
|
338
|
+
}
|
|
339
|
+
newRecords.set(key, rec);
|
|
340
|
+
ordered.push(rec);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Removed keys: dispose their owner scope (effects/listeners) + remove nodes.
|
|
344
|
+
for (const rec of oldRecords.values()) {
|
|
345
|
+
rec.dispose();
|
|
346
|
+
for (const n of rec.nodes) n.remove();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Moving the subtree that holds the focused element blurs it in some browsers,
|
|
350
|
+
// so capture focus (and caret) before the moves and restore it after — a
|
|
351
|
+
// reorder must never steal focus or selection, whichever nodes happen to move.
|
|
352
|
+
const doc = anchor.ownerDocument;
|
|
353
|
+
const active = doc && doc.activeElement;
|
|
354
|
+
const refocus = active && active !== doc.body && parent.contains(active);
|
|
355
|
+
let selStart = null;
|
|
356
|
+
let selEnd = null;
|
|
357
|
+
if (refocus) {
|
|
358
|
+
try {
|
|
359
|
+
selStart = active.selectionStart;
|
|
360
|
+
selEnd = active.selectionEnd;
|
|
361
|
+
} catch {
|
|
362
|
+
selStart = null; // not a text field — focus only, no caret to restore
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Items in the longest increasing subsequence of old positions are already in
|
|
367
|
+
// the right relative order — leave them put (minimal DOM moves). Everything
|
|
368
|
+
// else (moved or new) is inserted before the running cursor, walking from the
|
|
369
|
+
// end so each insertion's reference node is already correctly placed.
|
|
370
|
+
const keep = longestIncreasingSubsequence(sources);
|
|
371
|
+
let cursor = anchor;
|
|
372
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
373
|
+
const rec = ordered[i];
|
|
374
|
+
if (sources[i] === -1 || !keep.has(i)) {
|
|
375
|
+
let ref = cursor;
|
|
376
|
+
for (let j = rec.nodes.length - 1; j >= 0; j--) {
|
|
377
|
+
const node = rec.nodes[j];
|
|
378
|
+
if (node.nextSibling !== ref) parent.insertBefore(node, ref);
|
|
379
|
+
ref = node;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
cursor = rec.nodes[0] || cursor;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (refocus && doc.activeElement !== active) {
|
|
386
|
+
active.focus();
|
|
387
|
+
if (selStart !== null && active.setSelectionRange) {
|
|
388
|
+
try {
|
|
389
|
+
active.setSelectionRange(selStart, selEnd);
|
|
390
|
+
} catch {
|
|
391
|
+
/* element no longer supports selection — focus alone is enough */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
records = newRecords;
|
|
397
|
+
currentList = ordered;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
labelNext({ kind: "list", el: anchor }, () => effect(() => {
|
|
401
|
+
const newItems = readItems(); // tracked: subscribe to the list state
|
|
402
|
+
runWithOwner(listOwner, () => reconcile(newItems));
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
onCleanup(() => {
|
|
406
|
+
for (const rec of currentList) {
|
|
407
|
+
rec.dispose();
|
|
408
|
+
for (const n of rec.nodes) n.remove();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function makeItemProxy(itemCell) {
|
|
414
|
+
return new Proxy(
|
|
415
|
+
{},
|
|
416
|
+
{
|
|
417
|
+
get(_, prop) {
|
|
418
|
+
const item = itemCell.get();
|
|
419
|
+
if (item == null) return undefined;
|
|
420
|
+
const v = item[prop];
|
|
421
|
+
return typeof v === "function" ? v.bind(item) : v;
|
|
422
|
+
},
|
|
423
|
+
has(_, prop) {
|
|
424
|
+
const item = itemCell.get();
|
|
425
|
+
return item != null && prop in Object(item);
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function stringifyKey(key) {
|
|
432
|
+
try {
|
|
433
|
+
return JSON.stringify(key);
|
|
434
|
+
} catch {
|
|
435
|
+
return String(key);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---- attribute binding -------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function applyAttribute(el, name, value) {
|
|
442
|
+
if (!isSafeAttributeName(name)) {
|
|
443
|
+
if (isDev()) console.warn(`Zoijs: refusing to bind unsafe attribute "${name}"`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (URL_ATTRS.has(name) && !isSafeUrl(toText(value))) {
|
|
447
|
+
if (isDev()) console.warn(`Zoijs: refusing unsafe URL in "${name}": ${value}`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (name === "value" || name === "checked") {
|
|
451
|
+
el[name] = value; // form-control state lives on the property
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (name.startsWith("xlink:")) {
|
|
455
|
+
// SVG namespaced attribute (e.g. xlink:href).
|
|
456
|
+
if (value === false || value == null) el.removeAttributeNS(XLINK_NS, name.slice(6));
|
|
457
|
+
else el.setAttributeNS(XLINK_NS, name, toText(value));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (value === false || value == null) {
|
|
461
|
+
el.removeAttribute(name);
|
|
462
|
+
} else if (value === true) {
|
|
463
|
+
el.setAttribute(name, "");
|
|
464
|
+
} else {
|
|
465
|
+
el.setAttribute(name, toText(value));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---- helpers -----------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
function isHtmlResult(v) {
|
|
472
|
+
return v && typeof v === "object" && v.template && Array.isArray(v.parts);
|
|
473
|
+
}
|