Let memoized file rows skip unrelated re-renders

FileRow and FileCard were wrapped in memo, but every handler passed to
them was re-created on each render, so a keystroke or a selection
re-rendered every row of a large directory. useStableHandler gives the
rows fixed handler identities that still run the latest code.
This commit is contained in:
Juan Denis committed 2026-10-10 22:38:21 -04:00
1 parent 540b86c445
commit e49c627640
3 files changed
+51 -11

No files matched your search

+17 -1
View File
@@ -18,6 +18,7 @@ import Login from '/src/pages/Login.jsx';
import SSOCallback from '/src/pages/SSOCallback.jsx';
import {useOverflowItems} from '/src/hooks/useOverflowItems.js';
import useFocusTrap from '/src/hooks/ai/useFocusTrap.js';
import {useStableHandler} from '/src/hooks/useStableHandler.js';
window.calls=[]; window.users=[]; window.reads=0;
window.onUser=user=>window.users.push(user);
window.testApi={
@@ -38,9 +39,16 @@ function Focus(){
useFocusTrap(box,{active,restoreFocusRef:restore});
return <><button id="first" ref={first}>first</button><button id="second" ref={second}>second</button><div ref={box}><button>inside</button></div></>
}
window.rowRenders=0;window.seen=[];
const Row=React.memo(function Row({onPick}){window.rowRenders++;return <button id="row" onClick={onPick}>row</button>});
function Stable(){
const [count,setCount]=useState(0);window.inc=()=>setCount(n=>n+1);
const onPick=useStableHandler(()=>window.seen.push(count));
return <><output>{count}</output><Row onPick={onPick}/></>
}
function App(){
const [,setTick]=useState(0);window.bump=()=>setTick(n=>n+1);
if(mode==='overflow')return <Overflow/>;if(mode==='focus')return <Focus/>;
if(mode==='overflow')return <Overflow/>;if(mode==='stable')return <Stable/>;if(mode==='focus')return <Focus/>;
const entry=mode==='sso'?'/login/callback/test?code=code1&state=state1':{pathname:'/login',state:{requires2FA:true,tempToken:'challenge1'}};
return <MemoryRouter initialEntries={[entry]}><Routes><Route path="/login" element={<Login/>}/><Route path="/login/callback/:provider" element={<SSOCallback/>}/><Route path="*" element={<div id="done">done</div>}/></Routes></MemoryRouter>
}
@@ -104,5 +112,13 @@ try {
await page.goto(origin+'/hook-check?mode=focus');await page.locator('#first').waitFor();await page.waitForTimeout(100);
await page.evaluate(()=>window.deactivate());await page.waitForFunction(()=>document.activeElement.id==='first');
console.log('PASS focus restores activation target after ref changes');
await page.goto(origin+'/hook-check?mode=stable');await page.locator('#row').waitFor();
const mounted=await page.evaluate(()=>window.rowRenders);
for(let i=0;i<3;i++)await page.evaluate(()=>window.inc());
await page.waitForFunction(()=>document.querySelector('output').textContent==='3');
assert.equal(await page.evaluate(()=>window.rowRenders),mounted,'a parent update re-rendered the memoized row');
await page.locator('#row').click();
assert.deepEqual(await page.evaluate(()=>window.seen),[3]);
console.log('PASS a stable handler keeps memoized rows still and still sees the latest state');
assert.deepEqual(errors,[]);
} finally {await browser?.close();await server.close()}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
// A callback whose identity never changes but that always runs the latest
// `fn`. Pass it to memoized children (list rows) so a parent re-render does
// not re-render every row just because its handlers were re-created.
// Not for use during render: the latest `fn` is installed after commit.
export function useStableHandler(fn) {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args) => ref.current(...args), []);
}
+21 -10
View File
@@ -31,6 +31,7 @@ import { useTranslation } from 'react-i18next';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import { errorReason } from '@/utils/errorMessage';
import usePersistentState from '@/hooks/usePersistentState';
import { useStableHandler } from '@/hooks/useStableHandler';
import { formatTime } from '@/utils/intl';
// Demo rail shortcuts (Quick access) — one-click jumps to the paths people
@@ -865,6 +866,16 @@ function FileManager() {
setContextMenu({ x: e.clientX, y: e.clientY, entry });
};
// Stable identities for the memoized rows/cards: a fresh handler on every
// render made each keystroke or selection re-render every row.
const rowOpen = useStableHandler(handleOpen);
const rowToggleSelect = useStableHandler(handleToggleSelect);
const rowContext = useStableHandler(openContextMenu);
const rowDownload = useStableHandler((entry) => fileApi.download(entry));
const rowRename = useStableHandler(openRenameModal);
const rowPermissions = useStableHandler(openPermissionsModal);
const rowDelete = useStableHandler((entry) => handleDelete(entry));
const copyPathToClipboard = (path) => copyText(path);
const downloadSelected = () => {
@@ -1309,9 +1320,9 @@ function FileManager() {
entry={entry}
selected={selectedPaths.has(entry.path)}
selectMode={selectMode}
onOpen={handleOpen}
onToggleSelect={handleToggleSelect}
onContext={openContextMenu}
onOpen={rowOpen}
onToggleSelect={rowToggleSelect}
onContext={rowContext}
isS3={isS3}
/>
))}
@@ -1348,13 +1359,13 @@ function FileManager() {
entry={entry}
selected={selectedPaths.has(entry.path)}
selectMode={selectMode}
onOpen={handleOpen}
onToggleSelect={handleToggleSelect}
onContext={openContextMenu}
onDownload={(e) => fileApi.download(e)}
onRename={openRenameModal}
onPermissions={openPermissionsModal}
onDelete={(e) => handleDelete(e)}
onOpen={rowOpen}
onToggleSelect={rowToggleSelect}
onContext={rowContext}
onDownload={rowDownload}
onRename={rowRename}
onPermissions={rowPermissions}
onDelete={rowDelete}
/>
))}
</div>