Skip to content

Commit ea6a209

Browse files
authored
Bug(reactivity): Fix collection-helper edge cases (#221)
1 parent 87477db commit ea6a209

6 files changed

Lines changed: 84 additions & 7 deletions

File tree

packages/reactivity/src/reaction.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ export class Reaction {
6969
}
7070

7171
run() {
72-
// only run this reaction is marked as active
7372
if (!this.active) {
7473
return;
7574
}

packages/reactivity/src/scheduler.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class Scheduler {
4545
const toRun = Scheduler.pendingReactions;
4646
Scheduler.pendingReactions = new Set();
4747
for (const r of toRun) {
48-
if (r.stopped) { continue; }
48+
if (!r.active) { continue; }
4949
try {
5050
r.run();
5151
}

packages/reactivity/src/signal.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ export class Signal {
2929
// default clone and equal pulled from utils
3030
static equalityFunction = isEqual;
3131
static cloneFunction = clone;
32+
static idFunction = (item) => item.id ?? item._id ?? item.hash ?? item.key;
3233
static safety = 'reference';
3334

3435
constructor(initialValue, {
3536
safety = Signal.safety,
3637
cloneFunction = Signal.cloneFunction,
3738
equalityFunction = (safety === 'none') ? returnsFalse : Signal.equalityFunction,
39+
idFunction = Signal.idFunction,
3840
context,
3941
} = {}) {
4042
// create dependency
@@ -46,6 +48,7 @@ export class Signal {
4648
// handle custom helpers if user specifies
4749
this.cloneFunction = cloneFunction;
4850
this.equalityFunction = equalityFunction;
51+
this.idFunction = idFunction;
4952

5053
this.safety = safety;
5154
this.currentValue = this.protect(initialValue);
@@ -321,13 +324,13 @@ export class Signal {
321324
if (!isObject(item)) {
322325
return [item];
323326
}
324-
return unique([item.id, item._id, item.hash, item.key].filter(Boolean));
327+
return unique([item.id, item._id, item.hash, item.key].filter(value => value != null));
325328
}
326329
getID(item) {
327330
if (!isObject(item)) {
328331
return item;
329332
}
330-
return item.id || item._id || item.hash || item.key;
333+
return this.idFunction(item);
331334
}
332335
hasID(item, id) {
333336
return this.getID(item) === id;
@@ -364,10 +367,18 @@ export class Signal {
364367
}
365368
}
366369
replaceItem(id, item) {
367-
return this.setIndex(this.getItemIndex(id), item);
370+
const index = this.getItemIndex(id);
371+
if (index === -1) {
372+
return;
373+
}
374+
return this.setIndex(index, item);
368375
}
369376
removeItem(id) {
370-
return this.removeIndex(this.getItemIndex(id));
377+
const index = this.getItemIndex(id);
378+
if (index === -1) {
379+
return;
380+
}
381+
return this.removeIndex(index);
371382
}
372383

373384
/*******************************

packages/reactivity/test/unit/signal-helpers.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,18 @@ SAFETY_MODES.forEach((safety) => {
341341
const sig = new Signal([], { safety });
342342
expect(sig.getID('plain')).toBe('plain');
343343
});
344+
345+
it('treats a falsy-but-present id as the id', () => {
346+
const sig = new Signal([], { safety });
347+
expect(sig.getID({ id: 0 })).toBe(0);
348+
expect(sig.getID({ id: '' })).toBe('');
349+
});
350+
351+
it('falls through fields only when the prior is null or undefined', () => {
352+
const sig = new Signal([], { safety });
353+
expect(sig.getID({ id: 0, _id: 'b' })).toBe(0);
354+
expect(sig.getID({ id: null, _id: 'b' })).toBe('b');
355+
});
344356
});
345357

346358
describe('getIDs', () => {
@@ -356,6 +368,11 @@ SAFETY_MODES.forEach((safety) => {
356368
const sig = new Signal([], { safety });
357369
expect(sig.getIDs('plain')).toEqual(['plain']);
358370
});
371+
372+
it('keeps a zero id rather than dropping it', () => {
373+
const sig = new Signal([], { safety });
374+
expect(sig.getIDs({ id: 0 })).toEqual([0]);
375+
});
359376
});
360377

361378
describe('hasID', () => {
@@ -591,6 +608,19 @@ SAFETY_MODES.forEach((safety) => {
591608
sig.replaceItem('a', { id: 'a', v: 100 });
592609
expect(sig.raw()).toBe(before);
593610
});
611+
612+
it('is a no-op when no item matches the id', () => {
613+
const sig = new Signal(
614+
[{ id: 'a', v: 1 }, { id: 'b', v: 2 }],
615+
{ safety },
616+
);
617+
const sub = subscribe(sig);
618+
sig.replaceItem('missing', { id: 'missing', v: 99 });
619+
Reaction.flush();
620+
expect(sig.raw()).toEqual([{ id: 'a', v: 1 }, { id: 'b', v: 2 }]);
621+
expect(sub.count).toBe(0);
622+
sub.stop();
623+
});
594624
});
595625

596626
describe('removeItem', () => {
@@ -624,6 +654,19 @@ SAFETY_MODES.forEach((safety) => {
624654
sig.removeItem('b');
625655
expect(sig.raw()).toBe(before);
626656
});
657+
658+
it('is a no-op when no item matches the id (does not delete the last item)', () => {
659+
const sig = new Signal(
660+
[{ id: 'a' }, { id: 'b' }, { id: 'c' }],
661+
{ safety },
662+
);
663+
const sub = subscribe(sig);
664+
sig.removeItem('missing');
665+
Reaction.flush();
666+
expect(sig.raw().map(i => i.id)).toEqual(['a', 'b', 'c']);
667+
expect(sub.count).toBe(0);
668+
sub.stop();
669+
});
627670
});
628671

629672
/*******************************

packages/reactivity/test/unit/signal.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,27 @@ describe('Signal API', () => {
753753
expect(cloneSpy).toHaveBeenCalled();
754754
expect(signal.peek().cloned).toBe(true);
755755
});
756+
757+
it('default idFunction prefers id over _id', () => {
758+
const signal = new Signal([]);
759+
expect(signal.getID({ _id: 'mongo', id: 'app' })).toBe('app');
760+
});
761+
762+
it('uses a per-instance idFunction override for getID', () => {
763+
const signal = new Signal([], { idFunction: item => item.slug });
764+
expect(signal.getID({ slug: 'x', id: 'y' })).toBe('x');
765+
});
766+
767+
it('falls back to the static Signal.idFunction when no override is given', () => {
768+
const original = Signal.idFunction;
769+
Signal.idFunction = item => item.slug;
770+
try {
771+
expect(new Signal([]).getID({ slug: 'x', id: 'y' })).toBe('x');
772+
}
773+
finally {
774+
Signal.idFunction = original;
775+
}
776+
});
756777
});
757778

758779
/***********************************************

packages/renderer/src/shared/each.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pure logic, no DOM or engine primitives
44
*/
55

6+
import { Signal } from '@semantic-ui/reactivity';
67
import { isArray, isPlainObject, isString } from '@semantic-ui/utils';
78

89
export function getCollectionType(items) {
@@ -15,7 +16,9 @@ export function getItemID(item, indexOrKey, collectionType) {
1516
let raw;
1617
if (isPlainObject(item)) {
1718
const key = (collectionType === 'object') ? indexOrKey : undefined;
18-
raw = key || item._id || item.id || item.key || item.hash || item._hash || item.value || indexOrKey;
19+
// key is the object positional index — a 0 falls through (||) to the
20+
// shared id resolver, so each + signals key items the same way
21+
raw = key || (Signal.idFunction(item) ?? indexOrKey);
1922
}
2023
else if (isString(item)) {
2124
raw = item + ':' + indexOrKey;

0 commit comments

Comments
 (0)