diff --git a/src/ObservableObject.ts b/src/ObservableObject.ts index f09bdb7a..24911aed 100644 --- a/src/ObservableObject.ts +++ b/src/ObservableObject.ts @@ -412,6 +412,11 @@ const proxyHandler: ProxyHandler = { } } + if (p === 'constructor') { + const ctor = peekInternal(node)?.constructor; + return typeof ctor === 'function' ? ctor : Object; + } + let value = peekInternal(node, /*activateRecursive*/ p === 'get' || p === 'peek'); // Trying to get an iterator if the raw value is a primitive should return undefined. @@ -639,7 +644,20 @@ const proxyHandler: ProxyHandler = { }, has(node: NodeInfo, prop: string) { const value = getNodeValue(node); - return Reflect.has(value, prop); + + // Short-circuit for the inputs that make Reflect.has error. + if (value === undefined || value === null) { + return false; + } + + // Functions behave like objects here, so let them flow through. + if (typeof value === 'object' || typeof value === 'function') { + return Reflect.has(value, prop); + } + + // For primitives (number, string, boolean, bigint, symbol) report “no key”. + // That keeps inspection code happy without pretending properties exist. + return false; }, apply(target, thisArg, argArray) { // If it's a function call it as a function diff --git a/src/observableTypes.ts b/src/observableTypes.ts index 76e52501..99c8fc48 100644 --- a/src/observableTypes.ts +++ b/src/observableTypes.ts @@ -43,7 +43,8 @@ export type RemoveObservables = : T; interface ObservableArray - extends ObservablePrimitive, + extends + ObservablePrimitive, Pick>, ArrayOverrideFnNames>, Omit>, ArrayOverrideFnNames> {} diff --git a/src/sync-plugins/crud.ts b/src/sync-plugins/crud.ts index 38cd8a53..dee705bd 100644 --- a/src/sync-plugins/crud.ts +++ b/src/sync-plugins/crud.ts @@ -69,8 +69,10 @@ export interface CrudErrorParams extends Omit { export type CrudOnErrorFn = (error: Error, params: CrudErrorParams) => void; -export interface SyncedCrudPropsBase - extends Omit, 'get' | 'set' | 'initial' | 'subscribe' | 'waitForSet' | 'onError'> { +export interface SyncedCrudPropsBase extends Omit< + SyncedOptions, + 'get' | 'set' | 'initial' | 'subscribe' | 'waitForSet' | 'onError' +> { create?(input: TRemote, params: SyncedSetParams): Promise | null | undefined | void>; update?( input: Partial, diff --git a/src/sync-plugins/fetch.ts b/src/sync-plugins/fetch.ts index 0205ae6d..2456960f 100644 --- a/src/sync-plugins/fetch.ts +++ b/src/sync-plugins/fetch.ts @@ -8,8 +8,10 @@ export interface SyncedFetchOnSavedParams { props: SyncedFetchProps; } -export interface SyncedFetchProps - extends Omit, 'get' | 'set'> { +export interface SyncedFetchProps extends Omit< + SyncedOptions, + 'get' | 'set' +> { get: Selector; set?: Selector; getInit?: RequestInit; diff --git a/src/sync-plugins/firebase.ts b/src/sync-plugins/firebase.ts index bdaa9a8e..ee3afe94 100644 --- a/src/sync-plugins/firebase.ts +++ b/src/sync-plugins/firebase.ts @@ -50,7 +50,8 @@ import { clone } from '../globals'; // Should it have mode merge by default? export interface SyncedFirebaseProps - extends Omit, 'list' | 'retry'>, + extends + Omit, 'list' | 'retry'>, Omit, 'onError'> { refPath: (uid: string | undefined) => string; query?: (ref: DatabaseReference) => DatabaseReference | Query; diff --git a/src/sync-plugins/keel.ts b/src/sync-plugins/keel.ts index 1d8d40e7..e2e75ddf 100644 --- a/src/sync-plugins/keel.ts +++ b/src/sync-plugins/keel.ts @@ -82,8 +82,10 @@ interface PageInfo { totalCount: number; } -interface SyncedKeelPropsManyBase - extends Omit, 'list'> { +interface SyncedKeelPropsManyBase extends Omit< + SyncedCrudPropsMany, + 'list' +> { first?: number; get?: never; } @@ -103,8 +105,11 @@ interface SyncedKeelPropsManyWhere< >; where?: Where | (() => Where); } -interface SyncedKeelPropsManyNoWhere - extends SyncedKeelPropsManyBase { +interface SyncedKeelPropsManyNoWhere< + TRemote extends { id: string }, + TLocal, + AOption extends CrudAsOption, +> extends SyncedKeelPropsManyBase { list?: (params: KeelListParams<{}>) => Promise< CrudResult< APIResult<{ @@ -127,8 +132,10 @@ type SyncedKeelPropsMany< ? SyncedKeelPropsManyWhere : SyncedKeelPropsManyNoWhere; -interface SyncedKeelPropsSingle - extends Omit, 'get'> { +interface SyncedKeelPropsSingle extends Omit< + SyncedCrudPropsSingle, + 'get' +> { get?: (params: KeelGetParams) => Promise>; first?: never; @@ -141,11 +148,10 @@ export interface KeelErrorParams extends CrudErrorParams { action: string; } -export interface SyncedKeelPropsBase - extends Omit< - SyncedCrudPropsBase, - 'create' | 'update' | 'delete' | 'updatePartial' | 'fieldUpdatedAt' | 'fieldCreatedAt' | 'onError' - > { +export interface SyncedKeelPropsBase extends Omit< + SyncedCrudPropsBase, + 'create' | 'update' | 'delete' | 'updatePartial' | 'fieldUpdatedAt' | 'fieldCreatedAt' | 'onError' +> { client?: KeelClient; create?: (i: NoInfer>) => Promise>>; update?: (params: { where: any; values?: Partial> }) => Promise>; diff --git a/src/sync-plugins/supabase.ts b/src/sync-plugins/supabase.ts index 8734e629..005aa650 100644 --- a/src/sync-plugins/supabase.ts +++ b/src/sync-plugins/supabase.ts @@ -70,11 +70,10 @@ export type SyncedSupabaseConfig; -export interface SyncedSupabaseConfiguration - extends Omit< - SyncedSupabaseConfig<{ id: string | number }, { id: string | number }>, - 'persist' | keyof SyncedOptions - > { +export interface SyncedSupabaseConfiguration extends Omit< + SyncedSupabaseConfig<{ id: string | number }, { id: string | number }>, + 'persist' | keyof SyncedOptions +> { persist?: SyncedOptionsGlobal; enabled?: Observable; as?: Exclude; @@ -87,8 +86,8 @@ interface SyncedSupabaseProps< TOption extends CrudAsOption = 'object', TRemote extends SupabaseRowOf = SupabaseRowOf, TLocal = TRemote, -> extends SyncedSupabaseConfig, - Omit, 'list'> { +> + extends SyncedSupabaseConfig, Omit, 'list'> { supabase?: Client; collection: Collection; schema?: SchemaName; diff --git a/src/sync-plugins/tanstack-query.ts b/src/sync-plugins/tanstack-query.ts index f5d8acff..4b5afed0 100644 --- a/src/sync-plugins/tanstack-query.ts +++ b/src/sync-plugins/tanstack-query.ts @@ -14,13 +14,17 @@ import { let nextMutationKey = 0; -export interface ObservableQueryOptions - extends Omit, 'queryKey'> { +export interface ObservableQueryOptions extends Omit< + QueryObserverOptions, + 'queryKey' +> { queryKey?: TQueryKey | (() => TQueryKey); } -export interface SyncedQueryParams - extends Omit, 'get' | 'set' | 'retry'> { +export interface SyncedQueryParams extends Omit< + SyncedOptions, + 'get' | 'set' | 'retry' +> { queryClient: QueryClient; query: ObservableQueryOptions; mutation?: MutationObserverOptions; diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index 6206dda8..af82c327 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -104,11 +104,10 @@ export interface SyncedOptions extends Omit void; } -export interface SyncedOptionsGlobal - extends Omit< - SyncedOptions, - 'get' | 'set' | 'persist' | 'initial' | 'waitForSet' | 'waitFor' | 'transform' | 'subscribe' - > { +export interface SyncedOptionsGlobal extends Omit< + SyncedOptions, + 'get' | 'set' | 'persist' | 'initial' | 'waitForSet' | 'waitFor' | 'transform' | 'subscribe' +> { persist?: ObservablePersistPluginOptions & Omit; } diff --git a/tests/tests.test.ts b/tests/tests.test.ts index 944e22fa..f8fda2f9 100644 --- a/tests/tests.test.ts +++ b/tests/tests.test.ts @@ -3881,4 +3881,16 @@ describe('Misc', () => { expect(descriptProxy).toEqual(undefined); expect(descriptValue).toEqual(undefined); }); + test('Observable is compatible with vitest equality checks and error printing', () => { + const obs = observable({ foo: 'bar' }); + const obj = obs.foo; + const ctor = obj.constructor; + + // https://github.com/vitest-dev/vitest/blob/v3.2.4/packages/expect/src/jest-utils.ts#L42-L49 + expect(!!obj && typeof obj === 'object' && 'asymmetricMatch' in obj).toBe(false); + + // https://github.com/vitest-dev/vitest/blob/v3.2.4/packages/pretty-format/src/index.ts#L288 + expect(typeof ctor).toBe('function'); + expect(obj.constructor).toBe(ctor); + }); });