-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix suggestion menu closes on typing @ at second line #27490
Fix suggestion menu closes on typing @ at second line #27490
Conversation
@cubuspl42 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
return; | ||
} | ||
suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); | ||
}, [suggestionsRef]); | ||
|
||
const debouncedHideSuggestionMenu = useMemo(() => _.debounce(hideSuggestionMenu, 200, true), [hideSuggestionMenu]); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I set immediate
of debounce to true, so the suggestion hides immediately when user scrolls the composer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Too be honest, I'm not sure if this is correct, as I have trouble reasoning about the current implementation. In the other thread I suggested something that's easier to reason about for me. Please let me know if the alternative way is also convincing to you.
if (!suggestionsRef.current) { | ||
const hideSuggestionMenu = useCallback(() => { | ||
if (!suggestionsRef.current || shouldIgnoreScrollCallback.current) { | ||
shouldIgnoreScrollCallback.current = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure whether this is a right way to lower this flag...
Wouldn't this be more direct?
const debouncedLowerIsScrollLikelyLayoutTriggered = useDebounce(() => {
isScrollLikelyLayoutTriggered.value = false;
}, 200);
const raiseIsScrollLikelyLayoutTriggered = () => {
isScrollLikelyLayoutTriggered.value = true;
debouncedLowerIsScrollLikelyLayoutTriggered();
}
Then, we could call raiseIsScrollLikelyLayoutTriggered
when the text changes. And check if isScrollLikelyLayoutTriggered.value
directly in the onScoll
callback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I suggested using a useDebounce
hook instead of directly calling _.debounce
inside useMemo
, because technically speaking starting a debounce is a side-effect...
I think that a possible implementation could be:
const useDebounce = (func, wait, options) => {
const debouncedFnRef = useRef();
useEffect(() => {
const debouncedFn = _.debounce(func, wait, options);
debouncedFnRef.current = debouncedFn;
return () => debouncedFn.cancel();
});
return (...args) => {
const debouncedFn = debouncedFnRef.current;
if (debouncedFn) {
debouncedFn(...args);
}
};
};
(Based on lodash debounce
)
But this would need to be tested.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. I'm actually having trouble deciding where to set the value back to false, seeing your alternative is better. I will try it and the useDebounce hook and update if all going well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Found 2 issues:
- The
useEffect
does not have any deps so it's always triggered on re-render making the debounce get canceled all the time. I fixed it by puttingfunc
,wait
,options
into the deps array. - We need a longer timeout now, from my testing 500 is enough. Looks like we need a longer time because we are debouncing it from
onChangeText
instead of fromonScroll
. The extra 300ms is possibly the time gap betweenonChangeText
andonScroll
calls.
The question is, are we okay to make the timeout 500ms?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The useEffect does not have any deps
Yeah, I was writing it out of my head, it was just a starting point. You're right that deps are required here.
The question is, are we okay to make the timeout 500ms?
You're talking about a Native build, I presume?
We're fine with a value that's good at distinguishing the layout-related scrolls from the manual ones, taking into account that a real user rarely scrolls immediately after typing.
We should test this on a release build, preferably on a physical device, as in my experience it makes a difference. Compiling a release build is very slow, so you can create a spin-off branch that adds a debug UI component for choosing the wait
value. Visually, it can be placed anywhere. Then we can experiment with this manually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. So, are we good with the 500ms? I will push the update if we have no other concerns
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we are, I'm not against any particular number, as I mentioned. I will test it and figure out if I can see any UX issues with it. Because we are in agreement that the only thing this number does is draw the boundary between true/false positives (/negatives) on debug/release modes, right?
Also, the constant itself isn't the only open topic here. We also have the issue of impureness in useMemo
and the wording of the comment we leave for the others.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because we are in agreement that the only thing this number does is draw the boundary between true/false positives (/negatives) on debug/release modes, right?
yes
We also have the issue of impureness in useMemo and the wording of the comment we leave for the others.
I will address this along with this.
I will probably push the update tomorrow. (it's midnight)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I didn't mean to rush
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed the update, only comment left.
return; | ||
} | ||
suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); | ||
}, [suggestionsRef]); | ||
|
||
const debouncedHideSuggestionMenu = useMemo(() => _.debounce(hideSuggestionMenu, 200, true), [hideSuggestionMenu]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We definitely need to explain ourselves out of this 200
, as in Expensify we're (rightfully) skeptical against timeout-based solutions. We need to comment why do this and why 200 ms.
src/hooks/useDebounce.js
Outdated
return debouncedFn.cancel; | ||
}, [func, wait, options]); | ||
|
||
return debouncedFnRef.current; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, maybe let's wrap this into a lambda that calls debouncedFnRef.current
only if it's not undefined
? I think the case when it would be undefined
are always an error, as we don't expect the returned function to be called inside a render function directly, but maybe dropping a call in a corner case is better than a crash?...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually prefer the opposite so we can catch the error/crash earlier, but I'm okay with the suggestion. Updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know what you mean. Perfectly, it would be great to combine both things, i.e. a safe handling of a corner case + an analytics report of an error. But we don't have such conventions in Expensify.
If you disagree with a suggestion, you can always say so before applying the change. Sometimes C+s will push for a change, sometimes not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's okay, I will say it if it is something that I strongly disagree 😄.
* @param {Boolean} options.trailing Specify invoking on the trailing edge of the timeout. | ||
* @returns Returns the new debounced function. | ||
*/ | ||
export default function useDebounce(func, wait, options) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ask for a review of this new code pattern
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 to useDebounce but I'd maybe include some notes about passing a stable function reference to it in order for it to work as expected.
I would start with a comment and a few examples using useCallback so people will copy examples with stable references 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe that the examples don't have to be in the JSDoc, maybe we can start by giving a good example in our PR?
We can just explicitly document that the debouncing operator resets its internal state when func
changes, so it's recommended to provide stable function references to achieve predictable/expected results.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated the comment.
maybe we can start by giving a good example in our PR?
Yeah, I think the use of useCallback
in the composer code as the example is enough.
I think we're looking good, I'll start completing the checklist. |
Please merge |
Done |
Scrolling doesn't close the suggestion list for me on the feature branch... Do you reproduce this behavior? The code looked good for me on paper, but it doesn't seem to achieve its goal (unless there is a flaw in my testing) prod.mp4feature-branch.mp4 |
Hmm, that's weird. Last time I tested it works fine. Let me check it! |
@bernhardoj Also, update the test steps to include this case |
Somehow the
I actually included this case in the recording, but I don't know what to write for the test step 😭.
Do you think the wording is good enough? |
@bernhardoj It's okey, I guess. Could also be:
...but also...
|
Great, thanks! Updated with a little bit modification. |
@bernhardoj "After at least a second, " can be removed in my opinion. I was too defensive suggesting that. I have big trouble reproducing a case when the manual scroll is considered layout-triggered, so I think we can just drop that part. |
@@ -324,8 +346,8 @@ function ComposerWithSuggestions({ | |||
[suggestionsRef], | |||
); | |||
|
|||
const updateShouldShowSuggestionMenuToFalse = useCallback(() => { | |||
if (!suggestionsRef.current) { | |||
const hideSuggestionMenu = useCallback(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like this name change, it's like changing giveCatLikeHighPitchedSound
to meow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😸
Reviewer Checklist
Screenshots/VideosWebfix-suggestions-ios-web.mp4Mobile Web - Chromefix-suggestions-android-web-compressed.mp4Mobile Web - Safarifix-suggestions-ios-web.mp4DesktopiOSfix-suggestions-ios.mp4Androidfix-suggestions-android-compressed.mp4 |
Removed Should we remove this too?
|
I don't have a strong opinion here. |
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
🚀 Deployed to staging by https://github.com/Gonals in version: 1.3.75-0 🚀
|
🚀 Deployed to production by https://github.com/mountiny in version: 1.3.75-12 🚀
|
🚀 Deployed to staging by https://github.com/Gonals in version: 1.3.76-0 🚀
|
🚀 Deployed to production by https://github.com/mountiny in version: 1.3.76-6 🚀
|
Details
On Android, typing a new next on a new line will trigger a scroll callback. The scroll callback is set to hide the suggestion menu.
Fixed Issues
$ #26197
PROPOSAL: #26197 (comment)
Tests
Same as QA Steps
Offline tests
Same as QA Steps
QA Steps
If you scroll right after typing (< 1 s), it's acceptable for the suggestion menu not to disappear after this interaction
Verify that no errors appear in the JS console
PR Author Checklist
### Fixed Issues
section aboveTests
sectionOffline steps
sectionQA steps
sectiontoggleReport
and notonIconClick
)myBool && <MyComponent />
.src/languages/*
files and using the translation methodWaiting for Copy
label for a copy review on the original GH to get the correct copy.STYLE.md
) were followedAvatar
, I verified the components usingAvatar
are working as expected)/** comment above it */
this
properly so there are no scoping issues (i.e. foronClick={this.submit}
the methodthis.submit
should be bound tothis
in the constructor)this
are necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);
ifthis.submit
is never passed to a component event handler likeonClick
)StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)
)Avatar
is modified, I verified thatAvatar
is working as expected in all cases)ScrollView
component to make it scrollable when more elements are added to the page.main
branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTest
steps.Screenshots/Videos
Web
Screen.Recording.2023-09-15.at.11.55.25.mov
Mobile Web - Chrome
Screen.Recording.2023-09-15.at.12.01.03.mov
Mobile Web - Safari
Screen.Recording.2023-09-15.at.11.58.18.mov
Desktop
Screen.Recording.2023-09-15.at.11.54.23.mov
iOS
Screen.Recording.2023-09-15.at.11.56.07.mov
Android
Screen.Recording.2023-09-15.at.11.59.58.mov