Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ When creating a source cell, you have fine-grained control over its behavior:
```javascript
const cell = Cell.source(initialValue, {
immutable: boolean, // If true, the cell will not allow updates
deep: boolean, // By default, the cell only reacts to changes at the top level of objects. Setting deep to true will proxy the cell to all nested properties and trigger updates when they change as well.
equals: (oldValue, newValue) => boolean, // Custom equality function
});
```
Expand Down
92 changes: 1 addition & 91 deletions library/classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@
* @typedef {object} CellOptions
* @property {boolean} [immutable]
* Whether the cell should be immutable. If set to true, the cell will not allow updates and will throw an error if the value is changed.
* @property {boolean} [deep]
* Whether the cell should watch for changes deep into the given value. By default the cell only reacts to changes at the top level.
* @property {(oldValue: T, newValue: T) => boolean} [equals]
* A function that determines whether two values are equal. If not provided, the default equality function will be used.
*/
Expand Down Expand Up @@ -226,27 +224,6 @@ function throwAnyErrors() {
}
}

const mutativeMapMethods = new Set(['set', 'delete', 'clear']);
const mutativeSetMethods = new Set(['add', 'delete', 'clear']);
const mutativeArrayMethods = new Set([
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
]);
const mutativeDateMethods = new Set([
'setDate',
'setMonth',
'setFullYear',
'setHours',
'setMinutes',
'setSeconds',
'setMilliseconds',
]);

/** @template T */
class Effect {
/**
Expand Down Expand Up @@ -936,8 +913,6 @@ export class DerivedCell extends Cell {
* @extends {Cell<T>}
* A cell whose value can be directly modified.
* Source cells are the primary way to introduce reactivity.
* They can hold any value type and will automatically handle proxying of objects
* to enable deep reactivity when needed.
*
* @example
* ```typescript
Expand Down Expand Up @@ -975,7 +950,7 @@ export class SourceCell extends Cell {
* @returns {T} The value of the Cell.
*/
get() {
return this.#proxy(this.revalued);
return this.revalued;
}

/**
Expand All @@ -999,71 +974,6 @@ export class SourceCell extends Cell {
UPDATE_BUFFER.push(this);
if (!IS_UPDATING) triggerUpdate();
}

/**
* Proxies the provided value deeply, allowing it to be observed and updated.
* @template T
* @param {T} value - The value to be proxied.
* @returns {T} - The proxied value.
*/
#proxy(value) {
if (typeof value !== 'object' || value === null) return value;
return new Proxy(value, {
get: (target, prop) => {
this.revalued;
if (this.options?.deep) {
// @ts-expect-error: Direct access is faster than Reflection here.
return this.#proxy(target[prop]);
}

if (typeof prop === 'string') {
const isMutativeMethod =
(target instanceof Map && mutativeMapMethods.has(prop)) ||
(target instanceof Set && mutativeSetMethods.has(prop)) ||
(target instanceof Date && mutativeDateMethods.has(prop)) ||
((ArrayBuffer.isView(target) || Array.isArray(target)) &&
mutativeArrayMethods.has(prop));

if (isMutativeMethod) {
// @ts-expect-error: Direct access is faster than Reflection here.
return (...args) => {
// @ts-expect-error: Direct access is faster than Reflection here.
const result = target[prop](...args);
UPDATE_BUFFER.push(this);
this[IsScheduled] = true;
if (!IS_UPDATING) triggerUpdate();
return result;
};
}
}

// @ts-expect-error: Direct access is faster than Reflection here.
let value = target[prop];

if (typeof value === 'function') {
value = value.bind(target);
}

return value;
},
set: (target, prop, value) => {
// @ts-expect-error: dynamic object access.
const formerValue = target[prop];
const isEqual = deepEqual(formerValue, value);
if (!isEqual) {
// @ts-expect-error: dynamic object access.
target[prop] = value;
UPDATE_BUFFER.push(this);
this[IsScheduled] = true;
if (!IS_UPDATING) {
triggerUpdate();
}
}

return true;
},
});
}
}

/**
Expand Down
186 changes: 4 additions & 182 deletions tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,10 @@ describe('Cells', () => {
});

test('Cell should ignore updates for deeply equal values', () => {
const cell = Cell.source(
{
a: 1,
b: { c: 2, d: 3 },
},
{ deep: true },
);
const cell = Cell.source({
a: 1,
b: { c: 2, d: 3 },
});
const callback = vi.fn();
cell.listen(callback);

Expand All @@ -40,12 +37,6 @@ describe('Cells', () => {
b: { c: 2, d: 4 },
});
expect(callback).toHaveBeenCalledTimes(1);

cell.get().b.c = 2;
expect(callback).toHaveBeenCalledTimes(1);

cell.get().b.c = 67;
expect(callback).toHaveBeenCalledTimes(2);
});

test('Creates a reactive Cell with null or undefined', () => {
Expand Down Expand Up @@ -747,156 +738,6 @@ describe('Derived cells', () => {
});
});

describe('Nested cells', () => {
test('Cell of object type should be reactive', () => {
const cell = Cell.source({ a: 1 });
const cell2 = Cell.source({ b: 10 });

const derived = Cell.derived(() => cell.get().a + cell2.get().b);

cell.get().a = 2; // Deep reactivity test
expect(derived.get()).toBe(12);

cell2.get().b = 20; // Deep reactivity test
expect(derived.get()).toBe(22);
});

test('Derived cell of object type should run callback when value changes', () => {
const cell = Cell.source({ a: 'hello', b: 1, c: true, d: null });
const callback = vi.fn();

const derived = Cell.derived(() => {
callback();
return cell.get().a;
});
expect(derived.get()).toBe('hello');
expect(callback).toHaveBeenCalledTimes(1);

cell.set({ a: 'world', b: 2, c: false, d: null });
expect(callback).toHaveBeenCalledTimes(2);
});

test('Cell of map type should be able to read entries', () => {
const cell = Cell.source(new Map());
cell.get().set('a', 1);
cell.get().set('b', 2);

const array = Cell.derived(() => Array.from(cell.get().entries()));

cell.get().set('c', 3);

expect(array.get()).toEqual([
['a', 1],
['b', 2],
['c', 3],
]);
});

test('Cell of array type should be reactive', () => {
const cell = Cell.source([1, 2, 3]);

const sum = Cell.derived(() => cell.get().reduce((a, b) => a + b, 0));
expect(sum.get()).toBe(6);

cell.get()[0] = 3; // Deep reactivity test
expect(sum.get()).toBe(8);

cell.get().push(4); // Deep reactivity test
expect(sum.get()).toBe(12);

cell.get().pop(); // Deep reactivity test
expect(sum.get()).toBe(8);
});

test('Cell of nested array type should be reactive', () => {
/** @type {SourceCell<[number, [number, number], number]>} */
const cell = Cell.source([1, [2, 3], 4], { deep: true });
const d1 = Cell.derived(() => cell.get()[1][1] + 2);
const d2 = Cell.derived(() => cell.get()[1][0] + d1.get());

expect(d1.get()).toBe(5);
expect(d2.get()).toBe(7);
cell.get()[1][1] = 5; // Deep reactivity test

expect(d1.get()).toBe(7);
expect(d2.get()).toBe(9);
});

test('Cells of maps should be reactive', () => {
const cell = Cell.source(new Map());
const derived = Cell.derived(() => cell.get().get('a'));

expect(derived.get()).toBe(undefined);

cell.get().set('a', 1); // Deep reactivity test
expect(derived.get()).toBe(1);

cell.get().set('a', 2); // Deep reactivity test
expect(derived.get()).toBe(2);
});

test('Cells of sets should be reactive', () => {
const cell = Cell.source(new Set());
const derived = Cell.derived(() => cell.get().has(1));
const size = Cell.derived(() => cell.get().size);

expect(derived.get()).toBe(false);
expect(size.get()).toBe(0);

cell.get().add(1); // Deep reactivity test
expect(derived.get()).toBe(true);
expect(size.get()).toBe(1);

cell.get().add(2); // Deep reactivity test
expect(derived.get()).toBe(true);
expect(size.get()).toBe(2);
});

test('Cells of dates should be reactive', () => {
const cell = Cell.source(new Date());
const callback = vi.fn();
cell.listen(callback);

cell.set(new Date(2022, 1, 1));
expect(callback).toHaveBeenCalledTimes(1);
cell.set(new Date(2022, 1, 1));
expect(callback).toHaveBeenCalledTimes(1);

cell.get().setMonth(2);
expect(callback).toHaveBeenCalledTimes(2);
});

test('Cell should handle built-in operators on objects', () => {
const cell = Cell.source({ a: 1, b: 2 });
const derived = Cell.derived(() => cell.get().a + cell.get().b);

cell.get().a += 2;
expect(derived.get()).toBe(5);

cell.get().b += 2;
expect(derived.get()).toBe(7);

cell.get().a++;
expect(derived.get()).toBe(8);

cell.get().b--;
expect(derived.get()).toBe(7);
});

test('Cell should handle built-in operators on arrays', () => {
const cell = Cell.source([1, 2, 3]);
const derived = Cell.derived(() => {
return cell.get().map((x) => x + 5);
});

expect(derived.get()).toEqual([6, 7, 8]);

cell.get()[0]++;

expect(derived.get()).toEqual([7, 7, 8]);
});
});

describe('Batched effects', () => {
test('Batched effects should run only once', () => {
const callback = vi.fn();
Expand Down Expand Up @@ -3609,25 +3450,6 @@ describe('Effect options', () => {
});

describe('Cell options', () => {
test('Cells should be deeply proxied if specified', () => {
const cell = Cell.source({ a: 1, b: { c: 5 } }, { deep: true });
const callback = vi.fn();
cell.listen(callback);
cell.get().b.c = 2; // Deep reactivity test
expect(callback).toHaveBeenCalledTimes(1);
});

test('Cells should be shallowly proxied by default', () => {
const cell = Cell.source({ a: 1, b: { c: 5 } });
const callback = vi.fn();
cell.listen(callback);
cell.get().a = 2; // Deep reactivity test
expect(callback).toHaveBeenCalledTimes(1);

cell.get().b.c = 90; // Should not trigger update
expect(callback).toHaveBeenCalledTimes(1);
});

test('Immutable cells should not allow updates', () => {
const cell = Cell.source(1, { immutable: true });
expect(() => {
Expand Down
Loading