|
| 1 | +// Garde-fou contre le crash du perf-track dev de React 19.2 avec les sections |
| 2 | +// react-aria : `addObjectDiffToProperties` évalue un getter (`childNodes`) qui |
| 3 | +// lève une exception sur les nœuds de collection, gelant le composant à la |
| 4 | +// sélection / frappe. Ce comportement est neutralisé par le patch |
| 5 | +// `patches/react-dom@<version>.patch`. Ce test reproduit la MÊME structure que |
| 6 | +// ComboBox.tsx (AriaComboBox + Virtualizer + Collection de sections) et échoue |
| 7 | +// si le patch n'est plus appliqué (typiquement après un bump de react-dom dont |
| 8 | +// la clé `patchedDependencies` n'a pas suivi). |
| 9 | +import './process-env-shim'; |
| 10 | +import { suite, test, expect, beforeEach, afterEach } from 'vitest'; |
| 11 | +import { userEvent, page } from '@vitest/browser/context'; |
| 12 | +import { useState } from 'react'; |
| 13 | +import { createRoot, type Root } from 'react-dom/client'; |
| 14 | +import { |
| 15 | + ComboBox as AriaComboBox, |
| 16 | + ListBox, |
| 17 | + ListBoxItem, |
| 18 | + ListBoxSection, |
| 19 | + Header, |
| 20 | + Popover, |
| 21 | + Input, |
| 22 | + Button, |
| 23 | + Collection, |
| 24 | + Virtualizer, |
| 25 | + ListLayout |
| 26 | +} from 'react-aria-components'; |
| 27 | + |
| 28 | +type Item = { label: string; value: string }; |
| 29 | +type Section = { label: string; items: Item[] }; |
| 30 | + |
| 31 | +function buildSections(): Section[] { |
| 32 | + return [ |
| 33 | + { |
| 34 | + label: 'Préfectures', |
| 35 | + items: [ |
| 36 | + { label: 'Préfecture de Paris', value: '75' }, |
| 37 | + { label: 'Préfecture du Rhône', value: '69' } |
| 38 | + ] |
| 39 | + }, |
| 40 | + { |
| 41 | + label: 'Ministères', |
| 42 | + items: [ |
| 43 | + { label: "Ministère de l'Intérieur", value: 'interieur' }, |
| 44 | + { label: 'Ministère de la Justice', value: 'justice' } |
| 45 | + ] |
| 46 | + } |
| 47 | + ]; |
| 48 | +} |
| 49 | + |
| 50 | +function ComboBoxWithSections() { |
| 51 | + const [selectedKey, setSelectedKey] = useState<string | null>(null); |
| 52 | + const sections = buildSections(); |
| 53 | + |
| 54 | + return ( |
| 55 | + <AriaComboBox |
| 56 | + aria-label="Démarches" |
| 57 | + menuTrigger="focus" |
| 58 | + selectedKey={selectedKey} |
| 59 | + onSelectionChange={(key) => |
| 60 | + setSelectedKey(key == null ? null : String(key)) |
| 61 | + } |
| 62 | + shouldFocusWrap |
| 63 | + > |
| 64 | + <Input aria-label="Démarches" /> |
| 65 | + <Button>open</Button> |
| 66 | + <Popover> |
| 67 | + <Virtualizer layout={ListLayout}> |
| 68 | + <ListBox style={{ height: 300, width: 300 }}> |
| 69 | + <Collection items={sections}> |
| 70 | + {(section) => ( |
| 71 | + <ListBoxSection id={section.label}> |
| 72 | + <Header>{section.label}</Header> |
| 73 | + <Collection items={section.items}> |
| 74 | + {(item) => ( |
| 75 | + <ListBoxItem id={item.value}>{item.label}</ListBoxItem> |
| 76 | + )} |
| 77 | + </Collection> |
| 78 | + </ListBoxSection> |
| 79 | + )} |
| 80 | + </Collection> |
| 81 | + </ListBox> |
| 82 | + </Virtualizer> |
| 83 | + </Popover> |
| 84 | + </AriaComboBox> |
| 85 | + ); |
| 86 | +} |
| 87 | + |
| 88 | +suite('ComboBox sections (React 19 perf-track)', () => { |
| 89 | + let container: HTMLDivElement; |
| 90 | + let root: Root; |
| 91 | + const errors: unknown[] = []; |
| 92 | + const onError = (e: ErrorEvent) => errors.push(e.error ?? e.message); |
| 93 | + |
| 94 | + beforeEach(() => { |
| 95 | + errors.length = 0; |
| 96 | + window.addEventListener('error', onError); |
| 97 | + container = document.createElement('div'); |
| 98 | + document.body.appendChild(container); |
| 99 | + root = createRoot(container); |
| 100 | + }); |
| 101 | + |
| 102 | + afterEach(() => { |
| 103 | + window.removeEventListener('error', onError); |
| 104 | + root.unmount(); |
| 105 | + container.remove(); |
| 106 | + }); |
| 107 | + |
| 108 | + test('typing in a sectioned combobox does not freeze the component', async () => { |
| 109 | + root.render(<ComboBoxWithSections />); |
| 110 | + |
| 111 | + const input = page.getByRole('combobox', { name: 'Démarches' }); |
| 112 | + await userEvent.click(input); |
| 113 | + |
| 114 | + // La frappe filtre la collection des sections, donc déclenche un re-render |
| 115 | + // que le perf-track dev de React diffe (et donc l'évaluation des getters). |
| 116 | + await userEvent.type(input, 'Rhô'); |
| 117 | + |
| 118 | + // laisser passer les passive effects (le perf-track tourne après le commit) |
| 119 | + await new Promise((resolve) => setTimeout(resolve, 300)); |
| 120 | + |
| 121 | + // sans le patch : crash « childNodes is not supported » → input gelé sur « R » |
| 122 | + await expect.element(input).toHaveValue('Rhô'); |
| 123 | + expect( |
| 124 | + errors, |
| 125 | + `erreurs non capturées: ${errors.map(String).join('; ')}` |
| 126 | + ).toEqual([]); |
| 127 | + }); |
| 128 | +}); |
0 commit comments