From 46140cc808623d3e599dd26d5dd7b8b4b5105e38 Mon Sep 17 00:00:00 2001 From: Latitude Bot Date: Wed, 11 Feb 2026 12:06:59 -0500 Subject: [PATCH] fix: return toPrimitive function instead of throwing for React 19 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 19's development mode introduces `logComponentRender` which inspects component props by calling `Symbol.toPrimitive` on them. When Legend State observables are passed as props, this triggers the proxy's `get` trap for `Symbol.toPrimitive`, which previously threw an error. This crashes the entire component tree in React 19 dev mode. The fix changes the behavior from throwing to: - Returning a proper `Symbol.toPrimitive` function (`NaN` for numeric hint, `'[Observable]'` for string/default hint) - Emitting a `console.warn` in development mode to still help developers catch accidental primitive coercion This is a non-breaking change for production code (the throw was only useful as a dev-time guard), and it's consistent with how other Proxy-based state libraries handle this scenario. Fixes React 19 compatibility where `addValueToProperties` → `logComponentRender` in react-dom-client would crash when encountering Legend State observables. Co-authored-by: Cursor --- src/ObservableObject.ts | 15 ++++++++++----- tests/tests.test.ts | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/ObservableObject.ts b/src/ObservableObject.ts index 24911aed0..51d75c173 100644 --- a/src/ObservableObject.ts +++ b/src/ObservableObject.ts @@ -395,11 +395,16 @@ export function flushPending() { const proxyHandler: ProxyHandler = { get(node: NodeInfo, p: any, receiver: any) { if (p === symbolToPrimitive) { - throw new Error( - process.env.NODE_ENV === 'development' - ? '[legend-state] observable should not be used as a primitive. You may have forgotten to use .get() or .peek() to get the value of the observable.' - : '[legend-state] observable is not a primitive.', - ); + // Return a toPrimitive function instead of throwing so that external code + // (e.g. React 19's dev-mode logComponentRender) can safely coerce observables + // without crashing. A dev-mode warning is still emitted to help catch + // accidental primitive usage in user code. + if (process.env.NODE_ENV === 'development') { + console.warn( + '[legend-state] observable is being converted to a primitive. You may have forgotten to use .get() or .peek() to get the value of the observable.', + ); + } + return (hint: string) => (hint === 'number' ? NaN : '[Observable]'); } if (p === symbolGetNode) { return node; diff --git a/tests/tests.test.ts b/tests/tests.test.ts index f8fda2f91..c2bf023bc 100644 --- a/tests/tests.test.ts +++ b/tests/tests.test.ts @@ -3493,16 +3493,26 @@ describe('_', () => { }); }); describe('Built-in functions', () => { - test('Adding observables should throw', () => { + test('Adding observables should warn and return NaN', () => { const obs = observable({ x: 0, y: 0 }); const x = obs.x; const y = obs.y; - expect(() => { - // @ts-expect-error Testing error - x + y; - }).toThrowError(/observable is not a primitive/); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error Testing primitive coercion + const result = x + y; + + // Should produce NaN since both observables coerce to NaN for numeric hint + expect(result).toBeNaN(); + + // Should warn in development mode + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('observable is being converted to a primitive'), + ); + + warnSpy.mockRestore(); }); }); describe('setAtPath', () => {