From e49c62764029311b4bd0d59f49bebba5fb7da8f4 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 10 Oct 2026 22:38:21 -0400 Subject: [PATCH] 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. --- frontend/scripts/hooks-browser.mjs | 18 ++++++++++++++- frontend/src/hooks/useStableHandler.js | 13 +++++++++++ frontend/src/pages/FileManager.jsx | 31 +++++++++++++++++--------- 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useStableHandler.js diff --git a/frontend/scripts/hooks-browser.mjs b/frontend/scripts/hooks-browser.mjs index d3341ac6..295cb253 100644 --- a/frontend/scripts/hooks-browser.mjs +++ b/frontend/scripts/hooks-browser.mjs @@ -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 <>
} +window.rowRenders=0;window.seen=[]; +const Row=React.memo(function Row({onPick}){window.rowRenders++;return }); +function Stable(){ + const [count,setCount]=useState(0);window.inc=()=>setCount(n=>n+1); + const onPick=useStableHandler(()=>window.seen.push(count)); + return <>{count} +} function App(){ const [,setTick]=useState(0);window.bump=()=>setTick(n=>n+1); - if(mode==='overflow')return ;if(mode==='focus')return ; + if(mode==='overflow')return ;if(mode==='stable')return ;if(mode==='focus')return ; const entry=mode==='sso'?'/login/callback/test?code=code1&state=state1':{pathname:'/login',state:{requires2FA:true,tempToken:'challenge1'}}; return }/>}/>done}/> } @@ -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()} diff --git a/frontend/src/hooks/useStableHandler.js b/frontend/src/hooks/useStableHandler.js new file mode 100644 index 00000000..87ebaa2e --- /dev/null +++ b/frontend/src/hooks/useStableHandler.js @@ -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), []); +} diff --git a/frontend/src/pages/FileManager.jsx b/frontend/src/pages/FileManager.jsx index fb88a5b0..7aa8d399 100644 --- a/frontend/src/pages/FileManager.jsx +++ b/frontend/src/pages/FileManager.jsx @@ -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} /> ))}