Skip to content
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: portable text editor crash #6870

Merged
merged 1 commit into from
Jun 7, 2024
Merged

Conversation

stipsan
Copy link
Member

@stipsan stipsan commented Jun 7, 2024

Description

On React 19 the Portable Text Editor sometimes ends up in an infinite render loop when there's no value yet, as demonstrated here: https://test-compiled-studio-b8mfdmi85.sanity.build/test/structure/input-standard;portable-text;pt_allTheBellsAndWhistles;7455ba88-75d5-4a77-94a4-91738fa670c5

This PR fixes it: https://test-compiled-studio-git-fix-portable-text-editor-crash.sanity.build/test/structure/input-standard;portable-text;pt_allTheBellsAndWhistles;7455ba88-75d5-4a77-94a4-91738fa670c5

The PR also contains a lot of other minor fixes related to React Compiler, improvements to how the color scheme state is passed around, adding the React Component stack to the form input error boundary etc.

What to review

There shouldn't be any infinite render recursion loops happening in React 19 anymore for Portable Text Editor, try and break it 🙌

Testing

Beyond manual testing the existing e2e testing suite should be enough.

Notes for release

Fixed React 19 causing Portable Text Editor to crash in some scenarios.

Copy link

vercel bot commented Jun 7, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
page-building-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 7, 2024 8:26am
performance-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 7, 2024 8:26am
test-compiled-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 7, 2024 8:26am
test-next-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 7, 2024 8:26am
test-studio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 7, 2024 8:26am
1 Ignored Deployment
Name Status Preview Comments Updated (UTC)
studio-workshop ⬜️ Ignored (Inspect) Visit Preview Jun 7, 2024 8:26am

@stipsan stipsan changed the base branch from refactor-use-forwarded-ref to add-input-error-boundary June 7, 2024 00:18
@stipsan stipsan force-pushed the fix-portable-text-editor-crash branch from 1865e6a to e001835 Compare June 7, 2024 00:19
Copy link
Contributor

github-actions bot commented Jun 7, 2024

No changes to documentation

Copy link
Contributor

github-actions bot commented Jun 7, 2024

Component Testing Report Updated Jun 7, 2024 8:31 AM (UTC)

File Status Duration Passed Skipped Failed
comments/CommentInput.spec.tsx ✅ Passed (Inspect) 37s 15 0 0
formBuilder/ArrayInput.spec.tsx ✅ Passed (Inspect) 6s 3 0 0
formBuilder/inputs/PortableText/Annotations.spec.tsx ✅ Passed (Inspect) 27s 6 0 0
formBuilder/inputs/PortableText/copyPaste/CopyPaste.spec.tsx ✅ Passed (Inspect) 31s 11 7 0
formBuilder/inputs/PortableText/Decorators.spec.tsx ✅ Passed (Inspect) 14s 6 0 0
formBuilder/inputs/PortableText/DisableFocusAndUnset.spec.tsx ✅ Passed (Inspect) 8s 3 0 0
formBuilder/inputs/PortableText/FocusTracking.spec.tsx ✅ Passed (Inspect) 36s 15 0 0
formBuilder/inputs/PortableText/Input.spec.tsx ✅ Passed (Inspect) 1m 16s 21 0 0
formBuilder/inputs/PortableText/ObjectBlock.spec.tsx ✅ Passed (Inspect) 1m 4s 18 0 0
formBuilder/inputs/PortableText/PresenceCursors.spec.tsx ✅ Passed (Inspect) 7s 3 9 0
formBuilder/inputs/PortableText/RangeDecoration.spec.tsx ✅ Passed (Inspect) 21s 9 0 0
formBuilder/inputs/PortableText/Styles.spec.tsx ✅ Passed (Inspect) 15s 6 0 0
formBuilder/inputs/PortableText/Toolbar.spec.tsx ✅ Passed (Inspect) 31s 12 0 0

Comment on lines +185 to +189
useEffect(() => {
console.warn(
'useColorScheme() is deprecated, use useColorSchemeValue() or useColorSchemeSetValue() instead',
)
}, [])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @deprecated TSDoc haven't been sufficient to discourage using this hook, which is why it makes sense to console.warn in useEffect to push for possible userland to refactor away 🙌

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be nice to have another boolean outside this to prevent it from being logged more than once in cases where it's used in multiple components but more warnings means more annoying which may be more motivating lol

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm in favor of only logging once when it's not actionable. I'm this case the migration is very easy to do and I'd like to encourage it as much as possible 😬

@christianhg
Copy link
Contributor

The PR also contains a lot of other minor fixes related to React Compiler, improvements to how the color scheme state is passed around, adding the React Component stack to the form input error boundary etc.

I'm curious, what was the actual culprit and what change fixed the issue?

Copy link
Contributor

@ricokahler ricokahler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amazing work. the <Code> change i’m curious about. lmk what u think!

@@ -115,6 +115,7 @@ function DocumentPaneInner(props: DocumentPaneProviderProps) {
t={t}
i18nKey="panes.document-pane.document-not-found.text"
values={{id: options.id}}
components={{Code: ({children}) => <code>{children}</code>}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this change introduced? i think the simple <code> tag (lowercase) should be supported so ideally the translation strings should be refactored instead but i may be missing something

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It crashed and complained about Code not being declared instead of showing the error. Then I saw a commit from @rexxars that fixed the same error but from a different location. Hang on let's see if I can find it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

@ricokahler ricokahler Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh hmm. I was referencing #5114

It's a bit tedious to pass in components like <Code> that just maps to <code>. This PR allows using this predefined list of tags without specifying it directly: <abbr>, <address>, <cite>, <code>, <del>, <em>, <ins>, <kbd>, <q>, <samp>, <strong>, <sub>, <sup>.

the suggestion is to change any translation string to remove <Code> in favor of just <code> instead of adding support for <Code> but maybe we don't have control over those translation strings?

nbd though

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, I have to admit I'm not too familiar with how our i18n works yet. All I know is that adding this line allowed the 404 to render again, instead of crashing the Structure Tool and propagating to the StudioErrorBoundary that wraps the tool layout :D

Comment on lines +185 to +189
useEffect(() => {
console.warn(
'useColorScheme() is deprecated, use useColorSchemeValue() or useColorSchemeSetValue() instead',
)
}, [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be nice to have another boolean outside this to prevent it from being logged more than once in cases where it's used in multiple components but more warnings means more annoying which may be more motivating lol

const setValue = useContext(ColorSchemeSetValueContext)
if (setValue === null) throw new Error('Could not find `ColorSchemeSetValueContext` context')
return setValue
}

/** @internal */
export function _useColorSchemeInternalValue(): StudioThemeColorSchemeKey {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think this hook needs to be exported? would refactoring it to not be exported be better? it seems like it was prefaced with an _ to prevent external usage but not exposing it does the same job

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to do that in a quick follow-up! It's only needed internally so it doesn't need to be available on import 'sanity'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw the underscore caused the React Compiler to not see it as a hook, causing hook re-ordering errors, which is why it was changed here 🙌

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know 💡

@@ -38,8 +42,8 @@ export function FormBuilderInputErrorBoundary(
* when there are no errors.
* @internal
*/
function ErrorCard(props: {error: unknown; onRetry: () => void}) {
const {error, onRetry} = props
function ErrorCard(props: {error: unknown; info?: React.ErrorInfo; onRetry: () => void}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just double checking but is React.ErrorInfo available in the react 18 types as well?

Copy link
Member Author

@stipsan stipsan Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup! We don't use React 19 types yet, and we might not do so before we drop 18 support, depending on wether it'll be possible to use both (framer-motion tried but couldn't get it to work, we'll see once 19 is stable and the new typings are available on @types/reactinstead of the temporarytypes-react@rc`) 🙌

@@ -22,14 +23,17 @@ export function FormBuilderInputErrorBoundary(
props: FormBuilderInputErrorBoundaryProps,
): JSX.Element {
const {children} = props
const [{error}, setError] = useState<{error: unknown}>({error: null})
const handleRetry = useCallback(() => setError({error: null}), [])
const [{error, info}, setError] = useState<{error: unknown; info: React.ErrorInfo}>({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stylistic comment: since you made info optional in the ErrorCard, would it be better for info here to be optional as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typings from ErrorCard in @sanity/ui claims that it's always provided, so I follow that assumption here to ensure default values and reset values fulfill that contract.

It's optional in the component as a defensive measure, to ensure that should the component in @sanity/ui somehow return null or undefined then the ErrorCard makes sure to account for it. I don't think it's likely to happen, but given that this component is supposed to handle unhandled runtime exceptions it felt reasonable to follow Murphy's Law ☺️

</Stack>
</Box>
)}
{isDev && componentStack && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really nice dev quality of life improvement here 💖

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice refactor!

@stipsan
Copy link
Member Author

stipsan commented Jun 7, 2024

The PR also contains a lot of other minor fixes related to React Compiler, improvements to how the color scheme state is passed around, adding the React Component stack to the form input error boundary etc.

I'm curious, what was the actual culprit and what change fixed the issue?

Responded to this in depth on slack, the TL;DR is that writing to the ref in usePortableTextMemberItemsForProps during every render makes the return value of the hook, which is used in a long trail of hooks and components side effects as a dependency, unstable.
In React 18 it makes PTE slower than it needs to be.
In React 19 Strict Mode it can create infinite render loops and crash, but it's difficult to consistently reproduce as it's caused by race conditions and multiple other side effects happening at the same time.
In React 19 with React Compiler it's causing a very consistent crash since the way parent components are optimized cause certain deps to change on every render, causing side effects that end up scheduling another render, to fire and round and round we go.

@stipsan stipsan added this pull request to the merge queue Jun 7, 2024
Merged via the queue into next with commit 293e7d1 Jun 7, 2024
42 of 43 checks passed
@stipsan stipsan deleted the fix-portable-text-editor-crash branch June 7, 2024 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants