- 
                Notifications
    
You must be signed in to change notification settings  - Fork 307
 
Fix Elements initialization in React Strict/Concurrent mode #93
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 Elements initialization in React Strict/Concurrent mode #93
Conversation
        
          
                src/components/Elements.tsx
              
                Outdated
          
        
      | } | ||
| const maybeStripe = usePromiseResolver(rawStripe) | ||
| const stripe = validateStripe(maybeStripe) | ||
| const [ctx, setContext] = React.useState<ElementsContextValue>(() => createElementsContext(null)); | 
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.
Now it's also possible to initialize context value on mount
        
          
                src/components/Elements.tsx
              
                Outdated
          
        
      | const prevOptions = usePrevious(options); | ||
| if (prevStripe !== null) { | ||
| if (prevStripe !== rawStripeProp) { | ||
| const [inputs, setInputs] = React.useState({ rawStripe: rawStripeProp, options: optionsProp }) | 
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 should be more cleaner as we have state which we can trust (never changed after transition from null value).
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.
Hey @khmm12! Thanks for this. Having React Stripe.js work with Strict Mode would be great.
I left some feedback on the PR. My comments are either marked with "blocking", which marks feedback I think we must address before merging, or with "nit", which marks minor stylistic suggestions that you should feel free to ignore if you disagree with.
In addition to my inline comments, I have one more blocking comment: given that the general idea of these changes is to make React Stripe.js work with Strict Mode, could you add a test to that effect? For example, showing that a basic React Stripe.js integration doesn't error if it's wrapped with <React.StrictMode>.
        
          
                src/components/Elements.tsx
              
                Outdated
          
        
      | if (prevStripe !== null) { | ||
| if (prevStripe !== rawStripeProp) { | ||
| const [inputs, setInputs] = React.useState({ rawStripe: rawStripeProp, options: optionsProp }) | ||
| const { rawStripe, options } = inputs | 
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.
nit: I find it confusing to remember the difference between rawStripe/rawStripeProp, options/optionsProp etc. How about we:
- Remove the destructuring of the 
Elementsfunction parameter and just name that parameterprops - Rename the 
[inputs, setInputs]state variables to[savedProps, setSavedProps] - Refer to the various values everywhere as 
props.stripe/savedProps.stripe,props.options/savedProps.options, etc. 
What do you think? Does that seem clearer?
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.
Totally agree with you! I thought it was so customary 😁.  But IMO it's better to name inputs, because it's some kind of input to construct context and if it's named props it should contain children as well.
        
          
                src/components/Elements.tsx
              
                Outdated
          
        
      | } | ||
| const maybeStripe = usePromiseResolver(rawStripe) | ||
| const stripe = validateStripe(maybeStripe) | ||
| const [ctx, setContext] = React.useState<ElementsContextValue>(() => createElementsContext(null)); | 
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.
blocking: I think this will cause a subtle change in behavior that may break existing integrations. Consider someone using React Stripe.js like this:
// Synchronously create `Stripe` instance at module level
const stripe = Stripe('pk_123')
const CheckoutPage = () => {
  // Pass already instantiated `Stripe` instance to `Elements` provider
  return (
    <Elements stripe={stripe}>
      <CheckoutForm />
    </Elements>
  )
}
const CheckoutForm = () => {
  // Integration assumes `stripe` is not `null` on first render
  const stripe = useStripe();
  const [paymentRequest] = useState(() => stripe.paymentRequest(options))
  // ...etc.
}This integration expects that if a valid Stripe instance is passed to an Elements provider, that Stripe instance is available via useStripe in a child component when the child component first renders. With the current behavior in this PR, the child component will be rendered twice and useStripe will return null during the first render.
Here's a failing test for this:
it('exposes a passed Stripe instance immediately to children calling useStripe', () => {
  const TestChild = () => {
    const stripe = useStripe();
    if (stripe === null) {
      throw new Error('TestChild rendered with null stripe instance');
    }
    return null;
  };
  expect(() => {
    mount(
      <Elements stripe={stripe}>
        <TestChild />
      </Elements>
    );
  }).not.toThrow();
});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 also thought about this. But declined by 2 reasons:
- The interface defines nullable values.
 - The existing implementation also doesn't guarantee stripe instance on mount. As no one guarantees that the state will be updated before the first render of children ;-)
 
Anyway it's easy to add.
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.
Confirming - the existing code will provide empty values on the first render as well.
        
          
                src/utils/usePromiseResolver.ts
              
                Outdated
          
        
      | import React from 'react'; | ||
| import {isPromise} from '../utils/guards'; | ||
| 
               | 
          ||
| export const usePromiseResolver = <T>(mayBePromise: T | PromiseLike<T>): T | null => { | 
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.
nit: in the return value of this hook, there's no way to distinguish between a promise that resolved with null and a promise that has not yet resolved. It doesn't matter based on how this hook has been used in the Elements provider above, but I worry that this will be confusing in the future since it seems like this hook intends to be a general purpose utility.
How about changing the signature of this hook to be something like
<T>(maybePromise: T | PromiseLike<T>) => {settled: false} | {settled: true, value: T}
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 can go deeper and provide full promise state ;-)
        
          
                src/utils/usePromiseResolver.ts
              
                Outdated
          
        
      | setResolved(null) | ||
| mayBePromise.then(resolvedValue => { | ||
| if (isMounted) setResolved(resolvedValue) | ||
| }, () => undefined) | 
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.
question: why add () => undefined as the rejection handler? Don't we want to know if a promise is rejected?
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 depends on the hook purpose. If it's generic purpose hook, it should provide, otherwise in case of what provider does, it doesn't make sense as provider doesn't do anything with a rejection.
58fad83    to
    73bfa3d      
    Compare
  
    | 
           Hey @christopher-stripe! Thank for your feedback! I left some updates based on it. 
 This is the first thing I did before changing anything 😁.But the main problem seems enzyme doesn't work strict mode. It doesn't update react context.  | 
    
          
 I felt it was important to have some tests like this, so I went ahead and updated the whole suite to use React Testing Library instead of Enzyme (#97). Could you merge or rebase against  This might cause some gnarly merge conflicts, so sorry for that. If you want me to take over this PR and handle getting it merged, I would be happy to—just let me know!  | 
    
732b846    to
    581499c      
    Compare
  
    | 
           Hey @christopher-stripe 👋 
 Good choice! Indeed nowadays react-testing-library is more popular than enzyme. 
 I enabled 1 from 2 tests and added one more (you can try it against the master). 
 
  | 
    
bd35e6d    to
    1648083      
    Compare
  
    | 
          
 If you have any concerns regarding to elements, it can be postponed to next major version.  | 
    
Refs cannot be mutated and used to update state in the same time in rendering phase. As this is side-effect, it can produce various bugs in concurrent mode. In StrictMode Elements doesn't do transition from null to valid stripe instance. As in StrictMode React renders twice, `final` ref becomes `true`, but `ctx` state isn't changed. References: https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects facebook/react#18003 facebook/react#18545
1648083    to
    fb49dd3      
    Compare
  
    | 
           Thanks @khmm12. I'm slowly realizing how hard it would be to preserve the existing behavior while also supporting Strict Mode. I talked this over with some of my colleagues at Stripe, and we feel that: 
 So we're planning to hold off Strict Mode support until we have other changes that would warrant a major release. I'm going to close this PR for now. In any event, it's been very helpful to see exactly what it would take to support Strict Mode. This will be a helpful foundation to build off in the not-so-distant future when we revisit Strict Mode support.  | 
    
          
  | 
    
| 
           Hey @christopher-stripe! I still don't understand why we can't merge the PR now, because we can revert effect-safe elements initialization (fb49dd3) and deliver it in major version later. Without it doesn't change the existing behaviour. And even more it's more spec compliant than the current implementation. 
 Simply to revert effect-safe elements initialization.  | 
    
Summary & motivation
Refs cannot be mutated and used to update state in the same time in rendering phase. As this is side-effect, it can produce various bugs in concurrent mode.
In StrictMode Elements doesn't do transition from null to stripe instance. As in StrictMode React renders twice,
finalref becomestrue, butctxstate isn't changed.References:
https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects
facebook/react#18003
facebook/react#18545
Testing & documentation
Tests are passed and now it works in a test project with StrictMode.