Repository navigation
Conversation
addSummaryButton only pushed refs and never cleared an entry when a panel's button unmounted, so a detached DOM node stayed in the array and keyboard navigation could focus it instead of a remaining panel. Key the ref array by child index and clear the slot on unmount.
Verifies that keyboard navigation focuses the correct remaining button after a panel is removed, instead of a stale detached ref.
|
Note: this review was assisted by an AI coding agent — I ran it and vetted the findings below. Fix works for the reported bug, but I think it's only half the fix. Confirmed: button refs no longer accumulate stale entries when a panel unmounts — traced the old push-based Not addressed — same root cause: Smaller things, non-blocking:
Would like to see |
currButtonIndex never resynced when a panel was removed. If focus was on the last panel and an earlier one was removed, currButtonIndex kept pointing at the old (now out-of-range) position, so the next arrow key press read a stale index and became a no-op instead of moving focus. Clamp currButtonIndex during render when childCount changes, following https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes instead of doing it in an effect (which would trigger an extra render and tripped the react-hooks/set-state-in-effect lint rule). Per PR review on salesforce#3214: this was flagged as the remaining half of the stale-ref fix, confirmed by reproducing it with a dedicated regression test before making this change.
Verifies that ArrowUp still moves focus to the correct button after an earlier panel is removed while focus was on the last one, instead of becoming a no-op due to a stale tracked index.
…d-list removal The childCount clamp only catches the case where the tracked index ends up past the end of the shortened list. Removing a panel before the focused one shifts the focused button to an earlier position without moving DOM focus off of it, so currButtonIndex stays in range but now points at the wrong button. Resync currButtonIndex to wherever document.activeElement actually is among the ref array, falling back to the childCount clamp when nothing is focused. This has to run in a layout effect rather than during render, since summaryButtonsRef only gets reattached to the new positions during the commit that follows this render.
Verifies that removing a panel before the focused middle panel resyncs the tracked index to the button's new position, instead of leaving it stuck on a stale (but in-range) index.
|
Thanks for the comment. I reproduced the stale I fixed that first case in That clamp only catches the case where the tracked index ends up past the end of the list. I left the three non-blocking items you listed as follow-up, since you mentioned they could be handled separately. |
Fixes #
Additional description
Motivation:
Accordionleaked stalesummaryButtonsrefs on panel removal — a detached DOM node from a removed panel stayed in the array, so keyboard navigation could focus it instead of a remaining panel.This was already reported and fixed in #3193, but that PR was closed during the recent React 19 / TypeScript rewrite (#3195):
I reverified against the rewritten
components/accordion/index.tsxand confirmed the bug is still happen, so reopening this fix here.Root cause:
addSummaryButtononly ever pushed refs intosummaryButtonsRef.currentand de-duped by identity (!summaryButtonsRef.current.includes(button)). It never handled thenullcallback React fires when a panel's button unmounts, so a detached DOM node from a removed panel stayed in the array. Keyboard navigation (ArrowUp/ArrowDown) could then focus that stale detached node instead of the correct remaining panel.What I did: Keyed
summaryButtonsRef.currentby the child's render index instead of push/identity, and clear the slot (delete summaryButtonsRef.current[index]) when its ref callback fires withnullon unmount.React.Children.map's index is now passed through toaddSummaryButtonper child.CONTRIBUTOR checklist (do not remove)
Please complete for every pull request.
Green gate — all of these must pass locally (CI runs the same):
npm run lintpasses with 0 errors (npm run lint:fixto auto-fix). Warnings are the tracked gradual-migration backlog; do not add new errors.npm run typecheckis clean (tsc --noEmit).npm test -- --runpasses — this runs both Vitest projects (jsdomunit+ real-browserbrowser) and the story HTML snapshots. If you changed rendered markup on purpose, update snapshots withnpm test -- --run -uand review the diff.npm run buildsucceeds (type-check + Vite library build +.d.ts+ entry shims).Behavior & docs:
components/button/__tests__). New props and bug fixes should get a regression test. Tests that genuinely need a real browser (focus traversal, layout/measurement, canvas) go in a*.browser.test.jsxfile (thebrowserproject); everything else is jsdom.focuses the correct remaining button after a panel is removedtocomponents/accordion/__tests__/accordion.test.jsx(jsdomunitproject — the fix's.focus()call is triggered by component state, not real browser tab traversal, so jsdom is sufficient here). Verified it fails against the pre-fix code (focus lands on the stale removed panel's button) and passes after the fix.npm run storybook→ http://localhost:6007/. Confirm the story actually renders and behaves (deprecated Storyshots/Karma are gone; there is no separate browser-tests server).onRenderContent/onRenderVisualPicker,iconprops,events), not assumptions — a common source of "component looks broken" reports has been stories authored against the wrong API. Cross-check againstcomponents/<name>/__examples__..d.ts(they're generated by the build); JS consumers rely on them via editor type-acquisition.REVIEWER checklist (do not remove)
CIworkflow: lint, typecheck, test, build). Chromatic visual review, if configured, has been checked.components/__tests__/__snapshots__/story-snapshots.test.jsx.snap).@salesforce/design-system-react/components/<name>and nested paths resolve for ESM, CJS, and types). Removing/renaming a public component or export is a breaking change →feat!:/BREAKING CHANGE:and a major version bump.