diff --git a/package.json b/package.json index 3a79084e..fd9dd7b6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "test": "jest" }, "license": "APACHE 2.0", + "dependencies": { + "use-sync-external-store": "^1.6.0" + }, "devDependencies": { "@babel/core": "^7.17.8", "@babel/plugin-proposal-class-properties": "^7.16.7", diff --git a/readme.md b/readme.md index e03182e0..2c50a158 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/__tests__/clock-context.test.js b/src/components/__tests__/clock-context.test.js new file mode 100644 index 00000000..764a5997 --- /dev/null +++ b/src/components/__tests__/clock-context.test.js @@ -0,0 +1,126 @@ +/** + * @jest-environment jsdom + * + * 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. + **/ + +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ClockProvider, useClock, useClockSelector } from '../clock-context'; + +// Capture the onTick prop the Clock component receives so tests can drive ticks +// without depending on Clock's real timer / server-sync behavior. +let lastOnTick = null; +jest.mock('../clock', () => ({ + __esModule: true, + default: ({ onTick }) => { + lastOnTick = onTick; + return null; + } +})); + +beforeEach(() => { lastOnTick = null; }); + +const ClockReader = () => { + const now = useClock(); + return {now === null ? 'null' : String(now)}; +}; + +const SelectorReader = ({ select, label = 'sel' }) => { + const value = useClockSelector(select); + return {value === null || value === undefined ? 'null' : String(value)}; +}; + +describe('ClockProvider', () => { + it('renders children', () => { + render(
child
); + expect(screen.getByText('child')).toBeInTheDocument(); + }); + + it('mounts the Clock component and exposes its emit hook', () => { + render(
); + expect(typeof lastOnTick).toBe('function'); + }); +}); + +describe('useClock', () => { + it('throws when used outside ClockProvider', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => render()).toThrow(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('returns null before the first tick', () => { + render(); + expect(screen.getByTestId('now')).toHaveTextContent('null'); + }); + + it('returns the latest emitted timestamp', () => { + render(); + act(() => lastOnTick(1700000000)); + expect(screen.getByTestId('now')).toHaveTextContent('1700000000'); + act(() => lastOnTick(1700000001)); + expect(screen.getByTestId('now')).toHaveTextContent('1700000001'); + }); +}); + +describe('useClockSelector', () => { + it('throws when used outside ClockProvider', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => render( t} />)).toThrow(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('computes derived value from the latest tick', () => { + render( + + (t ? `tick:${t}` : null)} /> + + ); + act(() => lastOnTick(1700000000)); + expect(screen.getByTestId('sel')).toHaveTextContent('tick:1700000000'); + act(() => lastOnTick(1700000001)); + expect(screen.getByTestId('sel')).toHaveTextContent('tick:1700000001'); + }); + + it('only re-renders when the selected value changes across ticks', () => { + let renders = 0; + const Counted = () => { + renders++; + const phase = useClockSelector((t) => (t > 1700000000 ? 'after' : 'before')); + return {phase ?? 'null'}; + }; + render(); + + // First tick: selector goes from null (initial cache) to 'before' — one re-render. + act(() => lastOnTick(1699999999)); + const afterFirst = renders; + expect(screen.getByTestId('phase')).toHaveTextContent('before'); + + // Second tick: selected value is still 'before' — must NOT re-render. + act(() => lastOnTick(1700000000)); + expect(screen.getByTestId('phase')).toHaveTextContent('before'); + expect(renders).toBe(afterFirst); + + // Third tick: selected value flips to 'after' — one re-render. + act(() => lastOnTick(1700000001)); + expect(screen.getByTestId('phase')).toHaveTextContent('after'); + expect(renders).toBe(afterFirst + 1); + }); +}); diff --git a/src/components/clock-context.js b/src/components/clock-context.js new file mode 100644 index 00000000..0ef5ec2f --- /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 cc3dff08..9448ed35 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -40,6 +40,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 00000000..4644113a --- /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 00000000..60abe022 --- /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 eae3e602..34c7d820 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', @@ -160,6 +161,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'), diff --git a/yarn.lock b/yarn.lock index 8ab62c6b..7b41d094 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10856,6 +10856,11 @@ use-memo-one@^1.1.1: resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== +use-sync-external-store@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + use@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz"