From 38bd0b4dcfc9c71f6c5ab9280121c0230a5aa87e Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 09:27:00 -0400 Subject: [PATCH 1/2] refactor: apply hinge event payloads instead of re-reading the snapshot subscribe ignored the onHingesChange payload and called getSnapshot on every event, plus once synchronously right after startObserving to avoid applying a queued older event. Both native modules already replay the current snapshot as an event when startObserving runs, so that synchronous read was redundant. Apply event.hinges directly and drop the extra read. --- src/HingeObserver.ts | 13 +++++-------- src/__tests__/index.test.tsx | 16 ++++++++++------ website/docs/api.md | 2 +- website/docs/observers.md | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/HingeObserver.ts b/src/HingeObserver.ts index d06ab08..a3f4587 100644 --- a/src/HingeObserver.ts +++ b/src/HingeObserver.ts @@ -36,7 +36,8 @@ export function mapHinges(hinges: readonly NativeHinge[]): readonly Hinge[] { /** * Creates an observer for an existing React root tag (available from RootTagContext). - * Creation reads the native cache once; subscribe to keep the snapshot current. + * Creation reads the native cache once as a seed; further updates arrive with the + * onHingesChange event, and subscribing triggers a native replay of the current snapshot. * Multiple observers for the same root share native observation. No provider is needed. */ export function createHingeObserver(root: number | RootTag): HingeObserver { @@ -56,11 +57,6 @@ export function createHingeObserver(root: number | RootTag): HingeObserver { return true; }; update(NativeHinges.getSnapshot(rootTag).hinges); - const refresh = () => { - if (update(NativeHinges.getSnapshot(rootTag).hinges)) { - for (const callback of listeners) callback(); - } - }; return { get: () => snapshot, subscribe: (listener) => { @@ -69,10 +65,11 @@ export function createHingeObserver(root: number | RootTag): HingeObserver { if (listeners.size === 1) { subscription = NativeHinges.onHingesChange((event) => { if (event.rootTag !== rootTag) return; - refresh(); + if (update(event.hinges)) { + for (const callback of listeners) callback(); + } }); NativeHinges.startObserving(rootTag); - refresh(); } return () => { if (!listeners.delete(notify)) return; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 7ad29a7..37c66cf 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -18,7 +18,10 @@ jest.mock('../HingesModule', () => ({ __esModule: true, default: { getSnapshot: jest.fn((root: number) => ({ hinges: mockSnapshots.get(root) ?? [] })), - startObserving: jest.fn(), + startObserving: jest.fn((rootTag: number) => { + const hinges = mockSnapshots.get(rootTag) ?? []; + for (const listener of mockListeners) listener({ rootTag, hinges }); + }), stopObserving: jest.fn(), onHingesChange: jest.fn((listener: (event: HingesChangeEvent) => void) => { mockListeners.add(listener); @@ -72,13 +75,13 @@ it('isolates roots, shares one observation across subscriptions, and releases on expect(mockListeners.size).toBe(0); }); -it('does not replace a newer cached snapshot with a queued older event', () => { +it('applies event payloads directly without re-reading the native snapshot', () => { const observer = createHingeObserver(1); const off = observer.subscribe(jest.fn()); + expect(NativeHinges.getSnapshot).toHaveBeenCalledTimes(1); emit(1, native); - const snapshot = observer.get(); - for (const listener of mockListeners) listener({ rootTag: 1, hinges: [] }); - expect(observer.get()).toBe(snapshot); + expect(observer.get()).toEqual([{ status: 'partiallyOpen', angle: Math.PI / 2 }]); + expect(NativeHinges.getSnapshot).toHaveBeenCalledTimes(1); off(); }); @@ -149,12 +152,13 @@ it('throws a clear error when useHinges renders without a RootTagContext provide } }); -it('refreshes state that changed between observer creation and subscription', () => { +it('delivers state that changed between observer creation and subscription via the startObserving replay', () => { const observer = createHingeObserver(1); expect(observer.get()).toEqual([]); mockSnapshots.set(1, native); const listener = jest.fn(); const off = observer.subscribe(listener); + expect(NativeHinges.getSnapshot).toHaveBeenCalledTimes(1); expect(observer.get()).toEqual([{ status: 'partiallyOpen', angle: Math.PI / 2 }]); expect(listener).toHaveBeenCalledTimes(1); off(); diff --git a/website/docs/api.md b/website/docs/api.md index b27705f..017b47f 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -58,7 +58,7 @@ Subscribe callbacks receive no arguments. Read `get()` to obtain the latest snap function createHingeObserver(rootTag: number | RootTag): HingeObserver; ``` -Creates an observer for an existing React root. Obtain its tag from React Native's `RootTagContext` or a native host integration. Creation reads the native cache once; `get()` returns the latest observed snapshot and subscriptions keep it current. The last native subscriber releases observation and the cache. See [observer usage](./observers.md). +Creates an observer for an existing React root. Obtain its tag from React Native's `RootTagContext` or a native host integration. Creation reads the native cache once as a seed; `get()` returns the latest observed snapshot, and subscribing triggers a native replay of the current snapshot, with later updates arriving as native change events. The last native subscriber releases observation and the cache. See [observer usage](./observers.md). ## AnimatedHingesProvider diff --git a/website/docs/observers.md b/website/docs/observers.md index ace0b46..15ae63b 100644 --- a/website/docs/observers.md +++ b/website/docs/observers.md @@ -19,6 +19,6 @@ unsubscribe(); Creation seeds the snapshot from the native cache; `get()` synchronously reads the JS-owned snapshot. Returned arrays and entries are immutable, and equivalent snapshots retain their identity. Before a native reading is available it returns `[]`. -`subscribe()` starts observation for the selected root. Callbacks receive no arguments; read `get()` for the current value. Multiple subscriptions on this observer share one native subscription; separate observers and animated hooks are reference-counted natively. Unsubscribing the final consumer releases native observation and clears its native cache. The observer retains its last delivered snapshot until a later subscription refreshes it. +`subscribe()` starts observation for the selected root, which triggers a native replay of the current snapshot as an event; later updates also arrive with the native change event. Callbacks receive no arguments; read `get()` for the current value. Multiple subscriptions on this observer share one native subscription; separate observers and animated hooks are reference-counted natively. Unsubscribing the final consumer releases native observation and clears its native cache. The observer retains its last delivered snapshot until a later subscription. Creating an observer or calling `get()` alone does not start native observation. Subscribe when readings must remain current. No process-wide root is selected implicitly. From 59222e8830be0886c1ae504a40e49c677bc2935a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 09:33:20 -0400 Subject: [PATCH 2/2] test: replay the mocked snapshot asynchronously like native --- src/__tests__/index.test.tsx | 12 +++++++++--- website/docs/observers.md | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 37c66cf..0de8ee4 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -18,9 +18,12 @@ jest.mock('../HingesModule', () => ({ __esModule: true, default: { getSnapshot: jest.fn((root: number) => ({ hinges: mockSnapshots.get(root) ?? [] })), + // Native replays the current snapshot asynchronously after startObserving. startObserving: jest.fn((rootTag: number) => { - const hinges = mockSnapshots.get(rootTag) ?? []; - for (const listener of mockListeners) listener({ rootTag, hinges }); + queueMicrotask(() => { + const hinges = mockSnapshots.get(rootTag) ?? []; + for (const listener of mockListeners) listener({ rootTag, hinges }); + }); }), stopObserving: jest.fn(), onHingesChange: jest.fn((listener: (event: HingesChangeEvent) => void) => { @@ -152,12 +155,15 @@ it('throws a clear error when useHinges renders without a RootTagContext provide } }); -it('delivers state that changed between observer creation and subscription via the startObserving replay', () => { +it('delivers state that changed between observer creation and subscription via the startObserving replay', async () => { const observer = createHingeObserver(1); expect(observer.get()).toEqual([]); mockSnapshots.set(1, native); const listener = jest.fn(); const off = observer.subscribe(listener); + expect(observer.get()).toEqual([]); + expect(listener).not.toHaveBeenCalled(); + await Promise.resolve(); expect(NativeHinges.getSnapshot).toHaveBeenCalledTimes(1); expect(observer.get()).toEqual([{ status: 'partiallyOpen', angle: Math.PI / 2 }]); expect(listener).toHaveBeenCalledTimes(1); diff --git a/website/docs/observers.md b/website/docs/observers.md index 15ae63b..47ef9b4 100644 --- a/website/docs/observers.md +++ b/website/docs/observers.md @@ -19,6 +19,6 @@ unsubscribe(); Creation seeds the snapshot from the native cache; `get()` synchronously reads the JS-owned snapshot. Returned arrays and entries are immutable, and equivalent snapshots retain their identity. Before a native reading is available it returns `[]`. -`subscribe()` starts observation for the selected root, which triggers a native replay of the current snapshot as an event; later updates also arrive with the native change event. Callbacks receive no arguments; read `get()` for the current value. Multiple subscriptions on this observer share one native subscription; separate observers and animated hooks are reference-counted natively. Unsubscribing the final consumer releases native observation and clears its native cache. The observer retains its last delivered snapshot until a later subscription. +`subscribe()` starts observation for the selected root, which triggers a native replay of the current snapshot as an event; later updates also arrive with the native change event. Callbacks receive no arguments; read `get()` for the current value. Multiple subscriptions on this observer share one native subscription; separate observers are reference-counted natively. Unsubscribing the final consumer releases native observation and clears its native cache. The observer keeps its last delivered snapshot after unsubscribing. Creating an observer or calling `get()` alone does not start native observation. Subscribe when readings must remain current. No process-wide root is selected implicitly.