Expensify / react-native-onyx

Persistent, offline-first state management solution for React Native. Easy to use with minimal config and boilerplate.
MIT License
151 stars 71 forks source link

fix: connection could be called twice on set #570

Closed hannojg closed 1 month ago

hannojg commented 1 month ago

Details

This PR fixes cases where a call to Onyx.set could invoke a Onyx.connect listener twice. For withOnyx connections this isn't happening, as they are already guarded by previous equal comparisons.

The fix here is to add a map that will store all previous values that were send to the Onyx.connect callback. We can't reuse the cache here unfortunately, as it can contain values while no data has been send to the connection yet (and thus a comparison would lead to us not sending to the connection.

For useOnyx I had to add a special parameter to Onyx.connect called alwaysNotify which is false by default. useOnyx might uses a selector to transform the data. The output of that selector can change, even though the input data stay the same. Thus for useOnyx we always have to call the connect callback.

Related Issues

https://github.com/Expensify/react-native-onyx/issues/569

Automated Tests

Manual Tests

n/a

Author Checklist

Screenshots/Videos

Note: pressing the send button might be buggy in the recordings, its a know issue first reported here and is not related to this PR.

Android: Native https://github.com/user-attachments/assets/a3ca2efc-78a4-4c7f-8b92-4f00b6fe7fa7
Android: mWeb Chrome https://github.com/user-attachments/assets/1d83dd10-3e12-41a2-b885-ad61120f2a92
iOS: Native https://github.com/user-attachments/assets/07a76549-87ac-42b7-a65c-eb4f6148c9ce
iOS: mWeb Safari https://github.com/user-attachments/assets/3e962c75-ab38-44cb-b78b-a78adebcfca4
MacOS: Chrome / Safari https://github.com/user-attachments/assets/b031aa42-2241-4628-aa9f-d77032f10bf9
MacOS: Desktop https://github.com/user-attachments/assets/50ad6d61-170b-4b5a-af9f-414aa32de967
ZhenjaHorbach commented 1 month ago

Reviewer Checklist

Screenshots/Videos

Android: Native https://github.com/user-attachments/assets/6a610dba-b40b-4f62-a10e-3531a63fb03e
Android: mWeb Chrome https://github.com/user-attachments/assets/bc666f6c-0e20-4302-8599-09b1341ff33c
iOS: Native https://github.com/user-attachments/assets/6f5c9214-1710-4bce-8331-99c39999a5de
iOS: mWeb Safari https://github.com/user-attachments/assets/8842e9f7-18c3-4494-b245-fa1a1fcee907
MacOS: Chrome / Safari https://github.com/user-attachments/assets/a00916c7-72aa-4e8f-ab2f-fe5ac98d3a72
MacOS: Desktop https://github.com/user-attachments/assets/79d39151-275b-4178-88ed-e8627e30ef34
hannojg commented 1 month ago

hm, lets wait one moment before we continue, want to ask Fabio one thing who wrote the useOnxy hook. Maybe we don't need the alwaysNotify prop (conversation here)

ZhenjaHorbach commented 1 month ago

The changes look good ! I didn't find any regressions So we are waiting for information about alwaysNotify and tests (the slack thread)

hannojg commented 1 month ago

Yes, thanks for being patient with me! I figured that the alwaysNotify isn't needed.

There was this test that failed with the changes I made:

Screenshot 2024-07-23 at 17 46 23

The thing is that the test case was a bit broken. In the test we change the selector function reference. In a react application that will only happen when the hook gets called again aka. the component re-renders (if we follow all strict rules, which we do). The test was working before this change only because the Onyx.connect callback was called another time (which will cause a rerender in useOnyx), which is what this PR fixes:

Screenshot 2024-07-23 at 17 52 08

So the solution was to fix the test by simulating how in a regular react app the selector ref would change - with a rerender

ZhenjaHorbach commented 1 month ago

@hannojg Thanks for the detailed explanation ! Fix for tests makes sense to me ! LGTM

hannojg commented 1 month ago

@mountiny I think we are good to merge!

github-actions[bot] commented 1 month ago

🚀Published to npm in v2.0.58

fabioh8010 commented 1 month ago

@hannojg I noticed that these changes are causing some tests in E/App to fail. Is this expected?

Summary of all failing tests
 FAIL  tests/unit/LocalizeTests.ts
  ● localize › formatList › formatList([ 'rory', 'vit' ])

    expect(received).toBe(expected) // Object.is equality

    Expected: "rory y vit"
    Received: "rory and vit"

      55 |         ])('formatList(%s)', (input, {[CONST.LOCALES.DEFAULT]: expectedOutput, [CONST.LOCALES.ES]: expectedOutputES}) => {
      56 |             expect(Localize.formatList(input)).toBe(expectedOutput);
    > 57 |             return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(Localize.formatList(input)).toBe(expectedOutputES));
         |                                                                                                                            ^
      58 |         });
      59 |     });
      60 | });

      at toBe (tests/unit/LocalizeTests.ts:57:124)

  ● localize › formatList › formatList([ 'rory', 'vit', 'jules' ])

    expect(received).toBe(expected) // Object.is equality

    Expected: "rory, vit y jules"
    Received: "rory, vit, and jules"

      55 |         ])('formatList(%s)', (input, {[CONST.LOCALES.DEFAULT]: expectedOutput, [CONST.LOCALES.ES]: expectedOutputES}) => {
      56 |             expect(Localize.formatList(input)).toBe(expectedOutput);
    > 57 |             return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(Localize.formatList(input)).toBe(expectedOutputES));
         |                                                                                                                            ^
      58 |         });
      59 |     });
      60 | });

      at toBe (tests/unit/LocalizeTests.ts:57:124)

  ● localize › formatList › formatList([ 'rory', 'vit', 'ionatan' ])

    expect(received).toBe(expected) // Object.is equality

    Expected: "rory, vit e ionatan"
    Received: "rory, vit, and ionatan"

      55 |         ])('formatList(%s)', (input, {[CONST.LOCALES.DEFAULT]: expectedOutput, [CONST.LOCALES.ES]: expectedOutputES}) => {
      56 |             expect(Localize.formatList(input)).toBe(expectedOutput);
    > 57 |             return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(Localize.formatList(input)).toBe(expectedOutputES));
         |                                                                                                                            ^
      58 |         });
      59 |     });
      60 | });

      at toBe (tests/unit/LocalizeTests.ts:57:124)

 FAIL  tests/unit/CurrencyUtilsTest.ts (5.087 s)
  ● CurrencyUtils › isCurrencySymbolLTR › Returns false for preferredLocale es and currencyCode USD

    expect(received).toBe(expected) // Object.is equality

    Expected: false
    Received: true

      53 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, locale).then(() => {
      54 |                 const isSymbolLeft = CurrencyUtils.isCurrencySymbolLTR(currencyCode);
    > 55 |                 expect(isSymbolLeft).toBe(isLeft);
         |                                      ^
      56 |             }),
      57 |         );
      58 |     });

      at toBe (tests/unit/CurrencyUtilsTest.ts:55:38)

  ● CurrencyUtils › convertToDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "0,25 €"
    Received: "€0.25"

      168 |             ['EUR', 250000000, '2.500.000,00\xa0€'],
      169 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 170 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                 ^
      171 |         );
      172 |     });
      173 |

      at toBe (tests/unit/CurrencyUtilsTest.ts:170:145)

  ● CurrencyUtils › convertToDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "25,00 €"
    Received: "€25.00"

      168 |             ['EUR', 250000000, '2.500.000,00\xa0€'],
      169 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 170 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                 ^
      171 |         );
      172 |     });
      173 |

      at toBe (tests/unit/CurrencyUtilsTest.ts:170:145)

  ● CurrencyUtils › convertToDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "2500,00 €"
    Received: "€2,500.00"

      168 |             ['EUR', 250000000, '2.500.000,00\xa0€'],
      169 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 170 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                 ^
      171 |         );
      172 |     });
      173 |

      at toBe (tests/unit/CurrencyUtilsTest.ts:170:145)

  ● CurrencyUtils › convertToDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "2.500.000,00 €"
    Received: "€2,500,000.00"

      168 |             ['EUR', 250000000, '2.500.000,00\xa0€'],
      169 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 170 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                 ^
      171 |         );
      172 |     });
      173 |

      at toBe (tests/unit/CurrencyUtilsTest.ts:170:145)

  ● CurrencyUtils › convertToShortDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "0 €"
    Received: "€0"

      195 |             ['EUR', 250000000, '2.500.000\xa0€'],
      196 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 197 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToShortDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                      ^
      198 |         );
      199 |     });
      200 | });

      at toBe (tests/unit/CurrencyUtilsTest.ts:197:150)

  ● CurrencyUtils › convertToShortDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "25 €"
    Received: "€25"

      195 |             ['EUR', 250000000, '2.500.000\xa0€'],
      196 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 197 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToShortDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                      ^
      198 |         );
      199 |     });
      200 | });

      at toBe (tests/unit/CurrencyUtilsTest.ts:197:150)

  ● CurrencyUtils › convertToShortDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "2500 €"
    Received: "€2,500"

      195 |             ['EUR', 250000000, '2.500.000\xa0€'],
      196 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 197 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToShortDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                      ^
      198 |         );
      199 |     });
      200 | });

      at toBe (tests/unit/CurrencyUtilsTest.ts:197:150)

  ● CurrencyUtils › convertToShortDisplayString › Correctly displays EUR in ES locale

    expect(received).toBe(expected) // Object.is equality

    Expected: "2.500.000 €"
    Received: "€2,500,000"

      195 |             ['EUR', 250000000, '2.500.000\xa0€'],
      196 |         ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) =>
    > 197 |             Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(CurrencyUtils.convertToShortDisplayString(amount, currency)).toBe(expectedResult)),
          |                                                                                                                                                      ^
      198 |         );
      199 |     });
      200 | });

      at toBe (tests/unit/CurrencyUtilsTest.ts:197:150)

Test Suites: 2 failed, 2 skipped, 86 passed, 88 of 90 total
Tests:       12 failed, 154 skipped, 928 passed, 1094 total
Snapshots:   1 passed, 1 total
Time:        22.677 s
Ran all test suites.
hannojg commented 1 month ago

They are failing due to another bug in Onyx for which I have a PR to fix here: