-
Notifications
You must be signed in to change notification settings - Fork 74
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
Feat: Call useOnyx selector for entire collection #585
Feat: Call useOnyx selector for entire collection #585
Conversation
@kacper-mikolajczak are these changes breaking for App or can this be pulled without a parallel App PR? |
Hi @mjasikowski! Yes, this PR will lead to breaking changes to Here is a PR that is going to contain all the necessary adjustments: Expensify/App#49918 I am going to add a few clean-up commits to this PR and will let you know when it is ready for review, if you don't mind. |
@kacper-mikolajczak thanks and no worries - I'd appreciate if you'd convert it to a Draft PR before it's until though. |
@mjasikowski PR is ready for review! We can also improve typings of |
Hi @kacper-mikolajczak could take a look at this diff? I basically fixed the TS typings and unified diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts
index 78fb050..24ea12d 100644
--- a/lib/useOnyx.ts
+++ b/lib/useOnyx.ts
@@ -1,11 +1,10 @@
import {deepEqual, shallowEqual} from 'fast-equals';
import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react';
-import type {IsEqual} from 'type-fest';
import OnyxCache from './OnyxCache';
import type {Connection} from './OnyxConnectionManager';
import connectionManager from './OnyxConnectionManager';
import OnyxUtils from './OnyxUtils';
-import type {CollectionKeyBase, OnyxCollection, OnyxEntry, OnyxKey, OnyxValue} from './types';
+import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types';
import useLiveRef from './useLiveRef';
import usePrevious from './usePrevious';
@@ -47,67 +46,59 @@ type UseOnyxOptions<TKey extends OnyxKey, TReturnValue> = BaseUseOnyxOptions & U
type FetchStatus = 'loading' | 'loaded';
-type SelectedValue<TKey, TValue> = TKey extends CollectionKeyBase ? OnyxCollection<TValue> : OnyxEntry<TValue>;
-
-type CachedValue<TKey extends OnyxKey, TValue> = IsEqual<TValue, OnyxValue<TKey>> extends true ? TValue : SelectedValue<TKey, TValue>;
+type CachedValue<TValue> = TValue;
type ResultMetadata = {
status: FetchStatus;
};
-type UseOnyxResult<TKey extends OnyxKey, TValue> = [CachedValue<TKey, TValue>, ResultMetadata];
+type UseOnyxResult<TValue> = [CachedValue<TValue>, ResultMetadata];
-type UseOnyxSelector<TKey extends OnyxKey, SelectorValue> = (data?: OnyxValue<TKey>) => SelectorValue;
+type UseOnyxSelector<TKey extends OnyxKey, TReturnType> = (data: OnyxValue<TKey>) => TReturnType;
/**
* Gets the cached value from the Onyx cache. If the key is a collection key, it will return all the values in the collection.
* It is a fork of `tryGetCachedValue` from `OnyxUtils` caused by different selector logic in `useOnyx`. It should be unified in the future, when `withOnyx` is removed.
*/
-function getCachedValue<TKey extends OnyxKey>(key: TKey): OnyxValue<TKey> | undefined {
- if (!OnyxUtils.isCollectionKey(key)) {
- return OnyxCache.get(key) as OnyxValue<TKey> | undefined;
- }
-
- const allCacheKeys = OnyxCache.getAllKeys();
+function getCachedValue<TKey extends OnyxKey, TValue>(key: TKey, selector?: UseOnyxSelector<TKey, TValue>): CachedValue<TValue> | undefined {
+ let val = OnyxCache.get(key);
- // It is possible we haven't loaded all keys yet so we do not know if the
- // collection actually exists.
- if (allCacheKeys.size === 0) {
- return;
- }
+ if (OnyxUtils.isCollectionKey(key)) {
+ const allCacheKeys = OnyxCache.getAllKeys();
- const values: Record<string, unknown> = {};
- allCacheKeys.forEach((cacheKey) => {
- if (!cacheKey.startsWith(key)) {
+ // It is possible we haven't loaded all keys yet so we do not know if the
+ // collection actually exists.
+ if (allCacheKeys.size === 0) {
return;
}
- values[cacheKey] = OnyxCache.get(cacheKey);
- });
-
- return values as OnyxValue<TKey>;
-}
+ const values: OnyxCollection<KeyValueMapping[TKey]> = {};
+ allCacheKeys.forEach((cacheKey) => {
+ if (!cacheKey.startsWith(key)) {
+ return;
+ }
-/**
- * Gets the value from cache and maps it with selector.
- */
-function getSelectedValue<TKey extends OnyxKey, TValue = OnyxValue<TKey> | undefined>(key: TKey, selector?: UseOnyxSelector<TKey, TValue>) {
- const value = getCachedValue(key);
+ values[cacheKey] = OnyxCache.get(cacheKey);
+ });
+ val = values;
+ }
- const selectedValue = selector ? selector(value) : value;
+ if (selector) {
+ val = selector(val as OnyxValue<TKey>);
+ }
- return selectedValue;
+ return val as CachedValue<TValue> | undefined;
}
function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
key: TKey,
options?: BaseUseOnyxOptions & UseOnyxInitialValueOption<TReturnValue> & Required<UseOnyxSelectorOption<TKey, TReturnValue>>,
-): UseOnyxResult<TKey, TReturnValue>;
+): UseOnyxResult<TReturnValue>;
function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
key: TKey,
options?: BaseUseOnyxOptions & UseOnyxInitialValueOption<NoInfer<TReturnValue>>,
-): UseOnyxResult<TKey, TReturnValue>;
-function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey, options?: UseOnyxOptions<TKey, TReturnValue>): UseOnyxResult<TKey, TReturnValue> {
+): UseOnyxResult<TReturnValue>;
+function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey, options?: UseOnyxOptions<TKey, TReturnValue>): UseOnyxResult<TReturnValue> {
const connectionRef = useRef<Connection | null>(null);
const previousKey = usePrevious(key);
@@ -116,16 +107,16 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
// Stores the previous cached value as it's necessary to compare with the new value in `getSnapshot()`.
// We initialize it to `null` to simulate that we don't have any value from cache yet.
- const previousValueRef = useRef<CachedValue<TKey, TReturnValue> | undefined | null>(null);
+ const previousValueRef = useRef<CachedValue<TReturnValue> | undefined | null>(null);
// Stores the newest cached value in order to compare with the previous one and optimize `getSnapshot()` execution.
- const newValueRef = useRef<CachedValue<TKey, TReturnValue> | undefined | null>(null);
+ const newValueRef = useRef<CachedValue<TReturnValue> | undefined | null>(null);
// Stores the previously result returned by the hook, containing the data from cache and the fetch status.
// We initialize it to `undefined` and `loading` fetch status to simulate the initial result when the hook is loading from the cache.
// However, if `initWithStoredValues` is `false` we set the fetch status to `loaded` since we want to signal that data is ready.
- const resultRef = useRef<UseOnyxResult<TKey, TReturnValue>>([
- undefined as CachedValue<TKey, TReturnValue>,
+ const resultRef = useRef<UseOnyxResult<TReturnValue>>([
+ undefined as CachedValue<TReturnValue>,
{
status: options?.initWithStoredValues === false ? 'loaded' : 'loading',
},
@@ -197,7 +188,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
// If `newValueRef.current` is `null` or any other value it means that the cache does have a value for that key.
// This difference between `undefined` and other values is crucial and it's used to address the following
// conditions and use cases.
- newValueRef.current = getSelectedValue(key, selectorRef.current) as CachedValue<TKey, TReturnValue>;
+ newValueRef.current = getCachedValue(key, selectorRef.current) as CachedValue<TReturnValue>;
// We set this flag to `false` again since we don't want to get the newest cached value every time `getSnapshot()` is executed,
// and only when `Onyx.connect()` callback is fired.
@@ -220,7 +211,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
// If data is not present in cache and `initialValue` is set during the first connection,
// we set the new value to `initialValue` and fetch status to `loaded` since we already have some data to return to the consumer.
if (isFirstConnectionRef.current && !hasCacheForKey && options?.initialValue !== undefined) {
- newValueRef.current = (options?.initialValue ?? undefined) as CachedValue<TKey, TReturnValue>;
+ newValueRef.current = (options?.initialValue ?? undefined) as CachedValue<TReturnValue>;
newFetchStatus = 'loaded';
}
@@ -242,7 +233,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
previousValueRef.current = newValueRef.current;
// If the new value is `null` we default it to `undefined` to ensure the consumer gets a consistent result from the hook.
- resultRef.current = [previousValueRef.current as CachedValue<TKey, TReturnValue>, {status: newFetchStatus ?? 'loaded'}];
+ resultRef.current = [previousValueRef.current as CachedValue<TReturnValue>, {status: newFetchStatus ?? 'loaded'}];
}
return resultRef.current;
@@ -285,7 +276,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
checkEvictableKey();
}, [checkEvictableKey]);
- const result = useSyncExternalStore<UseOnyxResult<TKey, TReturnValue>>(subscribe, getSnapshot);
+ const result = useSyncExternalStore<UseOnyxResult<TReturnValue>>(subscribe, getSnapshot);
return result;
}
|
Nice @fabioh8010! I feel like we have similar idea in mind - please take a look at changes made here a16d45f and tell me if that works for you! In the meantime I will try to merge your suggestions as 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.
Looks good for my very limited knowledge, but I'd recommend to defer to @fabioh8010 and @mountiny for proper reviews.
@hungvu193 added for review and testing |
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.
some minor comments, but it's looking great!
As of what @fabioh8010 shared internally, the return selectedValue ?? undefined; |
Thanks for all the suggestions @fabioh8010! All the changes should be applied - let me know what you think 🙏 |
a76b8d8
to
81127bf
Compare
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.
Left one question, besides LGTM 🚀 Thanks for the work and patience here!!
Wild idea as another potential improvement: What if we apply selector at the end of It should simplify the typings and also enable possibility to return Here's a diff: diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts
index e294fe9..fa8e4d4 100644
--- a/lib/useOnyx.ts
+++ b/lib/useOnyx.ts
@@ -1 +1 @@
-import {deepEqual, shallowEqual} from 'fast-equals';
+import {shallowEqual} from 'fast-equals';
@@ -123,2 +123,2 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
- const resultRef = useRef<UseOnyxResult<TReturnValue>>([
- undefined,
+ const resultRef = useRef<[TReturnValue | undefined, ResultMetadata]>([
+ selectorRef.current?.(undefined),
@@ -196 +196 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
- newValueRef.current = getCachedValue(key, selectorRef.current);
+ newValueRef.current = getCachedValue(key);
@@ -226,6 +226 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
- let areValuesEqual: boolean;
- if (selectorRef.current) {
- areValuesEqual = deepEqual(previousValueRef.current ?? undefined, newValueRef.current);
- } else {
- areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
- }
+ const areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
@@ -241 +236 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(key: TKey
- resultRef.current = [previousValueRef.current ?? undefined, {status: newFetchStatus ?? 'loaded'}];
+ resultRef.current = [selectorRef.current(previousValueRef.current ?? undefined), {status: newFetchStatus ?? 'loaded'}]; |
@kacper-mikolajczak I think this idea makes very much sense! I’m just worried about how we currently use But do you think we could work on this separately in another Proposal / Issue / PR? So we keep the scope of the current PR small and allow a quicker merge, less chance of regressions, and more time to think about the solution. WDYT? |
The last line here confuses me: - resultRef.current = [previousValueRef.current ?? undefined, {status: newFetchStatus ?? 'loaded'}]
+ resultRef.current = [selectorRef.current(previousValueRef.current ?? undefined), {status: newFetchStatus ?? 'loaded'}]; According to the current code, I assume if (selectorRef.current) {
areValuesEqual = deepEqual(previousValueRef.current ?? undefined, newValueRef.current);
} else {
areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
} So I'd agree with @fabioh8010, we should work on it in a separate issue. |
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.
LGTM. Just one NAB comment
@hungvu193 did you test the changes applied to App? Can you please do so now before we merge such change? Thanks! |
Yes. So far so good. I can verify that the selector is called once for the entire collection. I haven't found any issues so far, but let me test it carefully to make sure it won't affect any existing flows. Screen.Recording.2024-10-02.at.23.45.30.mov |
All good @mountiny |
I will give it a go and get back to you guys with separate proposal if change turns out to be positive 👍 Let's close this PR as soon as we can! |
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.
Dont want to delay this too much but also going to ask Margelo to review
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.
LGTM!
const value = tryGetCachedValue(key) as OnyxValue<TKey>; | ||
|
||
const selectedValue = selector ? selector(value) : (value as TValue); | ||
|
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.
just wondering for the future if the onyx cache could also have a cache of key + selector function, so that when the selector function remains the same, the data remains the same we don't have to rerun the selector (as selectors could be expensive to run) [maybe this should be opt in though]
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.
Thanks for review Hanno!
What came up as a next improvement while working on this issue was the idea to separate selector from useOnyx
internals entirely so it won't interfere with Onyx cache and only transform the cached data at the end of the pipeline.
It should have many benefits, but I am still working on the PoC whether it is feasible for us to implement 🤞
Going to merge this one now |
🚀Published to npm in v2.0.73 |
Details
This PR makes
useOnyx
call itsselector
once for entire collection object instead of calling selector per item.❗ As
withOnyx
is deprecated the change to its selector functionality was not implemented. In order to achieve this, separation betweenwithOnyx
anduseOnyx
selectors were introduced.Implementation steps:
Related Issues
Expensify/App#49644
Automated Tests
Manual Tests
Author Checklist
### Related Issues
section aboveTests
sectiontoggleReport
and notonIconClick
)myBool && <MyComponent />
.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
)Avatar
is modified, I verified thatAvatar
is working as expected in all cases)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
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
MacOS: Desktop