Skip to content

feat: add createExternalStore utility and ClockProvider#195

Merged
smarcet merged 4 commits into
mainfrom
feature/external-store-clock-context
May 27, 2026
Merged

feat: add createExternalStore utility and ClockProvider#195
smarcet merged 4 commits into
mainfrom
feature/external-store-clock-context

Conversation

@gcutrini
Copy link
Copy Markdown
Contributor

@gcutrini gcutrini commented Feb 5, 2026

ref: https://app.clickup.com/t/86b8dm4da
Add a generic external store factory (createExternalStore) using useSyncExternalStore that lets components subscribe to frequently-updating data sources without unnecessary re-renders.

Includes a pre-built clock store (ClockProvider, useClock, useClockSelector) wrapping the existing Clock component. This enables projects to move clock state out of Redux, eliminating per-second re-renders of all connected components.

API

  • createExternalStore(name){ Provider, useValue, useSelector }
  • useValue() — re-renders on every emit
  • useSelector(compute, isEqual) — re-renders only when the computed result changes

Clock usage

import { ClockProvider, useClock, useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';

<ClockProvider timezone={summit.time_zone_id}>
  <App />
</ClockProvider>

const nowUtc = useClock(); // re-renders every second
const allowedTickets = useClockSelector(compute, isEqual); // re-renders only on change

ref: https://app.clickup.com/t/86b8dm4da

Summary by CodeRabbit

  • New Features

    • Clock context with Provider plus hooks for server-synced time and selector-based subscriptions.
    • External-store factory and re-exports for easy creation and consumption of shared stores.
  • Documentation

    • React compatibility note explaining the shim for React 16/17 and guidance for React 18+ migration.
  • Tests

    • Comprehensive test suites for clock context and external-store behaviors.
  • Chores

    • Added runtime dependency to support React 16/17 compatibility.

Review Change Stack

@gcutrini gcutrini requested a review from smarcet February 5, 2026 15:01
@gcutrini gcutrini force-pushed the feature/external-store-clock-context branch from 8f3375c to cd28bef Compare May 18, 2026 18:38
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89f8c701-8b00-4433-9139-6dbf6da5c0ca

📥 Commits

Reviewing files that changed from the base of the PR and between 1e96e65 and 107c103.

📒 Files selected for processing (3)
  • src/components/clock-context.js
  • src/utils/__tests__/external-store.test.js
  • src/utils/external-store.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/clock-context.js
  • src/utils/external-store.js
  • src/utils/tests/external-store.test.js

📝 Walkthrough

Walkthrough

Adds a generic createExternalStore (Provider, useValue, useSelector), a ClockProvider that forwards Clock ticks into that store, tests for both modules, barrel exports and webpack entries, and a README/dependency note about the use-sync-external-store shim for React 16/17.

Changes

External Store and Clock Context Implementation

Layer / File(s) Summary
External Store Implementation
src/utils/external-store.js
Core factory createExternalStore(name) returning { Provider, useValue, useSelector }. Provider holds latest value, manages listeners, exposes emit, and integrates with useSyncExternalStore/selector shims.
External Store Test Suite
src/utils/__tests__/external-store.test.js
Jest suite covering Provider render-prop and emit, useValue behavior and rerender counts, useSelector memoization and custom equality, multi-subscriber behavior, independent store isolation, and hook-misuse messages.
Clock Context Implementation
src/components/clock-context.js
Pre-built clock context that calls createExternalStore('Clock'), exposes ClockProvider which mounts Clock and forwards emit as onTick, and re-exports useClock and useClockSelector.
Clock Context Test Suite
src/components/__tests__/clock-context.test.js
Tests that mock Clock to capture onTick, assert ClockProvider mounts and supplies onTick, validate useClock/useClockSelector behavior and selector render suppression.
Public API Exports and Build Configuration
src/components/index.js, webpack.common.js
Barrel exports ClockProvider, useClock, useClockSelector, and createExternalStore; webpack adds entries for components/clock-context and utils/external-store.
Dependencies and Documentation
package.json, readme.md
Adds use-sync-external-store@^1.6.0 to dependencies for React 16/17 compatibility; README documents shim usage and recommends switching to React 18+ native imports when upgrading.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A store that hums with listeners near,
Clock ticks travel, clean and clear.
Hooks subscribe and selectors hush,
Tests ensure the renders don’t rush.
A tiny shim helps older React hear.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: introducing a generic createExternalStore utility and a ClockProvider implementation, which are the primary objectives of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/external-store-clock-context

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/__tests__/clock-context.test.js`:
- Around line 58-62: Two tests directly reassign console.error around rendering
ClockReader which can leak mocks if an exception occurs; replace those direct
assignments with jest.spyOn(console, 'error').mockImplementation(...) and ensure
you call mockRestore() in a finally block so the spy is always restored,
updating both the block that wraps render(<ClockReader />) (previously lines
58-62) and the similar block around lines 80-84; reference the ClockReader
render and use mockRestore() to guarantee cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 322fb309-5b6c-4966-b31b-9e06d7494ddd

📥 Commits

Reviewing files that changed from the base of the PR and between e01d8de and cd28bef.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (8)
  • package.json
  • readme.md
  • src/components/__tests__/clock-context.test.js
  • src/components/clock-context.js
  • src/components/index.js
  • src/utils/__tests__/external-store.test.js
  • src/utils/external-store.js
  • webpack.common.js

Comment thread src/components/__tests__/clock-context.test.js Outdated
gcutrini added a commit that referenced this pull request May 26, 2026
Backport of #195 to v4.x. Adds a generic external store factory
(createExternalStore) using useSyncExternalStore that lets components
subscribe to frequently-updating data sources without unnecessary
re-renders, plus a pre-built clock store (ClockProvider, useClock,
useClockSelector) wrapping the existing Clock component.

Tests use @testing-library/react@11 for React 16 compatibility.
Comment thread src/utils/external-store.js Outdated
const newResult = compute(value);
lastValueRef.current = value;

if (lastResultRef.current !== null && isEqual(lastResultRef.current, newResult)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null is used both as the "not yet initialized" sentinel for lastResultRef (line 169) and as a possible legitimate return value from compute. Once compute returns null, subsequent null → null transitions skip the isEqual call entirely — lastResultRef.current !== null is false, so the custom equality function is never consulted for those transitions.

In practice, useSyncExternalStore's own Object.is deduplication prevents spurious re-renders in all current cases, but the contract of the hook is broken: a consumer who passes a custom isEqual will find it silently ignored whenever the computed result is null.

Prefer a Symbol sentinel to cleanly separate the two states:

const UNSET = Symbol();
const lastResultRef = useRef(UNSET);
// …
if (lastResultRef.current !== UNSET && isEqual(lastResultRef.current, newResult)) {

Comment thread src/utils/external-store.js Outdated

lastResultRef.current = newResult;
return newResult;
}, [getSnapshot, compute, isEqual]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEqual is listed as a useCallback dependency, so when consumers pass an inline equality function the closure is recreated on every parent render. React's useSyncExternalStore responds to a changed getSnapshot identity by calling it immediately to check for tearing — correctness is preserved (the fast-path value === lastValueRef.current returns the cached result), but the extra call happens on every parent render rather than only on emits.

compute already receives ref-based stabilization (lines 174–177). The same pattern applied to isEqual eliminates the overhead and is consistent with how the rest of the hook is written:

const lastIsEqualRef = useRef(isEqual);
lastIsEqualRef.current = isEqual; // always up-to-date, no cache invalidation needed

const getComputedValue = useCallback(() => {
    // …
    if (lastResultRef.current !== null && lastIsEqualRef.current(lastResultRef.current, newResult)) {
    // …
}, [getSnapshot, compute]); // isEqual removed from deps

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini please review comments
also please check merge conflicts

gcutrini added 3 commits May 27, 2026 07:59
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.
…seSyncExternalStoreWithSelector

Delegates selector + equality handling to the react-recommended primitive
from use-sync-external-store. Removes the manual ref bookkeeping, the
null-sentinel ambiguity for cached results, and the useCallback dependency
on isEqual that recreated the snapshot closure on every parent render.
@gcutrini gcutrini force-pushed the feature/external-store-clock-context branch from 37adf17 to 1e96e65 Compare May 27, 2026 11:08
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/external-store.js`:
- Around line 39-40: The module docblock incorrectly states selector updates are
handled via useMemo; update the comment to reference
useSyncExternalStoreWithSelector instead. Edit the top-level comment in
src/utils/external-store.js (the module docblock) to replace any mention of
useMemo as the mechanism for selector updates with a clear note saying
useSyncExternalStoreWithSelector is used to subscribe and derive selector values
(and mention isEqual behavior if present), so readers know the actual
implementation used by functions like useExternalStore and
useSyncExternalStoreWithSelector.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7045c6ce-e833-445e-a51e-74233b372750

📥 Commits

Reviewing files that changed from the base of the PR and between 37adf17 and 1e96e65.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (8)
  • package.json
  • readme.md
  • src/components/__tests__/clock-context.test.js
  • src/components/clock-context.js
  • src/components/index.js
  • src/utils/__tests__/external-store.test.js
  • src/utils/external-store.js
  • webpack.common.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • package.json
  • webpack.common.js
  • src/components/tests/clock-context.test.js
  • src/components/clock-context.js
  • src/components/index.js
  • src/utils/tests/external-store.test.js

Comment on lines +39 to +40
* 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).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix stale implementation note in the module docblock.

The comment says selector updates are handled via useMemo, but this implementation uses useSyncExternalStoreWithSelector. Keeping this accurate avoids confusion during maintenance.

Suggested doc fix
- *   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).
+ *   4. useSyncExternalStoreWithSelector runs compute on the raw value and only
+ *      re-renders when the computed result changes (checked via isEqual).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* 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).
* 4. useSyncExternalStoreWithSelector runs compute on the raw value and only
* re-renders when the computed result changes (checked via isEqual).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/external-store.js` around lines 39 - 40, The module docblock
incorrectly states selector updates are handled via useMemo; update the comment
to reference useSyncExternalStoreWithSelector instead. Edit the top-level
comment in src/utils/external-store.js (the module docblock) to replace any
mention of useMemo as the mechanism for selector updates with a clear note
saying useSyncExternalStoreWithSelector is used to subscribe and derive selector
values (and mention isEqual behavior if present), so readers know the actual
implementation used by functions like useExternalStore and
useSyncExternalStoreWithSelector.

@gcutrini gcutrini requested a review from smarcet May 27, 2026 11:17
Provider now accepts initialValue so getSnapshot returns a real value
before the first emit. ClockProvider forwards its now prop as
initialValue so consumers reading useClock or useClockSelector during
the initial render see a valid timestamp instead of null.
Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@smarcet smarcet merged commit 88aa370 into main May 27, 2026
5 checks passed
smarcet pushed a commit that referenced this pull request May 27, 2026
…#247)

* feat: add createExternalStore utility and ClockProvider

Backport of #195 to v4.x. Adds a generic external store factory
(createExternalStore) using useSyncExternalStore that lets components
subscribe to frequently-updating data sources without unnecessary
re-renders, plus a pre-built clock store (ClockProvider, useClock,
useClockSelector) wrapping the existing Clock component.

Tests use @testing-library/react@11 for React 16 compatibility.

* refactor(external-store): replace hand-rolled selector caching with useSyncExternalStoreWithSelector

Delegates selector + equality handling to the react-recommended primitive
from use-sync-external-store. Removes the manual ref bookkeeping, the
null-sentinel ambiguity for cached results, and the useCallback dependency
on isEqual that recreated the snapshot closure on every parent render.

* feat(external-store): allow Provider to seed initial value

Provider now accepts initialValue so getSnapshot returns a real value
before the first emit. ClockProvider forwards its now prop as
initialValue so consumers reading useClock or useClockSelector during
the initial render see a valid timestamp instead of null.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants