-
Notifications
You must be signed in to change notification settings - Fork 266
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
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
Elements
function 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,
final
ref becomestrue
, butctx
state 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.