From f7be84b614f1704547600f66fd56634b4d9ab253 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 4 Oct 2026 11:40:27 +0200 Subject: [PATCH 1/2] feat: make highlight state controllable for embedding hosts Allow hosts to drive NMRium highlight state through optional `highlight` / `onHighlightChange` props so iframe embeddings no longer need to reach into the private HighlightProvider module. --- .../__tests__/highlightReducer.test.ts | 95 +++++++++++++++ src/component/highlight/index.tsx | 110 +++++++++++++++--- src/component/main/InnerNMRium.tsx | 7 +- src/component/main/NMRium.tsx | 13 +++ src/component/main/index.ts | 5 + src/demo/test/TestHighlight.tsx | 59 ++++++++-- 6 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 src/component/highlight/__tests__/highlightReducer.test.ts diff --git a/src/component/highlight/__tests__/highlightReducer.test.ts b/src/component/highlight/__tests__/highlightReducer.test.ts new file mode 100644 index 0000000000..7b31727281 --- /dev/null +++ b/src/component/highlight/__tests__/highlightReducer.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + emptyHighlightState, + highlightReducer, + toInternalHighlightState, + toPublicHighlightState, +} from '../index.js'; + +describe('highlightReducer', () => { + it('starts from the empty public state when uncontrolled', () => { + const internal = toInternalHighlightState(emptyHighlightState); + expect(toPublicHighlightState(internal)).toStrictEqual(emptyHighlightState); + expect(internal.highlights).toStrictEqual({}); + }); + + it('SHOW then HIDE updates highlighted ids', () => { + let state = toInternalHighlightState(emptyHighlightState); + + state = highlightReducer(state, { + type: 'SHOW', + payload: { + convertedHighlights: ['peak-1'], + sourceData: { type: 'PEAK', extra: { id: 'peak-1' } }, + }, + }); + expect(toPublicHighlightState(state)).toStrictEqual({ + highlighted: ['peak-1'], + highlightedPermanently: [], + sourceData: { type: 'PEAK', extra: { id: 'peak-1' } }, + }); + expect(state.highlights).toStrictEqual({ 'peak-1': 1 }); + + state = highlightReducer(state, { + type: 'HIDE', + payload: { convertedHighlights: ['peak-1'] }, + }); + expect(toPublicHighlightState(state)).toStrictEqual({ + highlighted: [], + highlightedPermanently: [], + sourceData: null, + }); + }); + + it('composes consecutive dispatches against the latest reduced value', () => { + let state = toInternalHighlightState({ + highlighted: ['peak-1'], + highlightedPermanently: ['peak-1'], + sourceData: { type: 'PEAK', extra: { id: 'peak-1' } }, + }); + + // Mirrors useHighlight unmount cleanup: HIDE then UNSET_PERMANENT. + state = highlightReducer(state, { + type: 'HIDE', + payload: { convertedHighlights: ['peak-1'] }, + }); + state = highlightReducer(state, { type: 'UNSET_PERMANENT' }); + + expect(toPublicHighlightState(state)).toStrictEqual({ + highlighted: [], + highlightedPermanently: [], + sourceData: null, + }); + }); + + it('SET_PERMANENT replaces the previous permanent group', () => { + let state = toInternalHighlightState({ + highlighted: ['a', 'b'], + highlightedPermanently: ['a'], + sourceData: null, + }); + + state = highlightReducer(state, { + type: 'SET_PERMANENT', + payload: { convertedHighlights: ['b'] }, + }); + + expect(state.highlightedPermanently).toStrictEqual(['b']); + expect(state.highlighted).toStrictEqual(['a', 'b']); + }); + + it('toInternalHighlightState derives the private highlights record', () => { + const internal = toInternalHighlightState({ + highlighted: ['x', 'y'], + highlightedPermanently: ['y'], + sourceData: { type: 'UNKNOWN' }, + }); + expect(internal.highlights).toStrictEqual({ x: 1, y: 1 }); + expect(toPublicHighlightState(internal)).toStrictEqual({ + highlighted: ['x', 'y'], + highlightedPermanently: ['y'], + sourceData: { type: 'UNKNOWN' }, + }); + }); +}); diff --git a/src/component/highlight/index.tsx b/src/component/highlight/index.tsx index fae22ce152..03ed65334a 100644 --- a/src/component/highlight/index.tsx +++ b/src/component/highlight/index.tsx @@ -6,7 +6,8 @@ import { useCallback, useEffect, useMemo, - useReducer, + useRef, + useState, } from 'react'; import type { ExclusionZone } from '../../data/types/data1d/ExclusionZone.js'; @@ -60,6 +61,17 @@ export function isHighlightEventSource( return types.includes(source.type as T); } +/** + * Public highlight state that hosts can read and write through + * ``. + * The internal `highlights` reference-count record stays private. + */ +export interface NMRiumHighlightState { + highlighted: string[]; + highlightedPermanently: string[]; + sourceData: HighlightEventSource | null; +} + const highLightStyle: CSSProperties = { backgroundColor: '#ff6f0091', }; @@ -87,20 +99,53 @@ interface HighlightContextValue { remove: () => void; } -const emptyState = { - highlight: { - highlights: {}, - highlighted: [], - highlightedPermanently: [], - sourceData: null, - }, +export const emptyHighlightState: NMRiumHighlightState = { + highlighted: [], + highlightedPermanently: [], + sourceData: null, +}; + +const emptyInternalState: HighlightState = { + highlights: {}, + highlighted: [], + highlightedPermanently: [], + sourceData: null, +}; + +const emptyContextValue: HighlightContextValue = { + highlight: emptyInternalState, dispatch: () => null, remove: () => null, }; -const HighlightContext = createContext(emptyState); +const HighlightContext = createContext(emptyContextValue); -function highlightReducer( +export function toPublicHighlightState( + state: HighlightState, +): NMRiumHighlightState { + return { + highlighted: state.highlighted, + highlightedPermanently: state.highlightedPermanently, + sourceData: state.sourceData, + }; +} + +export function toInternalHighlightState( + value: NMRiumHighlightState, +): HighlightState { + const highlights: Record = {}; + for (const id of value.highlighted) { + highlights[id] = 1; + } + return { + highlights, + highlighted: value.highlighted, + highlightedPermanently: value.highlightedPermanently, + sourceData: value.sourceData, + }; +} + +export function highlightReducer( state: HighlightState, action: { type: HighlightActions; @@ -165,10 +210,43 @@ function highlightReducer( } } -export function HighlightProvider(props: PropsWithChildren) { - const [highlight, dispatch] = useReducer( - highlightReducer, - emptyState.highlight, +export interface HighlightProviderProps extends PropsWithChildren { + /** + * When provided, highlight state is controlled by the host. + * Omit it to keep the previous uncontrolled behaviour. + */ + value?: NMRiumHighlightState; + /** + * Called for every highlight change, including internal hover/click and + * host-driven updates applied through the reducer. + */ + onChange?: (state: NMRiumHighlightState) => void; +} + +export function HighlightProvider(props: HighlightProviderProps) { + const { value, onChange, children } = props; + const isControlled = value !== undefined; + const [internalState, setInternalState] = useState(emptyInternalState); + + const highlight = isControlled + ? toInternalHighlightState(value) + : internalState; + + // Keep a mutable snapshot so consecutive dispatches in the same tick + // (for example useHighlight unmount: HIDE then UNSET_PERMANENT) compose. + const latestRef = useRef(highlight); + latestRef.current = highlight; + + const dispatch = useCallback( + (action: { type: HighlightActions; payload?: HighlightPayload }) => { + const next = highlightReducer(latestRef.current, action); + latestRef.current = next; + if (!isControlled) { + setInternalState(next); + } + onChange?.(toPublicHighlightState(next)); + }, + [isControlled, onChange], ); const contextValue = useMemo(() => { @@ -179,10 +257,10 @@ export function HighlightProvider(props: PropsWithChildren) { }); } return { highlight, dispatch, remove }; - }, [highlight]); + }, [dispatch, highlight]); return ( - {props.children} + {children} ); } diff --git a/src/component/main/InnerNMRium.tsx b/src/component/main/InnerNMRium.tsx index 8f5ff4340c..d38dc981ed 100644 --- a/src/component/main/InnerNMRium.tsx +++ b/src/component/main/InnerNMRium.tsx @@ -46,6 +46,8 @@ export function InnerNMRium(props: InnerNMRiumProps) { emptyText, apiRef, core, + highlight, + onHighlightChange, } = props; const rootRef = useRef(null); @@ -122,7 +124,10 @@ export function InnerNMRium(props: InnerNMRiumProps) { - + ReactElement; core?: NMRiumCore; + /** + * When provided, highlight state is controlled by the host. + * Omit it to keep the previous uncontrolled behaviour. + */ + highlight?: NMRiumHighlightState; + /** + * Called for every highlight change, including internal hover/click. + */ + onHighlightChange?: (state: NMRiumHighlightState) => void; ref?: Ref; } diff --git a/src/component/main/index.ts b/src/component/main/index.ts index e9c9d51c96..33c93ac0f2 100644 --- a/src/component/main/index.ts +++ b/src/component/main/index.ts @@ -2,3 +2,8 @@ export * from './types.js'; export * from './NMRium.js'; export type { NMRiumRefAPI } from './NMRiumRefAPI.js'; +export type { + HighlightEventSource, + NMRiumHighlightState, +} from '../highlight/index.js'; +export { emptyHighlightState } from '../highlight/index.js'; diff --git a/src/demo/test/TestHighlight.tsx b/src/demo/test/TestHighlight.tsx index daa5c7144c..81085f7ee4 100644 --- a/src/demo/test/TestHighlight.tsx +++ b/src/demo/test/TestHighlight.tsx @@ -1,7 +1,10 @@ import styled from '@emotion/styled'; +import { useState } from 'react'; +import type { NMRiumHighlightState } from '../../component/highlight/index.js'; import { HighlightProvider, + emptyHighlightState, useHighlight, } from '../../component/highlight/index.js'; @@ -44,18 +47,54 @@ const TableCell = styled.td` padding: 5px; `; +const Controls = styled.div` + display: flex; + gap: 8px; + padding: 20px; +`; + export default function TestHighlight() { + const [highlight, setHighlight] = + useState(emptyHighlightState); + return ( - - - - - - - - - - + <> + + + + + permanent: {highlight.highlightedPermanently.join(', ') || 'none'} + + + + + + + + + + + + + ); } From 856402f8104fcc47809437d8b77f6f9fc8926b06 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 4 Oct 2026 11:56:50 +0200 Subject: [PATCH 2/2] fix: keep highlight dispatch stable and memoize controlled state A dispatch that changed identity with onChange re-ran every useHighlight unmount cleanup, clearing highlights and looping when hosts passed an inline callback. Read the latest onChange from a ref synced in a layout effect instead of writing refs during render, and memoize the controlled value so consumers do not re-render on every provider render. --- src/component/highlight/index.tsx | 43 ++++++++++++++++++++----------- src/component/main/NMRium.tsx | 7 +++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/component/highlight/index.tsx b/src/component/highlight/index.tsx index 03ed65334a..293028e693 100644 --- a/src/component/highlight/index.tsx +++ b/src/component/highlight/index.tsx @@ -5,6 +5,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -118,7 +119,8 @@ const emptyContextValue: HighlightContextValue = { remove: () => null, }; -const HighlightContext = createContext(emptyContextValue); +const HighlightContext = + createContext(emptyContextValue); export function toPublicHighlightState( state: HighlightState, @@ -212,8 +214,10 @@ export function highlightReducer( export interface HighlightProviderProps extends PropsWithChildren { /** - * When provided, highlight state is controlled by the host. - * Omit it to keep the previous uncontrolled behaviour. + * When provided, highlight state is controlled by the host, which must then + * also handle `onChange`, otherwise hover/click highlights inside NMRium are + * not rendered. Omit it to keep the previous uncontrolled behaviour. Do not + * switch between controlled and uncontrolled during the provider's lifetime. */ value?: NMRiumHighlightState; /** @@ -228,25 +232,36 @@ export function HighlightProvider(props: HighlightProviderProps) { const isControlled = value !== undefined; const [internalState, setInternalState] = useState(emptyInternalState); - const highlight = isControlled - ? toInternalHighlightState(value) - : internalState; + const controlledState = useMemo( + () => (value ? toInternalHighlightState(value) : null), + [value], + ); + const highlight = controlledState ?? internalState; - // Keep a mutable snapshot so consecutive dispatches in the same tick - // (for example useHighlight unmount: HIDE then UNSET_PERMANENT) compose. + // `dispatch` must keep a stable identity: every useHighlight consumer runs an + // unmount effect keyed on it, so a new identity per render would clear the + // highlight (and loop when the host passes an inline onChange). + // `latestRef` also lets consecutive dispatches in one tick compose, for + // example useHighlight's unmount cleanup (HIDE then UNSET_PERMANENT). const latestRef = useRef(highlight); - latestRef.current = highlight; + const onChangeRef = useRef(onChange); + const isControlledRef = useRef(isControlled); + useLayoutEffect(() => { + latestRef.current = highlight; + onChangeRef.current = onChange; + isControlledRef.current = isControlled; + }); const dispatch = useCallback( (action: { type: HighlightActions; payload?: HighlightPayload }) => { const next = highlightReducer(latestRef.current, action); latestRef.current = next; - if (!isControlled) { + if (!isControlledRef.current) { setInternalState(next); } - onChange?.(toPublicHighlightState(next)); + onChangeRef.current?.(toPublicHighlightState(next)); }, - [isControlled, onChange], + [], ); const contextValue = useMemo(() => { @@ -259,9 +274,7 @@ export function HighlightProvider(props: HighlightProviderProps) { return { highlight, dispatch, remove }; }, [dispatch, highlight]); - return ( - {children} - ); + return {children}; } export function useHighlightData() { diff --git a/src/component/main/NMRium.tsx b/src/component/main/NMRium.tsx index 25c2ad072a..6574c89d44 100644 --- a/src/component/main/NMRium.tsx +++ b/src/component/main/NMRium.tsx @@ -81,12 +81,15 @@ export interface NMRiumProps { getSpinner?: () => ReactElement; core?: NMRiumCore; /** - * When provided, highlight state is controlled by the host. - * Omit it to keep the previous uncontrolled behaviour. + * When provided, highlight state is controlled by the host, which must then + * also handle `onHighlightChange`, otherwise hover/click highlights inside + * NMRium are not rendered. Omit it to keep the previous uncontrolled + * behaviour. */ highlight?: NMRiumHighlightState; /** * Called for every highlight change, including internal hover/click. + * Works in both controlled and uncontrolled mode. */ onHighlightChange?: (state: NMRiumHighlightState) => void;