From 5e7cfb11c8ba7a82ca7218924ce1e93dd5cc7768 Mon Sep 17 00:00:00 2001 From: Michael Francis Date: Fri, 30 Jan 2026 14:26:12 -0500 Subject: [PATCH 1/3] fix(adapters): make shallow equality handle Temporal value objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapters’ default `shallow` equality treated any two “keyless” objects as equal (no enumerable keys => no comparisons), which caused Temporal objects to be considered unchanged and skip UI updates. Adjust `shallow` so keyless values are only equal for plain objects/arrays, and use `.equals()` when available (e.g. Temporal) to preserve value semantics. Add Temporal regression coverage across React/Preact/Solid/Vue/Svelte, plus an Angular integration test to ensure Temporal updates trigger re-render. --- .changeset/olive-pens-draw.md | 10 ++ package.json | 1 + packages/angular-store/src/index.ts | 41 ++++++++ packages/angular-store/tests/index.test.ts | 49 +++++++++ packages/preact-store/src/index.ts | 41 ++++++++ packages/preact-store/tests/index.test.tsx | 11 ++ packages/react-store/src/index.ts | 38 +++++++ packages/react-store/tests/index.test.tsx | 11 ++ packages/solid-store/src/index.tsx | 41 ++++++++ packages/solid-store/tests/index.test.tsx | 17 +++- packages/svelte-store/src/index.svelte.ts | 47 ++++++++- packages/svelte-store/tests/index.test.ts | 11 ++ packages/vue-store/src/index.ts | 41 ++++++++ packages/vue-store/tests/index.test.tsx | 11 ++ pnpm-lock.yaml | 111 ++++++++++++++++++++- 15 files changed, 472 insertions(+), 9 deletions(-) create mode 100644 .changeset/olive-pens-draw.md diff --git a/.changeset/olive-pens-draw.md b/.changeset/olive-pens-draw.md new file mode 100644 index 00000000..12b126f7 --- /dev/null +++ b/.changeset/olive-pens-draw.md @@ -0,0 +1,10 @@ +--- +'@tanstack/angular-store': patch +'@tanstack/preact-store': patch +'@tanstack/svelte-store': patch +'@tanstack/react-store': patch +'@tanstack/solid-store': patch +'@tanstack/vue-store': patch +--- + +Fix adapter `shallow` equality for keyless value objects (e.g. Temporal) so updates aren’t skipped. diff --git a/package.json b/package.json index 5af6e0cf..ccf6f8b1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "prettier-plugin-svelte": "^3.4.0", "publint": "^0.3.15", "sherif": "^1.9.0", + "temporal-polyfill": "^0.3.0", "tinyglobby": "^0.2.15", "typescript": "5.6.3", "typescript50": "npm:typescript@5.0", diff --git a/packages/angular-store/src/index.ts b/packages/angular-store/src/index.ts index 130120a4..0e2930e4 100644 --- a/packages/angular-store/src/index.ts +++ b/packages/angular-store/src/index.ts @@ -95,6 +95,30 @@ function shallow(objA: T, objB: T) { return false } + // Many "value objects" (e.g. Temporal) have no enumerable keys, which would + // otherwise make any two instances appear "shallow equal". Only treat + // keyless values as equal when both are plain objects or both are arrays. + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + for (let i = 0; i < keysA.length; i++) { if ( !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || @@ -105,3 +129,20 @@ function shallow(objA: T, objB: T) { } return true } + +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} diff --git a/packages/angular-store/tests/index.test.ts b/packages/angular-store/tests/index.test.ts index 17c5e465..0f290e87 100644 --- a/packages/angular-store/tests/index.test.ts +++ b/packages/angular-store/tests/index.test.ts @@ -3,6 +3,7 @@ import { Component, effect } from '@angular/core' import { TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' import { Store } from '@tanstack/store' +import { Temporal } from 'temporal-polyfill' import { injectStore } from '../src/index' describe('injectStore', () => { @@ -142,4 +143,52 @@ describe('dataType', () => { debugElement.query(By.css('p#displayStoreVal')).nativeElement.textContent, ).toContain(new Date('2025-03-29T21:06:40.401Z')) }) + + test('temporal change trigger re-render', () => { + const store = new Store({ date: Temporal.PlainDate.from('2025-03-29') }) + + @Component({ + template: ` +
+

{{ storeVal().toString() }}

+ +
+ `, + standalone: true, + }) + class MyCmp { + storeVal = injectStore(store, (state) => state.date) + + constructor() { + effect(() => { + console.log(this.storeVal()) + }) + } + + updateDate() { + store.setState((v) => ({ + ...v, + date: Temporal.PlainDate.from('2025-03-30'), + })) + } + } + + const fixture = TestBed.createComponent(MyCmp) + fixture.detectChanges() + + const debugElement = fixture.debugElement + + expect( + debugElement.query(By.css('p#displayStoreVal')).nativeElement.textContent, + ).toContain('2025-03-29') + + debugElement + .query(By.css('button#updateDate')) + .triggerEventHandler('click', null) + + fixture.detectChanges() + expect( + debugElement.query(By.css('p#displayStoreVal')).nativeElement.textContent, + ).toContain('2025-03-30') + }) }) diff --git a/packages/preact-store/src/index.ts b/packages/preact-store/src/index.ts index 7b2c5bcc..2789b75c 100644 --- a/packages/preact-store/src/index.ts +++ b/packages/preact-store/src/index.ts @@ -167,6 +167,30 @@ export function shallow(objA: T, objB: T) { return false } + // Many "value objects" (e.g. Temporal) have no enumerable keys, which would + // otherwise make any two instances appear "shallow equal". Only treat + // keyless values as equal when both are plain objects or both are arrays. + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + for (const key of keysA) { if ( !Object.prototype.hasOwnProperty.call(objB, key as string) || @@ -178,6 +202,23 @@ export function shallow(objA: T, objB: T) { return true } +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} + function getOwnKeys(obj: object): Array { return (Object.keys(obj) as Array).concat( Object.getOwnPropertySymbols(obj), diff --git a/packages/preact-store/tests/index.test.tsx b/packages/preact-store/tests/index.test.tsx index 06315d51..843721d8 100644 --- a/packages/preact-store/tests/index.test.tsx +++ b/packages/preact-store/tests/index.test.tsx @@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/preact' import { Derived, Store } from '@tanstack/store' import { useState } from 'preact/hooks' import { userEvent } from '@testing-library/user-event' +import { Temporal } from 'temporal-polyfill' import { shallow, useStore } from '../src/index' const user = userEvent.setup() @@ -303,4 +304,14 @@ describe('shallow', () => { const objB = new Date('2025-02-10') expect(shallow(objA, objB)).toBe(true) }) + + test('should return false for empty object vs empty array', () => { + expect(shallow({}, [])).toBe(false) + }) + + test('should return false for temporal objects with different values', () => { + const objA = Temporal.PlainDate.from('2025-02-10') + const objB = Temporal.PlainDate.from('2025-02-11') + expect(shallow(objA, objB)).toBe(false) + }) }) diff --git a/packages/react-store/src/index.ts b/packages/react-store/src/index.ts index 21d3a478..c9aca182 100644 --- a/packages/react-store/src/index.ts +++ b/packages/react-store/src/index.ts @@ -79,6 +79,27 @@ export function shallow(objA: T, objB: T) { return false } + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + for (let i = 0; i < keysA.length; i++) { if ( !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || @@ -90,6 +111,23 @@ export function shallow(objA: T, objB: T) { return true } +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} + function getOwnKeys(obj: object): Array { return (Object.keys(obj) as Array).concat( Object.getOwnPropertySymbols(obj), diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 37d1d37c..073b918c 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react' import { Derived, Store } from '@tanstack/store' import { useState } from 'react' import { userEvent } from '@testing-library/user-event' +import { Temporal } from 'temporal-polyfill' import { shallow, useStore } from '../src/index' const user = userEvent.setup() @@ -302,4 +303,14 @@ describe('shallow', () => { const objB = new Date('2025-02-10') expect(shallow(objA, objB)).toBe(true) }) + + test('should return false for empty object vs empty array', () => { + expect(shallow({}, [])).toBe(false) + }) + + test('should return false for temporal objects with different values', () => { + const objA = Temporal.PlainDate.from('2025-02-10') + const objB = Temporal.PlainDate.from('2025-02-11') + expect(shallow(objA, objB)).toBe(false) + }) }) diff --git a/packages/solid-store/src/index.tsx b/packages/solid-store/src/index.tsx index 104653b8..c69f08d0 100644 --- a/packages/solid-store/src/index.tsx +++ b/packages/solid-store/src/index.tsx @@ -86,6 +86,30 @@ export function shallow(objA: T, objB: T) { return false } + // Many "value objects" (e.g. Temporal) have no enumerable keys, which would + // otherwise make any two instances appear "shallow equal". Only treat + // keyless values as equal when both are plain objects or both are arrays. + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + for (let i = 0; i < keysA.length; i++) { if ( !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || @@ -96,3 +120,20 @@ export function shallow(objA: T, objB: T) { } return true } + +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} diff --git a/packages/solid-store/tests/index.test.tsx b/packages/solid-store/tests/index.test.tsx index 2efa725c..e82a3a40 100644 --- a/packages/solid-store/tests/index.test.tsx +++ b/packages/solid-store/tests/index.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, test } from 'vitest' import { render, renderHook } from '@solidjs/testing-library' import { Store } from '@tanstack/store' -import { useStore } from '../src/index' +import { Temporal } from 'temporal-polyfill' +import { shallow, useStore } from '../src/index' describe('useStore', () => { it.todo('allows us to select state using a selector', () => { @@ -53,3 +54,15 @@ describe('useStore', () => { expect(result()).toStrictEqual(new Date('2025-03-29T21:06:40.401Z')) }) }) + +describe('shallow', () => { + test('should return false for empty object vs empty array', () => { + expect(shallow({}, [])).toBe(false) + }) + + test('should return false for temporal objects with different values', () => { + const objA = Temporal.PlainDate.from('2025-02-10') + const objB = Temporal.PlainDate.from('2025-02-11') + expect(shallow(objA, objB)).toBe(false) + }) +}) diff --git a/packages/svelte-store/src/index.svelte.ts b/packages/svelte-store/src/index.svelte.ts index 636dd433..e6f4f940 100644 --- a/packages/svelte-store/src/index.svelte.ts +++ b/packages/svelte-store/src/index.svelte.ts @@ -88,13 +88,54 @@ export function shallow(objA: T, objB: T) { return false } - for (let i = 0; i < keysA.length; i++) { + // Many "value objects" (e.g. Temporal) have no enumerable keys, which would + // otherwise make any two instances appear "shallow equal". Only treat + // keyless values as equal when both are plain objects or both are arrays. + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + + for (const key of keysA) { if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || - !Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T]) + !Object.prototype.hasOwnProperty.call(objB, key) || + !Object.is(objA[key as keyof T], objB[key as keyof T]) ) { return false } } return true } + +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} diff --git a/packages/svelte-store/tests/index.test.ts b/packages/svelte-store/tests/index.test.ts index 24f7a3dd..d160c999 100644 --- a/packages/svelte-store/tests/index.test.ts +++ b/packages/svelte-store/tests/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, test } from 'vitest' import { render, waitFor } from '@testing-library/svelte' import { userEvent } from '@testing-library/user-event' +import { Temporal } from 'temporal-polyfill' import { shallow } from '../src/index.svelte.js' import TestBaseStore from './BaseStore.test.svelte' import TestRerender from './Render.test.svelte' @@ -91,4 +92,14 @@ describe('shallow', () => { const objB = new Date('2025-02-10') expect(shallow(objA, objB)).toBe(true) }) + + test('should return false for empty object vs empty array', () => { + expect(shallow({}, [])).toBe(false) + }) + + test('should return false for temporal objects with different values', () => { + const objA = Temporal.PlainDate.from('2025-02-10') + const objB = Temporal.PlainDate.from('2025-02-11') + expect(shallow(objA, objB)).toBe(false) + }) }) diff --git a/packages/vue-store/src/index.ts b/packages/vue-store/src/index.ts index 6b2ebd0b..1ed45cdf 100644 --- a/packages/vue-store/src/index.ts +++ b/packages/vue-store/src/index.ts @@ -92,6 +92,30 @@ export function shallow(objA: T, objB: T) { return false } + // Many "value objects" (e.g. Temporal) have no enumerable keys, which would + // otherwise make any two instances appear "shallow equal". Only treat + // keyless values as equal when both are plain objects or both are arrays. + if (keysA.length === 0) { + const aIsPlain = isPlainObject(objA) + const bIsPlain = isPlainObject(objB) + const aIsArray = Array.isArray(objA) + const bIsArray = Array.isArray(objB) + + if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { + return true + } + + if (hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false + } + } + + return false + } + for (let i = 0; i < keysA.length; i++) { if ( !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || @@ -102,3 +126,20 @@ export function shallow(objA: T, objB: T) { } return true } + +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function hasEquals( + value: TValue, +): value is TValue & { equals: (other: unknown) => boolean } { + return ( + typeof value === 'object' && + value !== null && + 'equals' in (value as object) && + typeof (value as any).equals === 'function' + ) +} diff --git a/packages/vue-store/tests/index.test.tsx b/packages/vue-store/tests/index.test.tsx index ebdb3f98..52c7ec17 100644 --- a/packages/vue-store/tests/index.test.tsx +++ b/packages/vue-store/tests/index.test.tsx @@ -4,6 +4,7 @@ import { defineComponent, h } from 'vue-demi' import { render, waitFor } from '@testing-library/vue' import { Store } from '@tanstack/store' import { userEvent } from '@testing-library/user-event' +import { Temporal } from 'temporal-polyfill' import { shallow, useStore } from '../src/index' const user = userEvent.setup() @@ -168,4 +169,14 @@ describe('shallow', () => { const objB = new Date('2025-02-10') expect(shallow(objA, objB)).toBe(true) }) + + test('should return false for empty object vs empty array', () => { + expect(shallow({}, [])).toBe(false) + }) + + test('should return false for temporal objects with different values', () => { + const objA = Temporal.PlainDate.from('2025-02-10') + const objB = Temporal.PlainDate.from('2025-02-11') + expect(shallow(objA, objB)).toBe(false) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe910cf..acc6288e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: sherif: specifier: ^1.9.0 version: 1.9.0 + temporal-polyfill: + specifier: ^0.3.0 + version: 0.3.0 tinyglobby: specifier: ^0.2.15 version: 0.2.15 @@ -7187,6 +7190,13 @@ packages: tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + + temporal-polyfill@0.3.0: + resolution: {integrity: sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==} + + temporal-spec@0.3.0: + resolution: {integrity: sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==} term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -8000,9 +8010,9 @@ snapshots: tree-kill: 1.2.2 tslib: 2.8.1 typescript: 5.6.3 - webpack: 5.98.0(esbuild@0.25.4) + webpack: 5.98.0 webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.4)) - webpack-dev-server: 5.2.2(webpack@5.98.0(esbuild@0.25.4)) + webpack-dev-server: 5.2.2(webpack@5.98.0) webpack-merge: 6.0.1 webpack-subresource-integrity: 5.1.0(webpack@5.98.0(esbuild@0.25.4)) optionalDependencies: @@ -8164,7 +8174,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.10) '@inquirer/confirm': 5.1.6(@types/node@24.9.2) - '@vitejs/plugin-basic-ssl': 1.2.0(vite@6.4.1(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.1)) + '@vitejs/plugin-basic-ssl': 1.2.0(vite@6.4.1(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.2)(sass@1.93.3)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.1)) beasties: 0.3.2 browserslist: 4.27.0 esbuild: 0.25.4 @@ -11176,7 +11186,6 @@ snapshots: '@vitejs/plugin-basic-ssl@1.2.0(vite@6.4.1(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.2)(sass@1.93.3)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: vite: 6.4.1(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.2)(sass@1.93.3)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.1) - optional: true '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.2)(sass@1.93.3)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: @@ -15685,6 +15694,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + temporal-polyfill@0.3.0: + dependencies: + temporal-spec: 0.3.0 + + temporal-spec@0.3.0: {} + term-size@2.2.1: {} terser-webpack-plugin@5.3.14(esbuild@0.25.4)(webpack@5.98.0(esbuild@0.25.4)): @@ -15698,6 +15713,15 @@ snapshots: optionalDependencies: esbuild: 0.25.4 + terser-webpack-plugin@5.3.14(webpack@5.98.0(esbuild@0.25.4)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.39.0 + webpack: 5.98.0 + terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -16273,6 +16297,17 @@ snapshots: optionalDependencies: webpack: 5.98.0(esbuild@0.25.4) + webpack-dev-middleware@7.4.2(webpack@5.98.0): + dependencies: + colorette: 2.0.20 + memfs: 4.50.0 + mime-types: 2.1.35 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.98.0 + webpack-dev-server@5.2.2(webpack@5.98.0(esbuild@0.25.4)): dependencies: '@types/bonjour': 3.5.13 @@ -16311,6 +16346,44 @@ snapshots: - supports-color - utf-8-validate + webpack-dev-server@5.2.2(webpack@5.98.0): + dependencies: + '@types/bonjour': 3.5.13 + '@types/connect-history-api-fallback': 1.5.4 + '@types/express': 4.17.25 + '@types/express-serve-static-core': 4.19.7 + '@types/serve-index': 1.9.4 + '@types/serve-static': 1.15.10 + '@types/sockjs': 0.3.36 + '@types/ws': 8.18.1 + ansi-html-community: 0.0.8 + bonjour-service: 1.3.0 + chokidar: 3.6.0 + colorette: 2.0.20 + compression: 1.8.1 + connect-history-api-fallback: 2.0.0 + express: 4.21.2 + graceful-fs: 4.2.11 + http-proxy-middleware: 2.0.9(@types/express@4.17.25) + ipaddr.js: 2.2.0 + launch-editor: 2.12.0 + open: 10.1.0 + p-retry: 6.2.1 + schema-utils: 4.3.3 + selfsigned: 2.4.1 + serve-index: 1.9.1 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack-dev-middleware: 7.4.2(webpack@5.98.0) + ws: 8.18.3 + optionalDependencies: + webpack: 5.98.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + webpack-merge@6.0.1: dependencies: clone-deep: 4.0.1 @@ -16327,6 +16400,36 @@ snapshots: webpack-virtual-modules@0.6.2: optional: true + webpack@5.98.0: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + browserslist: 4.27.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(webpack@5.98.0(esbuild@0.25.4)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.98.0(esbuild@0.25.4): dependencies: '@types/eslint-scope': 3.7.7 From b9914b969a4f6330bc414ac8df96dd93e119abb3 Mon Sep 17 00:00:00 2001 From: Michael Francis Date: Mon, 2 Feb 2026 18:55:10 -0500 Subject: [PATCH 2/3] feat: add support for Temporal objects in shallow equality checks Enhanced the `shallow` equality function across multiple adapters (React, Preact, Solid, Vue, Svelte, Angular) to correctly handle Temporal value objects. Introduced a method to check for Temporal branding using `Symbol.toStringTag`, ensuring accurate comparisons and triggering UI updates when necessary. Added tests for Temporal support in the relevant frameworks. --- package.json | 4 ++- packages/angular-store/src/index.ts | 26 +++++++++++++++++- packages/preact-store/src/index.ts | 26 +++++++++++++++++- packages/react-store/src/index.ts | 32 ++++++++++++++++++++--- packages/react-store/tests/index.test.tsx | 7 +++++ packages/solid-store/src/index.tsx | 26 +++++++++++++++++- packages/svelte-store/src/index.svelte.ts | 26 +++++++++++++++++- packages/vue-store/src/index.ts | 26 +++++++++++++++++- pnpm-lock.yaml | 20 ++++++++++++++ 9 files changed, 183 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ccf6f8b1..467a5ab0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@changesets/cli": "^2.29.8", "@eslint-react/eslint-plugin": "^1.53.1", + "@js-temporal/polyfill": "^0.5.1", "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tanstack/eslint-config": "0.3.3", "@tanstack/typedoc-config": "0.3.1", @@ -62,6 +63,7 @@ "sherif": "^1.9.0", "temporal-polyfill": "^0.3.0", "tinyglobby": "^0.2.15", + "tsx": "^4.21.0", "typescript": "5.6.3", "typescript50": "npm:typescript@5.0", "typescript51": "npm:typescript@5.1", @@ -80,4 +82,4 @@ "@tanstack/svelte-store": "workspace:*", "@tanstack/vue-store": "workspace:*" } -} +} \ No newline at end of file diff --git a/packages/angular-store/src/index.ts b/packages/angular-store/src/index.ts index 0e2930e4..c7d92234 100644 --- a/packages/angular-store/src/index.ts +++ b/packages/angular-store/src/index.ts @@ -108,7 +108,25 @@ function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -146,3 +164,9 @@ function hasEquals( typeof (value as any).equals === 'function' ) } + +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} diff --git a/packages/preact-store/src/index.ts b/packages/preact-store/src/index.ts index 2789b75c..c774a120 100644 --- a/packages/preact-store/src/index.ts +++ b/packages/preact-store/src/index.ts @@ -180,7 +180,25 @@ export function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -219,6 +237,12 @@ function hasEquals( ) } +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} + function getOwnKeys(obj: object): Array { return (Object.keys(obj) as Array).concat( Object.getOwnPropertySymbols(obj), diff --git a/packages/react-store/src/index.ts b/packages/react-store/src/index.ts index c9aca182..cc2343e1 100644 --- a/packages/react-store/src/index.ts +++ b/packages/react-store/src/index.ts @@ -89,7 +89,25 @@ export function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -100,10 +118,10 @@ export function shallow(objA: T, objB: T) { return false } - for (let i = 0; i < keysA.length; i++) { + for (const key of keysA) { if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || - !Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T]) + !Object.prototype.hasOwnProperty.call(objB, key) || + !Object.is(objA[key as keyof T], objB[key as keyof T]) ) { return false } @@ -128,6 +146,12 @@ function hasEquals( ) } +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} + function getOwnKeys(obj: object): Array { return (Object.keys(obj) as Array).concat( Object.getOwnPropertySymbols(obj), diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 073b918c..ccc79fdf 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react' import { Derived, Store } from '@tanstack/store' import { useState } from 'react' import { userEvent } from '@testing-library/user-event' +import { Temporal as JsTemporal } from '@js-temporal/polyfill' import { Temporal } from 'temporal-polyfill' import { shallow, useStore } from '../src/index' @@ -313,4 +314,10 @@ describe('shallow', () => { const objB = Temporal.PlainDate.from('2025-02-11') expect(shallow(objA, objB)).toBe(false) }) + + test('supports Temporal from @js-temporal/polyfill', () => { + const objA = JsTemporal.PlainDate.from('2025-02-10') + const objB = JsTemporal.PlainDate.from('2025-02-10') + expect(shallow(objA, objB)).toBe(true) + }) }) diff --git a/packages/solid-store/src/index.tsx b/packages/solid-store/src/index.tsx index c69f08d0..163f470c 100644 --- a/packages/solid-store/src/index.tsx +++ b/packages/solid-store/src/index.tsx @@ -99,7 +99,25 @@ export function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -137,3 +155,9 @@ function hasEquals( typeof (value as any).equals === 'function' ) } + +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} diff --git a/packages/svelte-store/src/index.svelte.ts b/packages/svelte-store/src/index.svelte.ts index e6f4f940..728d1bc7 100644 --- a/packages/svelte-store/src/index.svelte.ts +++ b/packages/svelte-store/src/index.svelte.ts @@ -101,7 +101,25 @@ export function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -139,3 +157,9 @@ function hasEquals( typeof (value as any).equals === 'function' ) } + +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} diff --git a/packages/vue-store/src/index.ts b/packages/vue-store/src/index.ts index 1ed45cdf..abc71944 100644 --- a/packages/vue-store/src/index.ts +++ b/packages/vue-store/src/index.ts @@ -105,7 +105,25 @@ export function shallow(objA: T, objB: T) { return true } - if (hasEquals(objA) && hasEquals(objB)) { + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { try { return objA.equals(objB) } catch { @@ -143,3 +161,9 @@ function hasEquals( typeof (value as any).equals === 'function' ) } + +function getToStringTag(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null) return undefined + const tag = (value as any)[Symbol.toStringTag] + return typeof tag === 'string' ? tag : undefined +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acc6288e..5fe9b3e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint-react/eslint-plugin': specifier: ^1.53.1 version: 1.53.1(eslint@9.39.2(jiti@2.6.1))(ts-api-utils@2.1.0(typescript@5.6.3))(typescript@5.6.3) + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) @@ -77,6 +80,9 @@ importers: tinyglobby: specifier: ^0.2.15 version: 0.2.15 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: 5.6.3 version: 5.6.3 @@ -2077,6 +2083,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -5453,6 +5463,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -7186,6 +7199,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} @@ -9913,6 +9927,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -13693,6 +13711,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 From f4d25fd4360863258a5aef2864a03fb0fbc53e13 Mon Sep 17 00:00:00 2001 From: Michael Francis Date: Mon, 2 Feb 2026 20:08:35 -0500 Subject: [PATCH 3/3] refactor: streamline shallow equality checks across adapters Refactored the `shallow` equality function in Angular, Preact, React, Solid, Svelte, and Vue to improve handling of Temporal objects. The implementation now ensures accurate comparisons by checking `Symbol.toStringTag` for Temporal branding and simplifies the logic for keyless values. Removed redundant checks for plain objects and arrays, enhancing code clarity. Updated tests to reflect these changes and maintain coverage for Temporal support. --- packages/angular-store/src/index.ts | 74 ++++++++------------- packages/preact-store/src/index.ts | 76 ++++++++-------------- packages/preact-store/tests/index.test.tsx | 4 -- packages/react-store/src/index.ts | 71 ++++++++------------ packages/react-store/tests/index.test.tsx | 4 -- packages/solid-store/src/index.tsx | 74 ++++++++------------- packages/solid-store/tests/index.test.tsx | 4 -- packages/svelte-store/src/index.svelte.ts | 74 ++++++++------------- packages/svelte-store/tests/index.test.ts | 4 -- packages/vue-store/src/index.ts | 74 ++++++++------------- packages/vue-store/tests/index.test.tsx | 4 -- 11 files changed, 157 insertions(+), 306 deletions(-) diff --git a/packages/angular-store/src/index.ts b/packages/angular-store/src/index.ts index c7d92234..bf716889 100644 --- a/packages/angular-store/src/index.ts +++ b/packages/angular-store/src/index.ts @@ -90,50 +90,34 @@ function shallow(objA: T, objB: T) { return true } - const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { - return false - } - - // Many "value objects" (e.g. Temporal) have no enumerable keys, which would - // otherwise make any two instances appear "shallow equal". Only treat - // keyless values as equal when both are plain objects or both are arrays. - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { return false } @@ -148,12 +132,6 @@ function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/preact-store/src/index.ts b/packages/preact-store/src/index.ts index c774a120..dea75dfc 100644 --- a/packages/preact-store/src/index.ts +++ b/packages/preact-store/src/index.ts @@ -162,56 +162,40 @@ export function shallow(objA: T, objB: T) { return true } - const keysA = getOwnKeys(objA) - if (keysA.length !== getOwnKeys(objB).length) { - return false - } - - // Many "value objects" (e.g. Temporal) have no enumerable keys, which would - // otherwise make any two instances appear "shallow equal". Only treat - // keyless values as equal when both are plain objects or both are arrays. - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = getOwnKeys(objA) + if (keysA.length !== getOwnKeys(objB).length) { return false } for (const key of keysA) { if ( - !Object.prototype.hasOwnProperty.call(objB, key as string) || + !Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key as keyof T], objB[key as keyof T]) ) { return false @@ -220,12 +204,6 @@ export function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/preact-store/tests/index.test.tsx b/packages/preact-store/tests/index.test.tsx index 843721d8..d5e21e7c 100644 --- a/packages/preact-store/tests/index.test.tsx +++ b/packages/preact-store/tests/index.test.tsx @@ -305,10 +305,6 @@ describe('shallow', () => { expect(shallow(objA, objB)).toBe(true) }) - test('should return false for empty object vs empty array', () => { - expect(shallow({}, [])).toBe(false) - }) - test('should return false for temporal objects with different values', () => { const objA = Temporal.PlainDate.from('2025-02-10') const objB = Temporal.PlainDate.from('2025-02-11') diff --git a/packages/react-store/src/index.ts b/packages/react-store/src/index.ts index cc2343e1..cdb7b5df 100644 --- a/packages/react-store/src/index.ts +++ b/packages/react-store/src/index.ts @@ -74,47 +74,34 @@ export function shallow(objA: T, objB: T) { return true } - const keysA = getOwnKeys(objA) - if (keysA.length !== getOwnKeys(objB).length) { - return false - } - - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = getOwnKeys(objA) + if (keysA.length !== getOwnKeys(objB).length) { return false } @@ -129,12 +116,6 @@ export function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index ccc79fdf..596a89e8 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -305,10 +305,6 @@ describe('shallow', () => { expect(shallow(objA, objB)).toBe(true) }) - test('should return false for empty object vs empty array', () => { - expect(shallow({}, [])).toBe(false) - }) - test('should return false for temporal objects with different values', () => { const objA = Temporal.PlainDate.from('2025-02-10') const objB = Temporal.PlainDate.from('2025-02-11') diff --git a/packages/solid-store/src/index.tsx b/packages/solid-store/src/index.tsx index 163f470c..0cd26f7d 100644 --- a/packages/solid-store/src/index.tsx +++ b/packages/solid-store/src/index.tsx @@ -81,50 +81,34 @@ export function shallow(objA: T, objB: T) { return true } - const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { - return false - } - - // Many "value objects" (e.g. Temporal) have no enumerable keys, which would - // otherwise make any two instances appear "shallow equal". Only treat - // keyless values as equal when both are plain objects or both are arrays. - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { return false } @@ -139,12 +123,6 @@ export function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/solid-store/tests/index.test.tsx b/packages/solid-store/tests/index.test.tsx index e82a3a40..a078fe49 100644 --- a/packages/solid-store/tests/index.test.tsx +++ b/packages/solid-store/tests/index.test.tsx @@ -56,10 +56,6 @@ describe('useStore', () => { }) describe('shallow', () => { - test('should return false for empty object vs empty array', () => { - expect(shallow({}, [])).toBe(false) - }) - test('should return false for temporal objects with different values', () => { const objA = Temporal.PlainDate.from('2025-02-10') const objB = Temporal.PlainDate.from('2025-02-11') diff --git a/packages/svelte-store/src/index.svelte.ts b/packages/svelte-store/src/index.svelte.ts index 728d1bc7..c6318d2f 100644 --- a/packages/svelte-store/src/index.svelte.ts +++ b/packages/svelte-store/src/index.svelte.ts @@ -83,50 +83,34 @@ export function shallow(objA: T, objB: T) { return true } - const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { - return false - } - - // Many "value objects" (e.g. Temporal) have no enumerable keys, which would - // otherwise make any two instances appear "shallow equal". Only treat - // keyless values as equal when both are plain objects or both are arrays. - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { return false } @@ -141,12 +125,6 @@ export function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/svelte-store/tests/index.test.ts b/packages/svelte-store/tests/index.test.ts index d160c999..ab435952 100644 --- a/packages/svelte-store/tests/index.test.ts +++ b/packages/svelte-store/tests/index.test.ts @@ -93,10 +93,6 @@ describe('shallow', () => { expect(shallow(objA, objB)).toBe(true) }) - test('should return false for empty object vs empty array', () => { - expect(shallow({}, [])).toBe(false) - }) - test('should return false for temporal objects with different values', () => { const objA = Temporal.PlainDate.from('2025-02-10') const objB = Temporal.PlainDate.from('2025-02-11') diff --git a/packages/vue-store/src/index.ts b/packages/vue-store/src/index.ts index abc71944..9910cd46 100644 --- a/packages/vue-store/src/index.ts +++ b/packages/vue-store/src/index.ts @@ -87,50 +87,34 @@ export function shallow(objA: T, objB: T) { return true } - const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { - return false - } - - // Many "value objects" (e.g. Temporal) have no enumerable keys, which would - // otherwise make any two instances appear "shallow equal". Only treat - // keyless values as equal when both are plain objects or both are arrays. - if (keysA.length === 0) { - const aIsPlain = isPlainObject(objA) - const bIsPlain = isPlainObject(objB) - const aIsArray = Array.isArray(objA) - const bIsArray = Array.isArray(objB) - - if ((aIsPlain && bIsPlain) || (aIsArray && bIsArray)) { - return true - } - - /** - * Temporal branding note: - * Temporal types (native or polyfill) define `Symbol.toStringTag` values like - * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this - * check reliable across realms/polyfills (unlike `instanceof`). - * - * See: - * - https://tc39.es/proposal-temporal/ - * - https://tc39.es/proposal-temporal/docs/plaindate.html - */ - const tagA = getToStringTag(objA) - const tagB = getToStringTag(objB) - const isTemporal = - tagA !== undefined && - tagB !== undefined && - tagA === tagB && - tagA.startsWith('Temporal.') - - if (isTemporal && hasEquals(objA) && hasEquals(objB)) { - try { - return objA.equals(objB) - } catch { - return false - } + /** + * Temporal branding note: + * Temporal types (native or polyfill) define `Symbol.toStringTag` values like + * `"Temporal.PlainDate"` as part of the TC39 Temporal spec, which makes this + * check reliable across realms/polyfills (unlike `instanceof`). + * + * See: + * - https://tc39.es/proposal-temporal/ + * - https://tc39.es/proposal-temporal/docs/plaindate.html + */ + const tagA = getToStringTag(objA) + const tagB = getToStringTag(objB) + const isTemporal = + tagA !== undefined && + tagB !== undefined && + tagA === tagB && + tagA.startsWith('Temporal.') + + if (isTemporal && hasEquals(objA) && hasEquals(objB)) { + try { + return objA.equals(objB) + } catch { + return false } + } + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { return false } @@ -145,12 +129,6 @@ export function shallow(objA: T, objB: T) { return true } -function isPlainObject(value: unknown): value is object { - if (typeof value !== 'object' || value === null) return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - function hasEquals( value: TValue, ): value is TValue & { equals: (other: unknown) => boolean } { diff --git a/packages/vue-store/tests/index.test.tsx b/packages/vue-store/tests/index.test.tsx index 52c7ec17..832784fb 100644 --- a/packages/vue-store/tests/index.test.tsx +++ b/packages/vue-store/tests/index.test.tsx @@ -170,10 +170,6 @@ describe('shallow', () => { expect(shallow(objA, objB)).toBe(true) }) - test('should return false for empty object vs empty array', () => { - expect(shallow({}, [])).toBe(false) - }) - test('should return false for temporal objects with different values', () => { const objA = Temporal.PlainDate.from('2025-02-10') const objB = Temporal.PlainDate.from('2025-02-11')