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
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@evilmartians/lefthook": "^1.6.13",
"@happy-dom/global-registrator": "^14.12.0",
"@jest/globals": "^29.7.0",
"@js-temporal/polyfill": "^0.5.1",
"@react-native-async-storage/async-storage": "^1.23.1",
"@release-it/conventional-changelog": "^8.0.1",
"@supabase/supabase-js": "^2.43.4",
Expand Down
5 changes: 4 additions & 1 deletion src/ObservableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isPrimitive,
isPromise,
isSet,
isTemporal,
} from './is';
import { linked } from './linked';
import type {
Expand Down Expand Up @@ -725,8 +726,10 @@ function setKey(node: NodeInfo, key: string, newValue?: any, level?: number) {
const isPrim =
isPrimitive(prevValue) ||
prevValue instanceof Date ||
isTemporal(prevValue) ||
isPrimitive(savedValue) ||
savedValue instanceof Date;
savedValue instanceof Date ||
isTemporal(savedValue);

if (!isPrim) {
let parent = childNode;
Expand Down
6 changes: 4 additions & 2 deletions src/globals.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isArray, isChildNode, isDate, isFunction, isMap, isObject, isSet } from './is';
import { isArray, isChildNode, isDate, isFunction, isMap, isObject, isSet, isTemporal } from './is';
import type { NodeInfo, ObservableEvent, TypeAtPath, UpdateFn } from './observableInterfaces';
import type { Observable, ObservableParam } from './observableTypes';

Expand Down Expand Up @@ -250,7 +250,9 @@ export function extractFunction(node: NodeInfo, key: string, fnOrComputed: Funct
node.functions.set(key, fnOrComputed);
}
export function equals(a: unknown, b: unknown) {
return a === b || (isDate(a) && isDate(b) && +a === +b);
return (
a === b || (isDate(a) && isDate(b) && +a === +b) || (isTemporal(a) && isTemporal(b) && String(a) === String(b))
);
}
export function getKeys(
obj: Record<any, any> | Array<any> | undefined,
Expand Down
18 changes: 16 additions & 2 deletions src/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function isString(obj: unknown): obj is string {
return typeof obj === 'string';
}
export function isObject(obj: unknown): obj is Record<any, any> {
return !!obj && typeof obj === 'object' && !(obj instanceof Date) && !isArray(obj);
return !!obj && typeof obj === 'object' && !(obj instanceof Date) && !isTemporal(obj) && !isArray(obj);
}
export function isPlainObject(obj: unknown): obj is Record<any, any> {
return isObject(obj) && obj.constructor === Object;
Expand All @@ -19,11 +19,25 @@ export function isFunction(obj: unknown): obj is Function {
}
export function isPrimitive(arg: unknown): arg is string | number | bigint | boolean | symbol {
const type = typeof arg;
return arg !== undefined && (isDate(arg) || (type !== 'object' && type !== 'function'));
return arg !== undefined && (isDate(arg) || isTemporal(arg) || (type !== 'object' && type !== 'function'));
}
export function isDate(obj: unknown): obj is Date {
return obj instanceof Date;
}
// Temporal.* instances (PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Instant, Duration,
// PlainMonthDay, PlainYearMonth) have no enumerable own properties, so they must be treated as
// opaque/primitive-like values (like Date) rather than deep-diffed. We can't rely on `instanceof
// Temporal.X` because the global `Temporal` may not exist (native support or polyfill is optional
// for consumers), so we duck-type via the spec-mandated `Symbol.toStringTag`, which every Temporal
// type sets to `"Temporal.<TypeName>"`.
export function isTemporal(obj: unknown): boolean {
return (
!!obj &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof (obj as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === 'string' &&
(obj as { [Symbol.toStringTag]: string })[Symbol.toStringTag].startsWith('Temporal.')
);
}
export function isSymbol(obj: unknown): obj is symbol {
return typeof obj === 'symbol';
}
Expand Down
52 changes: 52 additions & 0 deletions tests/computed.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Temporal } from '@js-temporal/polyfill';
import {
Observable,
batch,
beginBatch,
computed,
endBatch,
getNode,
isObservable,
Expand Down Expand Up @@ -255,6 +257,56 @@ describe('Computed', () => {
obs.test.set(11);
expect(comp.get()).toEqual({ prev: 35, sum: 31 });
});
test('Computed returning a Temporal.PlainDate notifies on change', () => {
const source$ = observable('2024-01-15');
const date$ = computed(() => Temporal.PlainDate.from(source$.get()));

const handler = jest.fn();
observe(() => {
handler(date$.get().toString());
});

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenLastCalledWith('2024-01-15');

source$.set('2024-03-20');

expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenLastCalledWith('2024-03-20');
});
test('Computed returning a Temporal.Instant notifies on change', () => {
const source$ = observable('2024-01-15T00:00:00Z');
const instant$ = computed(() => Temporal.Instant.from(source$.get()));

const handler = jest.fn();
observe(() => {
handler(instant$.get().toString());
});

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenLastCalledWith('2024-01-15T00:00:00Z');

source$.set('2024-03-20T00:00:00Z');

expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenLastCalledWith('2024-03-20T00:00:00Z');
});
test('Computed returning an equal Temporal.PlainDate does not notify', () => {
const source$ = observable('2024-01-15');
const date$ = computed(() => Temporal.PlainDate.from(source$.get()));

const handler = jest.fn();
observe(() => {
handler(date$.get().toString());
});

expect(handler).toHaveBeenCalledTimes(1);

// Setting to a different string that resolves to an equal PlainDate should not re-notify.
source$.set('2024-01-15T10:30:00');

expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('Accessor functions', () => {
test('get fn', () => {
Expand Down
Loading