diff --git a/bun.lock b/bun.lock index b1af3c0c..b5cc3941 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,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", @@ -643,6 +644,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.18", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" } }, "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA=="], + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + "@ljharb/through": ["@ljharb/through@2.3.13", "", { "dependencies": { "call-bind": "^1.0.7" } }, "sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ=="], "@mole-inc/bin-wrapper": ["@mole-inc/bin-wrapper@8.0.1", "", { "dependencies": { "bin-check": "^4.1.0", "bin-version-check": "^5.0.0", "content-disposition": "^0.5.4", "ext-name": "^5.0.0", "file-type": "^17.1.6", "filenamify": "^5.0.2", "got": "^11.8.5", "os-filter-obj": "^2.0.0" } }, "sha512-sTGoeZnjI8N4KS+sW2AN95gDBErhAguvkw/tWdCjeM8bvxpz5lqrnd0vOJABA1A+Ic3zED7PYoLP/RANLgVotA=="], @@ -1945,6 +1948,8 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jsc-android": ["jsc-android@250231.0.0", "", {}, "sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw=="], "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], diff --git a/package.json b/package.json index a5290a6c..5472a10f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ObservableObject.ts b/src/ObservableObject.ts index 1eb45706..815ab44a 100644 --- a/src/ObservableObject.ts +++ b/src/ObservableObject.ts @@ -34,6 +34,7 @@ import { isPrimitive, isPromise, isSet, + isTemporal, } from './is'; import { linked } from './linked'; import type { @@ -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; diff --git a/src/globals.ts b/src/globals.ts index ac736dee..e8cdda6b 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -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'; @@ -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 | Array | undefined, diff --git a/src/is.ts b/src/is.ts index 702a2217..5e8db2d6 100644 --- a/src/is.ts +++ b/src/is.ts @@ -9,7 +9,7 @@ export function isString(obj: unknown): obj is string { return typeof obj === 'string'; } export function isObject(obj: unknown): obj is Record { - 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 { return isObject(obj) && obj.constructor === Object; @@ -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."`. +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'; } diff --git a/tests/computed.test.ts b/tests/computed.test.ts index 3c86f2ee..36b631c2 100644 --- a/tests/computed.test.ts +++ b/tests/computed.test.ts @@ -1,7 +1,9 @@ +import { Temporal } from '@js-temporal/polyfill'; import { Observable, batch, beginBatch, + computed, endBatch, getNode, isObservable, @@ -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', () => {