From 46a33dd7ad18417a89023b5b5c02471a334f6f23 Mon Sep 17 00:00:00 2001 From: LarytheLord Date: Thu, 26 Feb 2026 11:47:19 +0530 Subject: [PATCH 1/5] fix: correct DataTable tooltip row mapping with pagination Port the pagination tooltip row-index fix from plotly/dash-table#906 and add a regression unit test for paginated row handling in cell event tooltip state. Signed-off-by: LarytheLord --- .../src/dash-table/derived/table/tooltip.ts | 5 +- .../src/dash-table/handlers/cellEvents.ts | 11 ++-- .../cellEvents_tooltip_pagination_test.ts | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts diff --git a/components/dash-table/src/dash-table/derived/table/tooltip.ts b/components/dash-table/src/dash-table/derived/table/tooltip.ts index 54620dea99..f9d5929aad 100644 --- a/components/dash-table/src/dash-table/derived/table/tooltip.ts +++ b/components/dash-table/src/dash-table/derived/table/tooltip.ts @@ -42,7 +42,10 @@ function getSelectedTooltip( return ( !tt.if || (ifColumnId(tt.if, id) && - ifRowIndex(tt.if, row) && + ifRowIndex( + tt.if, + virtualized.indices[row - virtualized.offset.rows] + ) && ifFilter( tt.if, virtualized.data[row - virtualized.offset.rows] diff --git a/components/dash-table/src/dash-table/handlers/cellEvents.ts b/components/dash-table/src/dash-table/handlers/cellEvents.ts index 8ece59511e..6fc6d02dfa 100644 --- a/components/dash-table/src/dash-table/handlers/cellEvents.ts +++ b/components/dash-table/src/dash-table/handlers/cellEvents.ts @@ -160,13 +160,13 @@ export const handleEnter = ( idx: number, i: number ) => { - const {setState, virtualized, visibleColumns} = propsFn(); + const {setState, visibleColumns} = propsFn(); setState({ currentTooltip: { header: false, id: visibleColumns[i].id, - row: virtualized.indices[idx - virtualized.offset.rows] + row: idx } }); }; @@ -202,15 +202,14 @@ export const handleMove = ( idx: number, i: number ) => { - const {currentTooltip, setState, virtualized, visibleColumns} = propsFn(); + const {currentTooltip, setState, visibleColumns} = propsFn(); const c = visibleColumns[i]; - const realIdx = virtualized.indices[idx - virtualized.offset.rows]; if ( currentTooltip && currentTooltip.id === c.id && - currentTooltip.row === realIdx && + currentTooltip.row === idx && !currentTooltip.header ) { return; @@ -220,7 +219,7 @@ export const handleMove = ( currentTooltip: { header: false, id: c.id, - row: realIdx + row: idx } }); }; diff --git a/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts b/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts new file mode 100644 index 0000000000..2291914c55 --- /dev/null +++ b/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts @@ -0,0 +1,52 @@ +import {expect} from 'chai'; + +import {handleEnter, handleMove} from 'dash-table/handlers/cellEvents'; + +describe('cell events - tooltip row index with pagination', () => { + it('uses the provided row index when entering a cell', () => { + let state: any; + const propsFn = () => + ({ + setState: (next: any) => { + state = next; + }, + visibleColumns: [{id: 'Description'}], + virtualized: { + indices: [5, 6, 7, 8, 9], + offset: {rows: 0, columns: 0} + } + }) as any; + + handleEnter(propsFn, 6, 0); + + expect(state.currentTooltip.row).to.equal(6); + expect(state.currentTooltip.id).to.equal('Description'); + expect(state.currentTooltip.header).to.equal(false); + }); + + it('uses the provided row index when moving between cells', () => { + let state: any; + const propsFn = () => + ({ + currentTooltip: { + header: false, + id: 'Description', + row: 5 + }, + setState: (next: any) => { + state = next; + }, + visibleColumns: [{id: 'Description'}], + virtualized: { + indices: [5, 6, 7, 8, 9], + offset: {rows: 0, columns: 0} + } + }) as any; + + handleMove(propsFn, 6, 0); + + expect(state.currentTooltip.row).to.equal(6); + expect(state.currentTooltip.id).to.equal('Description'); + expect(state.currentTooltip.header).to.equal(false); + }); +}); From 62aace28b7805f3c425e8661d8dac8c65ce4a7fd Mon Sep 17 00:00:00 2001 From: LarytheLord Date: Thu, 26 Feb 2026 12:27:44 +0530 Subject: [PATCH 2/5] fix(dcc): guard input ref access after unmount --- .../src/components/Input.tsx | 83 ++++++++++++++----- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/components/dash-core-components/src/components/Input.tsx b/components/dash-core-components/src/components/Input.tsx index e099b2ee36..d2d7555860 100644 --- a/components/dash-core-components/src/components/Input.tsx +++ b/components/dash-core-components/src/components/Input.tsx @@ -77,7 +77,7 @@ function Input({ disabled, ...props }: InputProps) { - const input = useRef(document.createElement('input')); + const input = useRef(null); const [value, setValue] = useState(props.value); const [pendingEvent, setPendingEvent] = useState(); const inputId = useState(() => uniqid('input-'))[0]; @@ -89,8 +89,12 @@ function Input({ const setPropValue = useCallback( (base: InputProps['value'], value: InputProps['value']) => { const {setProps} = props; + const currentInput = input.current; + if (!currentInput) { + return; + } base = convert(base); - value = input.current.checkValidity() ? convert(value) : NaN; + value = currentInput.checkValidity() ? convert(value) : NaN; if (!isEquivalent(base, value)) { setProps({value}); @@ -99,8 +103,12 @@ function Input({ [props.setProps] ); - const onEvent = () => { - const {value: inputValue} = input.current; + const onEvent = useCallback(() => { + const currentInput = input.current; + if (!currentInput) { + return; + } + const {value: inputValue} = currentInput; const valueAsNumber = convert(inputValue); if (type === HTMLInputTypes.number) { setPropValue(props.value, valueAsNumber ?? value); @@ -112,19 +120,29 @@ function Input({ props.setProps({value: propValue}); } setPendingEvent(undefined); - }; + }, [props, setPropValue, type, value]); const onBlur = useCallback(() => { + const currentInput = input.current; + if (!currentInput) { + return; + } props.setProps({ n_blur: (n_blur ?? 0) + 1, n_blur_timestamp: Date.now(), }); - input.current.checkValidity(); - return debounce === true && onEvent(); - }, [n_blur, debounce]); + currentInput.checkValidity(); + if (debounce === true) { + onEvent(); + } + }, [n_blur, debounce, onEvent, props]); const onChange = useCallback(() => { - const {value} = input.current; + const currentInput = input.current; + if (!currentInput) { + return; + } + const {value} = currentInput; setValue(value); }, []); @@ -143,14 +161,18 @@ function Input({ const setInputValue = useCallback( (base: InputProps['value'], value: InputProps['value']) => { - base = input.current.checkValidity() ? convert(base) : NaN; + const currentInput = input.current; + if (!currentInput) { + return; + } + base = currentInput.checkValidity() ? convert(base) : NaN; value = convert(value); if (!isEquivalent(base, value)) { if (typeof value === 'undefined') { - input.current.value = ''; + currentInput.value = ''; } else { - input.current.value = `${value}`; + currentInput.value = `${value}`; } } }, @@ -159,7 +181,11 @@ function Input({ const debounceEvent = useCallback( (seconds = 0.5) => { - const {value} = input.current; + const currentInput = input.current; + if (!currentInput) { + return; + } + const {value} = currentInput; window.clearTimeout(pendingEvent); setPendingEvent( window.setTimeout(() => { @@ -174,7 +200,11 @@ function Input({ const handleStepperClick = useCallback( (direction: 'increment' | 'decrement') => { - const currentValue = parseFloat(input.current.value) || 0; + const currentInput = input.current; + if (!currentInput) { + return; + } + const currentValue = parseFloat(currentInput.value) || 0; const stepAsNum = parseFloat(step as string) || 1; // Count decimal places to avoid floating point precision issues @@ -206,7 +236,7 @@ function Input({ constrainedValue.toFixed(decimalPlaces) ); - input.current.value = roundedValue.toString(); + currentInput.value = roundedValue.toString(); setValue(roundedValue.toString()); onEvent(); }, @@ -214,7 +244,11 @@ function Input({ ); useEffect(() => { - const {value} = input.current; + const currentInput = input.current; + if (!currentInput) { + return; + } + const {value} = currentInput; if (pendingEvent || props.value === value) { return; } @@ -231,14 +265,18 @@ function Input({ return; } - const {selectionStart: cursorPosition} = input.current; + const currentInput = input.current; + if (!currentInput) { + return; + } + const {selectionStart: cursorPosition} = currentInput; if (debounce) { if (typeof debounce === 'number' && Number.isFinite(debounce)) { debounceEvent(debounce); } if (type !== HTMLInputTypes.number) { setTimeout(() => { - input.current.setSelectionRange( + input.current?.setSelectionRange( cursorPosition, cursorPosition ); @@ -247,7 +285,14 @@ function Input({ } else { onEvent(); } - }, [value, debounce, type]); + }, [value, debounce, type, props.value, debounceEvent, onEvent]); + + useEffect( + () => () => { + window.clearTimeout(pendingEvent); + }, + [pendingEvent] + ); const disabledAsBool = [true, 'disabled', 'DISABLED'].includes( disabled ?? false From 65da12a10da90ac2f80c46dde672af261e6ea333 Mon Sep 17 00:00:00 2001 From: LarytheLord Date: Thu, 26 Feb 2026 13:42:49 +0530 Subject: [PATCH 3/5] fix(dcc): guard stale debounced input callbacks --- .../src/components/Input.tsx | 69 +++++-------------- 1 file changed, 18 insertions(+), 51 deletions(-) diff --git a/components/dash-core-components/src/components/Input.tsx b/components/dash-core-components/src/components/Input.tsx index d2d7555860..253d791d92 100644 --- a/components/dash-core-components/src/components/Input.tsx +++ b/components/dash-core-components/src/components/Input.tsx @@ -77,7 +77,7 @@ function Input({ disabled, ...props }: InputProps) { - const input = useRef(null); + const input = useRef(document.createElement('input')); const [value, setValue] = useState(props.value); const [pendingEvent, setPendingEvent] = useState(); const inputId = useState(() => uniqid('input-'))[0]; @@ -89,12 +89,8 @@ function Input({ const setPropValue = useCallback( (base: InputProps['value'], value: InputProps['value']) => { const {setProps} = props; - const currentInput = input.current; - if (!currentInput) { - return; - } base = convert(base); - value = currentInput.checkValidity() ? convert(value) : NaN; + value = input.current.checkValidity() ? convert(value) : NaN; if (!isEquivalent(base, value)) { setProps({value}); @@ -103,9 +99,10 @@ function Input({ [props.setProps] ); - const onEvent = useCallback(() => { + const onEvent = () => { const currentInput = input.current; if (!currentInput) { + setPendingEvent(undefined); return; } const {value: inputValue} = currentInput; @@ -120,29 +117,19 @@ function Input({ props.setProps({value: propValue}); } setPendingEvent(undefined); - }, [props, setPropValue, type, value]); + }; const onBlur = useCallback(() => { - const currentInput = input.current; - if (!currentInput) { - return; - } props.setProps({ n_blur: (n_blur ?? 0) + 1, n_blur_timestamp: Date.now(), }); - currentInput.checkValidity(); - if (debounce === true) { - onEvent(); - } - }, [n_blur, debounce, onEvent, props]); + input.current.checkValidity(); + return debounce === true && onEvent(); + }, [n_blur, debounce]); const onChange = useCallback(() => { - const currentInput = input.current; - if (!currentInput) { - return; - } - const {value} = currentInput; + const {value} = input.current; setValue(value); }, []); @@ -161,18 +148,14 @@ function Input({ const setInputValue = useCallback( (base: InputProps['value'], value: InputProps['value']) => { - const currentInput = input.current; - if (!currentInput) { - return; - } - base = currentInput.checkValidity() ? convert(base) : NaN; + base = input.current.checkValidity() ? convert(base) : NaN; value = convert(value); if (!isEquivalent(base, value)) { if (typeof value === 'undefined') { - currentInput.value = ''; + input.current.value = ''; } else { - currentInput.value = `${value}`; + input.current.value = `${value}`; } } }, @@ -181,11 +164,7 @@ function Input({ const debounceEvent = useCallback( (seconds = 0.5) => { - const currentInput = input.current; - if (!currentInput) { - return; - } - const {value} = currentInput; + const {value} = input.current; window.clearTimeout(pendingEvent); setPendingEvent( window.setTimeout(() => { @@ -200,11 +179,7 @@ function Input({ const handleStepperClick = useCallback( (direction: 'increment' | 'decrement') => { - const currentInput = input.current; - if (!currentInput) { - return; - } - const currentValue = parseFloat(currentInput.value) || 0; + const currentValue = parseFloat(input.current.value) || 0; const stepAsNum = parseFloat(step as string) || 1; // Count decimal places to avoid floating point precision issues @@ -236,7 +211,7 @@ function Input({ constrainedValue.toFixed(decimalPlaces) ); - currentInput.value = roundedValue.toString(); + input.current.value = roundedValue.toString(); setValue(roundedValue.toString()); onEvent(); }, @@ -244,11 +219,7 @@ function Input({ ); useEffect(() => { - const currentInput = input.current; - if (!currentInput) { - return; - } - const {value} = currentInput; + const {value} = input.current; if (pendingEvent || props.value === value) { return; } @@ -265,11 +236,7 @@ function Input({ return; } - const currentInput = input.current; - if (!currentInput) { - return; - } - const {selectionStart: cursorPosition} = currentInput; + const {selectionStart: cursorPosition} = input.current; if (debounce) { if (typeof debounce === 'number' && Number.isFinite(debounce)) { debounceEvent(debounce); @@ -285,7 +252,7 @@ function Input({ } else { onEvent(); } - }, [value, debounce, type, props.value, debounceEvent, onEvent]); + }, [value, debounce, type]); useEffect( () => () => { From c08b39a93114a2f2f210d4a512a99c3b0a50ddd6 Mon Sep 17 00:00:00 2001 From: LarytheLord Date: Thu, 26 Feb 2026 19:06:00 +0530 Subject: [PATCH 4/5] chore: keep tooltip PR scoped to dash-table changes --- .../src/components/Input.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/components/dash-core-components/src/components/Input.tsx b/components/dash-core-components/src/components/Input.tsx index 253d791d92..e099b2ee36 100644 --- a/components/dash-core-components/src/components/Input.tsx +++ b/components/dash-core-components/src/components/Input.tsx @@ -100,12 +100,7 @@ function Input({ ); const onEvent = () => { - const currentInput = input.current; - if (!currentInput) { - setPendingEvent(undefined); - return; - } - const {value: inputValue} = currentInput; + const {value: inputValue} = input.current; const valueAsNumber = convert(inputValue); if (type === HTMLInputTypes.number) { setPropValue(props.value, valueAsNumber ?? value); @@ -243,7 +238,7 @@ function Input({ } if (type !== HTMLInputTypes.number) { setTimeout(() => { - input.current?.setSelectionRange( + input.current.setSelectionRange( cursorPosition, cursorPosition ); @@ -254,13 +249,6 @@ function Input({ } }, [value, debounce, type]); - useEffect( - () => () => { - window.clearTimeout(pendingEvent); - }, - [pendingEvent] - ); - const disabledAsBool = [true, 'disabled', 'DISABLED'].includes( disabled ?? false ); From f7e5e4c15a5a2e4d5c66d99a22c5be880e6572df Mon Sep 17 00:00:00 2001 From: LarytheLord Date: Fri, 27 Feb 2026 12:41:36 +0530 Subject: [PATCH 5/5] style: apply prettier for tooltip pagination unit test --- .../tests/js-unit/cellEvents_tooltip_pagination_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts b/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts index 2291914c55..3b3cd6ed70 100644 --- a/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts +++ b/components/dash-table/tests/js-unit/cellEvents_tooltip_pagination_test.ts @@ -15,7 +15,7 @@ describe('cell events - tooltip row index with pagination', () => { indices: [5, 6, 7, 8, 9], offset: {rows: 0, columns: 0} } - }) as any; + } as any); handleEnter(propsFn, 6, 0); @@ -41,7 +41,7 @@ describe('cell events - tooltip row index with pagination', () => { indices: [5, 6, 7, 8, 9], offset: {rows: 0, columns: 0} } - }) as any; + } as any); handleMove(propsFn, 6, 0);