Skip to content

fix(discover): preserve keyword filter input focus after selection#2962

Merged
gauthier-th merged 4 commits into
developfrom
0xsysr3ll/fix/keywork-filter-focus
May 21, 2026
Merged

fix(discover): preserve keyword filter input focus after selection#2962
gauthier-th merged 4 commits into
developfrom
0xsysr3ll/fix/keywork-filter-focus

Conversation

@0xSysR3ll
Copy link
Copy Markdown
Contributor

@0xSysR3ll 0xSysR3ll commented Apr 27, 2026

Description

The PR fixes an annoying issue in Discover where the keyword and exclude-keyword filters would lose focus every time you selected a value. This happened because the selector component was being remounted on each update.

How Has This Been Tested?

Typed a keyword, hit enter, repeated without loosing input focus.

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

Summary by CodeRabbit

  • Bug Fixes
    • Keyword selector now reliably syncs selection state between single- and multi-select modes and with external handlers.
    • Default-value handling improved: missing defaults clear the current selection; when defaults are present they load correctly without forcing component remounts, and user choices update state immediately before external change handlers are called.

Review Change Stack

@0xSysR3ll 0xSysR3ll requested a review from a team as a code owner April 27, 2026 13:20
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

KeywordSelector converted from uncontrolled to controlled: internal state now stores react-select value, effects initialize/clear selection based on default keywords and isMulti, effect dependencies updated, and user selection updates internal selectedValue before calling external onChange.

Changes

Selector / KeywordSelector

Layer / File(s) Summary
State + default initialization
src/components/Selector/index.tsx
Replaces defaultDataValue with selectedValue, clears selection when defaultValue is absent, maps loaded default keywords into {label,value} options, and adds isMulti to effect dependencies.
Controlled AsyncSelect wiring
src/components/Selector/index.tsx
Removes the key-based remount, switches AsyncSelect from defaultValue to controlled value={selectedValue}, and updates onChange to sync selectedValue before delegating to the external onChange.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble on values, neat and small,
I hold their shapes so they won't fall.
No sudden remount, no wandering key,
Selections settle, calm and free.
Hooray — focus hops along with me.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: preserving keyword filter input focus after selection, which is the core issue being addressed in the PR.
Linked Issues check ✅ Passed The code changes directly address issue #2830 by refactoring the KeywordSelector from an uncontrolled to controlled component, removing the forced remount (via key prop) that was causing focus loss.
Out of Scope Changes check ✅ Passed All changes in the KeywordSelector component are directly related to fixing the focus loss issue; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Selector/index.tsx (1)

301-330: ⚠️ Potential issue | 🟡 Minor

Async effect can race and clobber the user's latest selection.

Now that value is controlled, every user pick propagates to the parent (URL/filter state) and comes back as a new defaultValue, which re-fires this effect and re-fetches each keyword by id. There are two practical concerns:

  1. Stale-response race: loadDefaultKeywords has no cancellation. If defaultValue changes faster than the network (e.g., user picks a second keyword before the previous fetch resolves), an older response can resolve last and overwrite selectedValue with stale data.
  2. Redundant work: every selection triggers setSelectedValue(value) (immediate) followed shortly by setSelectedValue(fetched) from the effect — an N+1 round-trip per user pick, which can also cause a transient flicker of the chip(s).

Consider guarding the effect with a cancellation flag and short-circuiting when defaultValue already matches the current selectedValue (so user-driven updates don't re-fetch themselves):

♻️ Suggested refactor
 useEffect(() => {
+  let cancelled = false;
   const loadDefaultKeywords = async (): Promise<void> => {
     if (!defaultValue) {
-      setSelectedValue(null);
+      if (!cancelled) setSelectedValue(null);
       return;
     }

+    // Skip refetch when defaultValue already reflects the current selection
+    const currentIds = (Array.isArray(selectedValue)
+      ? selectedValue
+      : selectedValue
+        ? [selectedValue]
+        : []
+    )
+      .map((v) => String(v.value))
+      .join(',');
+    if (currentIds === defaultValue) return;
+
     const keywords = await Promise.all(
       defaultValue.split(',').map(async (keywordId) => {
         const keyword = await axios.get<Keyword | null>(
           `/api/v1/keyword/${keywordId}`
         );
         return keyword.data;
       })
     );

     const validKeywords: Keyword[] = keywords.filter(
       (keyword): keyword is Keyword => keyword !== null
     );

     const nextValue = validKeywords.map((keyword) => ({
       label: keyword.name,
       value: keyword.id,
     }));

-    setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null));
+    if (!cancelled) {
+      setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null));
+    }
   };

   loadDefaultKeywords();
+  return () => {
+    cancelled = true;
+  };
 }, [defaultValue, isMulti]);

If you adopt the early-return guard, add selectedValue to the dep array (or read it via a ref to avoid re-running the effect).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Selector/index.tsx` around lines 301 - 330, The effect's async
loader (loadDefaultKeywords) can race and clobber user selection; modify
useEffect to (1) bail out early when the incoming defaultValue already matches
the current selectedValue (compare string form of selectedValue vs defaultValue)
to avoid redundant refetches, (2) add a local cancelled flag or AbortController
inside loadDefaultKeywords and check it before calling setSelectedValue so stale
responses never overwrite state, and (3) either include selectedValue in the
effect deps or read selectedValue from a ref inside the effect to avoid
unnecessary re-runs; target the loadDefaultKeywords, setSelectedValue,
defaultValue, selectedValue and isMulti symbols when making these changes.
🧹 Nitpick comments (2)
src/components/Selector/index.tsx (2)

348-368: Fix LGTM — controlled value correctly resolves the focus-loss bug.

Dropping the key={...defaultDataValue} pattern (which forced a remount on every default change and stole focus) in favor of the controlled value={selectedValue} is the right approach for #2830. Mirroring this in the sibling selectors (CompanySelector, GenreSelector, StatusSelector, UserSelector), which still rely on the dynamic key + defaultValue pattern, would be a nice follow-up if those filters can also be appended in-place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Selector/index.tsx` around lines 348 - 368, Sibling selectors
(CompanySelector, GenreSelector, StatusSelector, UserSelector) still use the
remount-causing pattern (key={...defaultDataValue} + defaultValue) and should
mirror the fix applied in Selector/index.tsx: remove the dynamic key and
defaultValue usage, make each selector fully controlled by wiring its value prop
to component state (e.g., selectedValue) and call the existing onChange handler
to update state, keep other props (isMulti, isDisabled, loadOptions,
placeholder, noOptionsMessage) intact; locate the components by their component
names and replace the key/defaultValue pattern with value={selectedValue} and
the same onChange logic used in Selector's AsyncSelect to prevent focus loss on
updates.

297-299: Remove the redundant | null in the state type.

SingleValue<SingleVal> from react-select already expands to SingleVal | null, making the trailing | null redundant:

Suggested change
-  const [selectedValue, setSelectedValue] = useState<
-    MultiValue<SingleVal> | SingleValue<SingleVal> | null
-  >(null);
+  const [selectedValue, setSelectedValue] = useState<
+    MultiValue<SingleVal> | SingleValue<SingleVal>
+  >(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Selector/index.tsx` around lines 297 - 299, The state type for
selectedValue is using an unnecessary union with null; update the useState
generic so selectedValue is typed as MultiValue<SingleVal> |
SingleValue<SingleVal> (remove the trailing | null) and keep the same
identifiers (selectedValue, setSelectedValue, useState, MultiValue<SingleVal>,
SingleValue<SingleVal>) so the nullability provided by SingleValue<SingleVal> is
relied on instead of repeating | null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/Selector/index.tsx`:
- Around line 301-330: The effect's async loader (loadDefaultKeywords) can race
and clobber user selection; modify useEffect to (1) bail out early when the
incoming defaultValue already matches the current selectedValue (compare string
form of selectedValue vs defaultValue) to avoid redundant refetches, (2) add a
local cancelled flag or AbortController inside loadDefaultKeywords and check it
before calling setSelectedValue so stale responses never overwrite state, and
(3) either include selectedValue in the effect deps or read selectedValue from a
ref inside the effect to avoid unnecessary re-runs; target the
loadDefaultKeywords, setSelectedValue, defaultValue, selectedValue and isMulti
symbols when making these changes.

---

Nitpick comments:
In `@src/components/Selector/index.tsx`:
- Around line 348-368: Sibling selectors (CompanySelector, GenreSelector,
StatusSelector, UserSelector) still use the remount-causing pattern
(key={...defaultDataValue} + defaultValue) and should mirror the fix applied in
Selector/index.tsx: remove the dynamic key and defaultValue usage, make each
selector fully controlled by wiring its value prop to component state (e.g.,
selectedValue) and call the existing onChange handler to update state, keep
other props (isMulti, isDisabled, loadOptions, placeholder, noOptionsMessage)
intact; locate the components by their component names and replace the
key/defaultValue pattern with value={selectedValue} and the same onChange logic
used in Selector's AsyncSelect to prevent focus loss on updates.
- Around line 297-299: The state type for selectedValue is using an unnecessary
union with null; update the useState generic so selectedValue is typed as
MultiValue<SingleVal> | SingleValue<SingleVal> (remove the trailing | null) and
keep the same identifiers (selectedValue, setSelectedValue, useState,
MultiValue<SingleVal>, SingleValue<SingleVal>) so the nullability provided by
SingleValue<SingleVal> is relied on instead of repeating | null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 09df69ad-1c36-4829-ad59-dd8b140f8f4a

📥 Commits

Reviewing files that changed from the base of the PR and between 428fc4f and da7cfd4.

📒 Files selected for processing (1)
  • src/components/Selector/index.tsx

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Selector/index.tsx (1)

301-330: ⚠️ Potential issue | 🟠 Major

Potential race condition: stale loadDefaultKeywords resolution can clobber the user's just-added selection.

After this change, every user selection round-trips through the parent (URL params / form state) and pushes a new defaultValue back into the component, which re-fires this effect and starts a fresh axios.get per keyword id. There is no cancellation, so when the user adds keywords in quick succession (the exact UX this PR enables — typing, hitting enter, repeating), the in-flight requests can resolve out of order: an earlier invocation finishing last will call setSelectedValue with a smaller/older array and silently drop the most recently added entry. Additionally, if /api/v1/keyword/:id returns null for a freshly-selected keyword, the validKeywords filter strips it and the selection visibly reverts.

Guard the async work with a cancellation flag (or AbortController) so only the latest invocation can update state.

🔒 Suggested fix: ignore stale resolutions
   useEffect(() => {
+    let cancelled = false;
     const loadDefaultKeywords = async (): Promise<void> => {
       if (!defaultValue) {
-        setSelectedValue(null);
+        if (!cancelled) setSelectedValue(null);
         return;
       }

       const keywords = await Promise.all(
         defaultValue.split(',').map(async (keywordId) => {
           const keyword = await axios.get<Keyword | null>(
             `/api/v1/keyword/${keywordId}`
           );
           return keyword.data;
         })
       );

+      if (cancelled) return;
+
       const validKeywords: Keyword[] = keywords.filter(
         (keyword): keyword is Keyword => keyword !== null
       );

       const nextValue = validKeywords.map((keyword) => ({
         label: keyword.name,
         value: keyword.id,
       }));

       setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null));
     };

     loadDefaultKeywords();
+    return () => {
+      cancelled = true;
+    };
   }, [defaultValue, isMulti]);

As a follow-up, consider short‑circuiting the refetch when selectedValue already matches defaultValue (the values were just produced by loadKeywordOptions and don't need re-fetching) — that would also avoid the “API returns null → selection disappears” edge case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Selector/index.tsx` around lines 301 - 330, The effect that
defines loadDefaultKeywords can clobber newer user selections because in-flight
axios.get calls resolve out of order; modify loadDefaultKeywords (and its
enclosing useEffect) to use a cancellation mechanism (e.g., create an
AbortController or a local `let cancelled = false`) tied to the effect cleanup
so only the latest invocation updates state: pass the controller.signal to
axios.get (or check cancelled after each await), and in the useEffect cleanup
call controller.abort() or set cancelled = true to prevent stale resolutions
from calling setSelectedValue; keep the existing mapping/validation logic
(defaultValue.split, validKeywords, nextValue) but gate the final
setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null)) behind the
cancellation check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/Selector/index.tsx`:
- Around line 301-330: The effect that defines loadDefaultKeywords can clobber
newer user selections because in-flight axios.get calls resolve out of order;
modify loadDefaultKeywords (and its enclosing useEffect) to use a cancellation
mechanism (e.g., create an AbortController or a local `let cancelled = false`)
tied to the effect cleanup so only the latest invocation updates state: pass the
controller.signal to axios.get (or check cancelled after each await), and in the
useEffect cleanup call controller.abort() or set cancelled = true to prevent
stale resolutions from calling setSelectedValue; keep the existing
mapping/validation logic (defaultValue.split, validKeywords, nextValue) but gate
the final setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null)) behind
the cancellation check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8ae36a47-70ef-4b59-83bc-c15fd2855c1c

📥 Commits

Reviewing files that changed from the base of the PR and between da7cfd4 and 8997f2f.

📒 Files selected for processing (1)
  • src/components/Selector/index.tsx

Comment thread src/components/Selector/index.tsx Outdated
@0xSysR3ll 0xSysR3ll requested a review from M0NsTeRRR May 20, 2026 17:49
M0NsTeRRR
M0NsTeRRR previously approved these changes May 20, 2026
@seerr-automation-bot seerr-automation-bot added this to the v3.3.0 milestone May 20, 2026
Co-authored-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Selector/index.tsx (1)

301-330: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against stale async writes in keyword default loading.

When defaultValue changes rapidly, an older in-flight request can resolve last and overwrite a newer selection. Please gate setSelectedValue with effect cleanup/cancellation.

Suggested fix
   useEffect(() => {
+    let cancelled = false;
+
     const loadDefaultKeywords = async (): Promise<void> => {
       if (!defaultValue) {
-        setSelectedValue(null);
+        if (!cancelled) {
+          setSelectedValue(null);
+        }
         return;
       }

       const keywords = await Promise.all(
         defaultValue.split(',').map(async (keywordId) => {
           const keyword = await axios.get<Keyword | null>(
             `/api/v1/keyword/${keywordId}`
           );
           return keyword.data;
         })
       );

       const validKeywords: Keyword[] = keywords.filter(
         (keyword): keyword is Keyword => keyword !== null
       );

       const nextValue = validKeywords.map((keyword) => ({
         label: keyword.name,
         value: keyword.id,
       }));

-      setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null));
+      if (!cancelled) {
+        setSelectedValue(isMulti ? nextValue : (nextValue[0] ?? null));
+      }
     };

     loadDefaultKeywords();
+    return () => {
+      cancelled = true;
+    };
   }, [defaultValue, isMulti]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Selector/index.tsx` around lines 301 - 330, The async
loadDefaultKeywords effect can write stale results when defaultValue changes;
modify it to cancel/outdate prior requests by using an in-effect token (e.g., a
boolean `isCurrent` or AbortController) and check that token before calling
setSelectedValue. Specifically, inside the useEffect that defines
loadDefaultKeywords, create a local `isCurrent = true` (or an AbortController)
before starting async work, and in the cleanup return set `isCurrent = false`
(or call controller.abort()); then, before calling setSelectedValue(...) only
proceed if `isCurrent` is still true (or the request wasn't aborted). Keep the
rest of loadDefaultKeywords (axios.get, mapping to nextValue) the same and
ensure the effect still depends on defaultValue and isMulti.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/components/Selector/index.tsx`:
- Around line 301-330: The async loadDefaultKeywords effect can write stale
results when defaultValue changes; modify it to cancel/outdate prior requests by
using an in-effect token (e.g., a boolean `isCurrent` or AbortController) and
check that token before calling setSelectedValue. Specifically, inside the
useEffect that defines loadDefaultKeywords, create a local `isCurrent = true`
(or an AbortController) before starting async work, and in the cleanup return
set `isCurrent = false` (or call controller.abort()); then, before calling
setSelectedValue(...) only proceed if `isCurrent` is still true (or the request
wasn't aborted). Keep the rest of loadDefaultKeywords (axios.get, mapping to
nextValue) the same and ensure the effect still depends on defaultValue and
isMulti.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ccd16b77-ce49-4c31-9844-413a9e1df0bd

📥 Commits

Reviewing files that changed from the base of the PR and between 8997f2f and 0fd6c5d.

📒 Files selected for processing (1)
  • src/components/Selector/index.tsx

@gauthier-th gauthier-th merged commit 32169d9 into develop May 21, 2026
17 checks passed
@gauthier-th gauthier-th deleted the 0xsysr3ll/fix/keywork-filter-focus branch May 21, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WEBUI QoL - Filter text fields lose focus after entering a single filter

4 participants