From 8f3375ce0fa7e93123026920cd80a4ae2c147fad Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 5 Feb 2026 11:53:34 -0300 Subject: [PATCH] feat: add createExternalStore utility and ClockProvider Add a generic external store factory (createExternalStore) using useSyncExternalStore that allows components to subscribe to frequently-updating data sources without causing unnecessary re-renders. Includes a pre-built clock store (ClockProvider, useClock, useClockSelector) that wraps the existing Clock component, enabling projects to move clock state out of Redux and eliminate per-second re-renders of all connected components. --- package.json | 5 + readme.md | 4 + src/components/clock-context.js | 64 ++++ src/components/index.js | 2 + src/utils/__tests__/external-store.test.js | 346 +++++++++++++++++++++ src/utils/external-store.js | 201 ++++++++++++ webpack.common.js | 2 + 7 files changed, 624 insertions(+) create mode 100644 src/components/clock-context.js create mode 100644 src/utils/__tests__/external-store.test.js create mode 100644 src/utils/external-store.js diff --git a/package.json b/package.json index b29aa43..ab6f19c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "test": "jest" }, "license": "APACHE 2.0", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, "devDependencies": { "@babel/core": "^7.17.8", "@babel/plugin-proposal-class-properties": "^7.16.7", @@ -37,6 +40,8 @@ "dropzone": "5.7.2", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.6", + "@testing-library/react": "^12.1.5", + "@testing-library/jest-dom": "^5.16.5", "extend": "^3.0.1", "file-loader": "^6.2.0", "final-form": "^4.20.7", diff --git a/readme.md b/readme.md index e03182e..2c50a15 100644 --- a/readme.md +++ b/readme.md @@ -18,5 +18,9 @@ import 'sweetalert2/dist/sweetalert2.css'; 1 - yarn build && yarn publish +## React compatibility + +`createExternalStore` (and the clock context built on it) uses the `use-sync-external-store` shim for React 16/17 compatibility. When React is upgraded to 18+, replace the shim with the native import from `react` and remove the `use-sync-external-store` dependency from package.json. + ## Troubleshoot For Python 3.13 and above, yarn install will not work until you install this lib: sudo apt install python3-setuptools diff --git a/src/components/clock-context.js b/src/components/clock-context.js new file mode 100644 index 0000000..0ef5ec2 --- /dev/null +++ b/src/components/clock-context.js @@ -0,0 +1,64 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Pre-built clock store using createExternalStore. + * + * Wires the Clock component (server-synced, ticks every second) into a + * createExternalStore instance. Components choose their update strategy: + * + * - useClock() re-renders every second (for countdowns, live displays) + * - useClockSelector(compute) only re-renders when the computed result changes + * - Components that use neither are never affected by clock ticks + * + * Usage: + * import { ClockProvider, useClock, useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; + * + * + * + * + * + * const nowUtc = useClock(); + * + * const phase = useClockSelector((nowUtc) => { + * if (nowUtc < event.start) return 'before'; + * if (nowUtc <= event.end) return 'during'; + * return 'after'; + * }); + * + * For custom (non-clock) stores, see createExternalStore in utils/external-store.js. + **/ + +import React from 'react'; +import { createExternalStore } from '../utils/external-store'; +import Clock from './clock'; + +const { Provider, useValue: useClock, useSelector: useClockSelector } = createExternalStore('Clock'); + +/** + * ClockProvider - Wraps your app with server-synced clock context. + * + * @param {string} timezone - Timezone for the clock (e.g., "America/New_York") + * @param {number} now - Optional initial timestamp (for testing or manual override) + * @param {React.ReactNode} children - Child components + */ +export const ClockProvider = ({ timezone, now, children }) => ( + + {(emit) => ( + <> + + {children} + + )} + +); + +export { useClock, useClockSelector }; diff --git a/src/components/index.js b/src/components/index.js index c610b6a..5d7fb5f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -43,6 +43,8 @@ export {default as LanguageInput} from './inputs/language-input' export {default as FreeMultiTextInput} from "./inputs/free-multi-text-input"; export {default as Exclusive} from "./exclusive-wrapper"; export {default as Clock} from "./clock"; +export {ClockProvider, useClock, useClockSelector} from "./clock-context"; +export {createExternalStore} from "../utils/external-store"; export {default as CircleButton} from "./circle-button"; export {default as VideoStream} from "./video-stream"; export {default as AttendanceTracker} from "./attendance-tracker"; diff --git a/src/utils/__tests__/external-store.test.js b/src/utils/__tests__/external-store.test.js new file mode 100644 index 0000000..4644113 --- /dev/null +++ b/src/utils/__tests__/external-store.test.js @@ -0,0 +1,346 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { createExternalStore } from '../external-store'; + +describe('createExternalStore', () => { + let store; + let emit; + + // Test component that captures emit from the render function + const TestProvider = ({ children }) => ( + + {(emitFn) => { + emit = emitFn; + return children; + }} + + ); + + // Test component that uses useValue + const ValueDisplay = ({ onRender }) => { + const value = store.useValue(); + onRender?.(); + return
{value}
; + }; + + // Test component that uses useSelector + const SelectorDisplay = ({ compute, isEqual, onRender }) => { + const value = store.useSelector(compute, isEqual); + onRender?.(); + return
{JSON.stringify(value)}
; + }; + + beforeEach(() => { + store = createExternalStore('Test'); + emit = null; + }); + + describe('Provider', () => { + it('renders children', () => { + render( + +
hello
+
+ ); + expect(screen.getByTestId('child')).toHaveTextContent('hello'); + }); + + it('passes emit function to render prop', () => { + render(
); + expect(typeof emit).toBe('function'); + }); + }); + + describe('useValue', () => { + it('throws when used outside Provider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('Test hooks must be used within their Provider'); + + consoleError.mockRestore(); + }); + + it('returns null before first emit', () => { + render( + + + + ); + expect(screen.getByTestId('value')).toHaveTextContent(''); + }); + + it('returns current value after emit', () => { + render( + + + + ); + + act(() => emit(42)); + expect(screen.getByTestId('value')).toHaveTextContent('42'); + }); + + it('re-renders on every emit', () => { + const renderCount = jest.fn(); + + render( + + + + ); + + const initial = renderCount.mock.calls.length; + + act(() => emit(1)); + act(() => emit(2)); + act(() => emit(3)); + + expect(renderCount.mock.calls.length).toBe(initial + 3); + }); + + it('updates displayed value on each emit', () => { + render( + + + + ); + + act(() => emit('first')); + expect(screen.getByTestId('value')).toHaveTextContent('first'); + + act(() => emit('second')); + expect(screen.getByTestId('value')).toHaveTextContent('second'); + }); + }); + + describe('useSelector', () => { + it('throws when used outside Provider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render( v} />); + }).toThrow('Test hooks must be used within their Provider'); + + consoleError.mockRestore(); + }); + + it('computes derived value', () => { + const compute = (v) => v ? v * 2 : null; + + render( + + + + ); + + act(() => emit(50)); + expect(screen.getByTestId('selector')).toHaveTextContent('100'); + }); + + it('only re-renders when computed value changes', () => { + const renderCount = jest.fn(); + + // Returns "low" or "high" based on threshold + const compute = (v) => { + if (!v) return null; + return v < 100 ? 'low' : 'high'; + }; + + render( + + + + ); + + const initial = renderCount.mock.calls.length; + + // All "low" - only first causes re-render + act(() => emit(10)); + act(() => emit(20)); + act(() => emit(30)); + expect(renderCount.mock.calls.length).toBe(initial + 1); + expect(screen.getByTestId('selector')).toHaveTextContent('low'); + + // Crosses to "high" - re-render + act(() => emit(150)); + expect(renderCount.mock.calls.length).toBe(initial + 2); + expect(screen.getByTestId('selector')).toHaveTextContent('high'); + + // Still "high" - no re-render + act(() => emit(200)); + expect(renderCount.mock.calls.length).toBe(initial + 2); + }); + + it('uses custom equality function', () => { + const renderCount = jest.fn(); + + const compute = (v) => { + if (!v) return []; + if (v < 100) return [{ id: 1 }, { id: 2 }]; + return [{ id: 1 }]; + }; + + const isEqual = (a, b) => { + if (a.length !== b.length) return false; + return a.every((item, i) => item.id === b[i]?.id); + }; + + render( + + + + ); + + const initial = renderCount.mock.calls.length; + + // Same result [1,2] - no re-render after first + act(() => emit(10)); + act(() => emit(20)); + act(() => emit(30)); + expect(renderCount.mock.calls.length).toBe(initial + 1); + + // Result changes to [1] - re-render + act(() => emit(150)); + expect(renderCount.mock.calls.length).toBe(initial + 2); + }); + + it('recomputes when compute function changes', () => { + const { rerender } = render( + + v ? 'A' : null} /> + + ); + + act(() => emit(1)); + expect(screen.getByTestId('selector')).toHaveTextContent('A'); + + rerender( + + v ? 'B' : null} /> + + ); + + expect(screen.getByTestId('selector')).toHaveTextContent('B'); + }); + }); + + describe('multiple subscribers', () => { + it('supports multiple useValue subscribers', () => { + const render1 = jest.fn(); + const render2 = jest.fn(); + + render( + + + + + ); + + act(() => emit(1)); + + expect(render1).toHaveBeenCalled(); + expect(render2).toHaveBeenCalled(); + }); + + it('mixed useValue and useSelector subscribers', () => { + const valueRenders = jest.fn(); + const selectorRenders = jest.fn(); + + // Selector only changes every 100 + const compute = (v) => v ? Math.floor(v / 100) : null; + + render( + + + + + ); + + const initialValue = valueRenders.mock.calls.length; + const initialSelector = selectorRenders.mock.calls.length; + + // 3 emits within same "bucket" + act(() => emit(10)); + act(() => emit(20)); + act(() => emit(30)); + + // useValue re-renders every time + expect(valueRenders.mock.calls.length).toBe(initialValue + 3); + + // useSelector only re-renders once (value stays 0) + expect(selectorRenders.mock.calls.length).toBe(initialSelector + 1); + }); + }); + + describe('multiple independent stores', () => { + it('stores are isolated from each other', () => { + const store1 = createExternalStore('Store1'); + const store2 = createExternalStore('Store2'); + + let emit1, emit2; + + const Display1 = () => { + const v = store1.useValue(); + return
{v}
; + }; + + const Display2 = () => { + const v = store2.useValue(); + return
{v}
; + }; + + render( + + {(e) => { + emit1 = e; + return ( + + {(e2) => { + emit2 = e2; + return ( + <> + + + + ); + }} + + ); + }} + + ); + + act(() => emit1('hello')); + expect(screen.getByTestId('s1')).toHaveTextContent('hello'); + expect(screen.getByTestId('s2')).toHaveTextContent(''); + + act(() => emit2('world')); + expect(screen.getByTestId('s1')).toHaveTextContent('hello'); + expect(screen.getByTestId('s2')).toHaveTextContent('world'); + }); + }); + + describe('error messages', () => { + it('includes store name in error message', () => { + const namedStore = createExternalStore('MyCustomStore'); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const Component = () => { + namedStore.useValue(); + return null; + }; + + expect(() => { + render(); + }).toThrow('MyCustomStore hooks must be used within their Provider'); + + consoleError.mockRestore(); + }); + }); +}); diff --git a/src/utils/external-store.js b/src/utils/external-store.js new file mode 100644 index 0000000..60abe02 --- /dev/null +++ b/src/utils/external-store.js @@ -0,0 +1,201 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * createExternalStore - Factory for creating React-optimized external stores. + * + * Problem: + * When a frequently-updating data source (clock, WebSocket, polling, etc.) + * pushes its value into shared state (global store, lifted useState, or a + * context value), every consuming component re-renders on every update, + * even when they only care about a derived condition that rarely changes. + * + * Solution: + * createExternalStore() returns a Provider and hooks that store the value + * in a ref (no re-renders) and use useSyncExternalStore so components + * can opt in to updates selectively: + * + * - useValue() → re-renders on every update + * - useSelector(compute, isEqual) → re-renders only when the computed result changes + * + * Components that don't call either hook are never affected by updates. + * + * How it works: + * 1. The Provider stores the value in a ref (writing to a ref never triggers + * a React re-render) and keeps a Set of listener callbacks. + * 2. When emit(value) is called, the ref is updated and all listeners are + * notified. These listeners come from useSyncExternalStore. + * 3. useSyncExternalStore (React 18, shimmed for 16/17) calls getSnapshot() + * to read the ref, compares with the previous value, and only re-renders + * the component if the value changed. + * 4. useMemo adds a layer on top: it runs a compute function on the raw value + * and only re-renders if the computed result changed (checked via isEqual). + * + * API: + * createExternalStore(name) returns: + * + * - Provider Wraps your component tree. Pass children as a render function + * to receive the emit callback: (emit) => JSX. Call emit(value) + * each time your data source has a new value. + * + * - useValue() Returns the latest emitted value. The component re-renders + * on every emit. + * + * - useSelector(compute, isEqual?) + * Returns a derived value. compute(rawValue) runs on every emit, + * but the component only re-renders when isEqual returns false + * (default: ===). Useful when you need to derive something that + * changes less frequently than the raw value. + * + * The name parameter is used in error messages. For example, + * createExternalStore('Clock') throws "Clock hooks must be used within + * their Provider" when a hook is called outside the Provider. + * + * For clock-specific usage: + * A pre-built clock store is available at: + * import { ClockProvider, useClock, useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; + * This wires createExternalStore to the Clock component so projects don't + * have to repeat that boilerplate. + * + * Custom store example: + * import { createExternalStore } from 'openstack-uicore-foundation/lib/utils/external-store'; + * + * const { Provider, useValue, useSelector } = createExternalStore('WebSocket'); + * + * const WebSocketProvider = ({ url, children }) => ( + * + * {(emit) => ( + * <> + * + * {children} + * + * )} + * + * ); + * + * // Re-renders on every message: + * const message = useValue(); + * + * // Re-renders only when the derived value changes: + * const isActive = useSelector((msg) => msg?.status === 'active'); + **/ + +import React, { createContext, useContext, useRef, useCallback, useMemo as reactUseMemo } from 'react'; +// Shim for React 16/17 compatibility, falls back to native in React 18+ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +const strictEqual = (a, b) => a === b; + +/** + * Creates an external store with a Provider and subscription hooks. + * + * @param {string} name - Store name, used in error messages (e.g., "Clock", "WebSocket") + * @returns {{ Provider, useValue, useSelector }} + */ +export function createExternalStore(name = 'ExternalStore') { + const Context = createContext(null); + + const useStoreContext = () => { + const context = useContext(Context); + if (context === null) { + throw new Error(`${name} hooks must be used within their Provider`); + } + return context; + }; + + /** + * Provider - Wraps your component tree and provides the store. + * + * Pass children as a render function to receive the `emit` callback: + * {(emit) => } + * + * Or pass children normally if you wire emit externally. + */ + const Provider = ({ children }) => { + const valueRef = useRef(null); + const listenersRef = useRef(new Set()); + + const subscribe = useCallback((callback) => { + listenersRef.current.add(callback); + return () => listenersRef.current.delete(callback); + }, []); + + const getSnapshot = useCallback(() => valueRef.current, []); + + const emit = useCallback((value) => { + valueRef.current = value; + listenersRef.current.forEach(listener => listener()); + }, []); + + const contextValue = reactUseMemo(() => ({ subscribe, getSnapshot }), [subscribe, getSnapshot]); + + return ( + + {typeof children === 'function' ? children(emit) : children} + + ); + }; + + /** + * useValue - Subscribe to every update. + * Component re-renders each time emit() is called. + * + * @returns {*} The current value, or null before first emit + */ + const useValue = () => { + const { subscribe, getSnapshot } = useStoreContext(); + return useSyncExternalStore(subscribe, getSnapshot); + }; + + /** + * useSelector - Subscribe with a selector function. + * Only re-renders when the selected/derived value changes. + * + * @param {Function} compute - (value) => derivedValue + * @param {Function} isEqual - Optional equality function (default: ===) + * @returns {*} The computed value + */ + const useSelector = (compute, isEqual = strictEqual) => { + const { subscribe, getSnapshot } = useStoreContext(); + + const lastResultRef = useRef(null); + const lastValueRef = useRef(null); + const lastComputeRef = useRef(compute); + + // Invalidate cache when compute function changes + if (lastComputeRef.current !== compute) { + lastComputeRef.current = compute; + lastValueRef.current = null; + } + + const getComputedValue = useCallback(() => { + const value = getSnapshot(); + + if (value === lastValueRef.current) { + return lastResultRef.current; + } + + const newResult = compute(value); + lastValueRef.current = value; + + if (lastResultRef.current !== null && isEqual(lastResultRef.current, newResult)) { + return lastResultRef.current; + } + + lastResultRef.current = newResult; + return newResult; + }, [getSnapshot, compute, isEqual]); + + return useSyncExternalStore(subscribe, getComputedValue); + }; + + return { Provider, useValue, useSelector }; +} diff --git a/webpack.common.js b/webpack.common.js index 49847dd..42b24a8 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -31,6 +31,7 @@ module.exports = { 'components/table-sortable': './src/components/table-sortable/SortableTable.js', 'components/attendance-tracker': './src/components/attendance-tracker.js', 'components/clock': './src/components/clock.js', + 'components/clock-context': './src/components/clock-context.js', 'components/exclusive-wrapper': './src/components/exclusive-wrapper.js', 'components/video-stream': './src/components/video-stream.js', 'components/inputs/action-dropdown': './src/components/inputs/action-dropdown/index.js', @@ -90,6 +91,7 @@ module.exports = { 'i18n': './src/i18n/i18n.js', 'utils/questions-set': './src/utils/questions-set.js', 'utils/money': './src/utils/money.js', + 'utils/external-store': './src/utils/external-store.js', }, output: { path: path.resolve(__dirname, 'lib'),