dalila 1.8.0 → 1.8.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.
Files changed (2) hide show
  1. package/dist/runtime/bind.js +179 -33
  2. package/package.json +1 -1
@@ -278,40 +278,184 @@ function bindEach(root, ctx, cleanups) {
278
278
  const comment = document.createComment('d-each');
279
279
  el.parentNode?.replaceChild(comment, el);
280
280
  el.removeAttribute('d-each');
281
+ const keyBinding = normalizeBinding(el.getAttribute('d-key'));
282
+ el.removeAttribute('d-key');
281
283
  const template = el;
282
- let currentClones = [];
283
- let currentDisposes = [];
284
+ const clonesByKey = new Map();
285
+ const disposesByKey = new Map();
286
+ const metadataByKey = new Map();
287
+ const itemsByKey = new Map();
288
+ const objectKeyIds = new WeakMap();
289
+ const symbolKeyIds = new Map();
290
+ let nextObjectKeyId = 0;
291
+ let nextSymbolKeyId = 0;
292
+ const missingKeyWarned = new Set();
293
+ const getObjectKeyId = (value) => {
294
+ const existing = objectKeyIds.get(value);
295
+ if (existing !== undefined)
296
+ return existing;
297
+ const next = ++nextObjectKeyId;
298
+ objectKeyIds.set(value, next);
299
+ return next;
300
+ };
301
+ const keyValueToString = (value, index) => {
302
+ if (value === null || value === undefined)
303
+ return `idx:${index}`;
304
+ const type = typeof value;
305
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint') {
306
+ return `${type}:${String(value)}`;
307
+ }
308
+ if (type === 'symbol') {
309
+ const sym = value;
310
+ let id = symbolKeyIds.get(sym);
311
+ if (id === undefined) {
312
+ id = ++nextSymbolKeyId;
313
+ symbolKeyIds.set(sym, id);
314
+ }
315
+ return `sym:${id}`;
316
+ }
317
+ if (type === 'object' || type === 'function') {
318
+ return `obj:${getObjectKeyId(value)}`;
319
+ }
320
+ return `idx:${index}`;
321
+ };
322
+ const readKeyValue = (item, index) => {
323
+ if (keyBinding) {
324
+ if (keyBinding === '$index')
325
+ return index;
326
+ if (keyBinding === 'item')
327
+ return item;
328
+ if (typeof item === 'object' && item !== null && keyBinding in item) {
329
+ return item[keyBinding];
330
+ }
331
+ const warnId = `${keyBinding}:${index}`;
332
+ if (!missingKeyWarned.has(warnId)) {
333
+ warn(`d-each: key "${keyBinding}" not found on item at index ${index}. Falling back to index key.`);
334
+ missingKeyWarned.add(warnId);
335
+ }
336
+ return index;
337
+ }
338
+ if (typeof item === 'object' && item !== null) {
339
+ const obj = item;
340
+ if ('id' in obj)
341
+ return obj.id;
342
+ if ('key' in obj)
343
+ return obj.key;
344
+ }
345
+ return index;
346
+ };
347
+ function createClone(key, item, index, count) {
348
+ const clone = template.cloneNode(true);
349
+ // Inherit parent ctx via prototype so values and handlers defined
350
+ // outside the loop remain accessible inside each iteration.
351
+ const itemCtx = Object.create(ctx);
352
+ if (typeof item === 'object' && item !== null) {
353
+ Object.assign(itemCtx, item);
354
+ }
355
+ const metadata = {
356
+ $index: signal(index),
357
+ $count: signal(count),
358
+ $first: signal(index === 0),
359
+ $last: signal(index === count - 1),
360
+ $odd: signal(index % 2 !== 0),
361
+ $even: signal(index % 2 === 0),
362
+ };
363
+ metadataByKey.set(key, metadata);
364
+ itemsByKey.set(key, item);
365
+ // Expose item + positional / collection helpers.
366
+ itemCtx.item = item;
367
+ itemCtx.key = key;
368
+ itemCtx.$index = metadata.$index;
369
+ itemCtx.$count = metadata.$count;
370
+ itemCtx.$first = metadata.$first;
371
+ itemCtx.$last = metadata.$last;
372
+ itemCtx.$odd = metadata.$odd;
373
+ itemCtx.$even = metadata.$even;
374
+ // Mark BEFORE bind() so the parent's subsequent global passes
375
+ // (text, attrs, events …) skip this subtree entirely.
376
+ clone.setAttribute('data-dalila-internal-bound', '');
377
+ const dispose = bind(clone, itemCtx, { _skipLifecycle: true });
378
+ disposesByKey.set(key, dispose);
379
+ clonesByKey.set(key, clone);
380
+ return clone;
381
+ }
382
+ function updateCloneMetadata(key, index, count) {
383
+ const metadata = metadataByKey.get(key);
384
+ if (metadata) {
385
+ metadata.$index.set(index);
386
+ metadata.$count.set(count);
387
+ metadata.$first.set(index === 0);
388
+ metadata.$last.set(index === count - 1);
389
+ metadata.$odd.set(index % 2 !== 0);
390
+ metadata.$even.set(index % 2 === 0);
391
+ }
392
+ }
284
393
  function renderList(items) {
285
- for (const clone of currentClones)
286
- clone.remove();
287
- for (const dispose of currentDisposes)
288
- dispose();
289
- currentClones = [];
290
- currentDisposes = [];
394
+ const orderedClones = [];
395
+ const orderedKeys = [];
396
+ const nextKeys = new Set();
397
+ const changedKeys = new Set();
291
398
  for (let i = 0; i < items.length; i++) {
292
399
  const item = items[i];
293
- const clone = template.cloneNode(true);
294
- // Inherit parent ctx via prototype so values and handlers defined
295
- // outside the loop remain accessible inside each iteration.
296
- const itemCtx = Object.create(ctx);
297
- if (typeof item === 'object' && item !== null) {
298
- Object.assign(itemCtx, item);
400
+ let key = keyValueToString(readKeyValue(item, i), i);
401
+ if (nextKeys.has(key)) {
402
+ warn(`d-each: duplicate key "${key}" at index ${i}. Falling back to per-index key.`);
403
+ key = `${key}:dup:${i}`;
404
+ }
405
+ nextKeys.add(key);
406
+ let clone = clonesByKey.get(key);
407
+ if (clone) {
408
+ updateCloneMetadata(key, i, items.length);
409
+ if (itemsByKey.get(key) !== item) {
410
+ changedKeys.add(key);
411
+ }
412
+ }
413
+ else {
414
+ clone = createClone(key, item, i, items.length);
415
+ }
416
+ orderedClones.push(clone);
417
+ orderedKeys.push(key);
418
+ }
419
+ for (let i = 0; i < orderedClones.length; i++) {
420
+ const clone = orderedClones[i];
421
+ const item = items[i];
422
+ const key = orderedKeys[i];
423
+ if (!changedKeys.has(key))
424
+ continue;
425
+ clone.remove();
426
+ const dispose = disposesByKey.get(key);
427
+ if (dispose) {
428
+ dispose();
429
+ disposesByKey.delete(key);
299
430
  }
300
- // Always expose raw item + positional / collection helpers
301
- itemCtx.item = item;
302
- itemCtx.$index = i;
303
- itemCtx.$count = items.length;
304
- itemCtx.$first = i === 0;
305
- itemCtx.$last = i === items.length - 1;
306
- itemCtx.$odd = i % 2 !== 0;
307
- itemCtx.$even = i % 2 === 0;
308
- // Mark BEFORE bind() so the parent's subsequent global passes
309
- // (text, attrs, events …) skip this subtree entirely.
310
- clone.setAttribute('data-dalila-internal-bound', '');
311
- const dispose = bind(clone, itemCtx, { _skipLifecycle: true });
312
- currentDisposes.push(dispose);
313
- comment.parentNode?.insertBefore(clone, comment);
314
- currentClones.push(clone);
431
+ clonesByKey.delete(key);
432
+ metadataByKey.delete(key);
433
+ itemsByKey.delete(key);
434
+ orderedClones[i] = createClone(key, item, i, items.length);
435
+ }
436
+ for (const [key, clone] of clonesByKey) {
437
+ if (nextKeys.has(key))
438
+ continue;
439
+ clone.remove();
440
+ clonesByKey.delete(key);
441
+ metadataByKey.delete(key);
442
+ itemsByKey.delete(key);
443
+ const dispose = disposesByKey.get(key);
444
+ if (dispose) {
445
+ dispose();
446
+ disposesByKey.delete(key);
447
+ }
448
+ }
449
+ const parent = comment.parentNode;
450
+ if (!parent)
451
+ return;
452
+ let referenceNode = comment;
453
+ for (let i = orderedClones.length - 1; i >= 0; i--) {
454
+ const clone = orderedClones[i];
455
+ if (clone.nextSibling !== referenceNode) {
456
+ parent.insertBefore(clone, referenceNode);
457
+ }
458
+ referenceNode = clone;
315
459
  }
316
460
  }
317
461
  if (isSignal(binding)) {
@@ -328,12 +472,14 @@ function bindEach(root, ctx, cleanups) {
328
472
  warn(`d-each: "${bindingName}" is not an array or signal`);
329
473
  }
330
474
  cleanups.push(() => {
331
- for (const clone of currentClones)
475
+ for (const clone of clonesByKey.values())
332
476
  clone.remove();
333
- for (const dispose of currentDisposes)
477
+ for (const dispose of disposesByKey.values())
334
478
  dispose();
335
- currentClones = [];
336
- currentDisposes = [];
479
+ clonesByKey.clear();
480
+ disposesByKey.clear();
481
+ metadataByKey.clear();
482
+ itemsByKey.clear();
337
483
  });
338
484
  }
339
485
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",