diff --git a/.changeset/calendar-react-day-picker-react-19.md b/.changeset/calendar-react-day-picker-react-19.md new file mode 100644 index 0000000000..edb32b0160 --- /dev/null +++ b/.changeset/calendar-react-day-picker-react-19.md @@ -0,0 +1,12 @@ +--- +'@toptal/picasso-calendar': patch +'@toptal/picasso': patch +--- + +### Calendar + +- upgrade `react-day-picker` to `^8.10.2`, the first 8.x release that runs under React 19: it admits React 19 in its peer range and imports `react/jsx-runtime` instead of inlining a copy that read React 18 internals. No `Calendar` API or behavior change; it renders identically under React 18 + +### Picasso + +- remove the unused direct `react-day-picker` dependency from `@toptal/picasso`; nothing under the aggregate imports it, and it stays installed transitively via `@toptal/picasso-calendar` diff --git a/.changeset/charts-recharts-react-19.md b/.changeset/charts-recharts-react-19.md new file mode 100644 index 0000000000..de3cec6d67 --- /dev/null +++ b/.changeset/charts-recharts-react-19.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso-charts': patch +--- + +### Charts + +- upgrade `recharts` to `^2.15.4`, the first release that runs under React 19: it admits React 19 in its peer range and no longer relies on `defaultProps` for `XAxis`, `YAxis`, `ReferenceArea` and `ReferenceLine`, which React 19 stops applying on function components. No `LineChart` or `BarChart` API or behavior change diff --git a/.changeset/collapse-tailwind-migration.md b/.changeset/collapse-tailwind-migration.md new file mode 100644 index 0000000000..4ff157ae96 --- /dev/null +++ b/.changeset/collapse-tailwind-migration.md @@ -0,0 +1,12 @@ +--- +'@toptal/picasso-collapse': minor +--- + +### Collapse + +- drop the react-transition-group dependency, whose `findDOMNode` path React 19 removes, and reimplement on the shared timer-driven `useTransitionStatus` hook. Public props are unchanged +- the expand and collapse animations start about 50 ms sooner: the height handoff runs on the next frame instead of after an internal delay +- the object form of `timeout` now sets the CSS transition duration correctly per direction, including a distinct `appear` duration for the mount transition (previously it produced an invalid inline value) +- unrecognized props (including `data-private`) now reach the rendered DOM node — previously they were silently dropped +- add the `CollapseProps` type export and deprecate the misnamed `FadeProps` re-export (kept as an alias, so no action is required yet) +- prop descriptions are corrected (`children`, `appear` and `onEnter` were stale or wrong) and promoted to JSDoc, so they surface in IDE tooltips and generated docs diff --git a/.changeset/date-fns-v4-upgrade.md b/.changeset/date-fns-v4-upgrade.md new file mode 100644 index 0000000000..c2dbafe117 --- /dev/null +++ b/.changeset/date-fns-v4-upgrade.md @@ -0,0 +1,24 @@ +--- +'@toptal/picasso-calendar': major +'@toptal/picasso-date-picker': major +'@toptal/picasso': major +--- + +### Calendar + +- **consumer action** (the reason for the major): `@toptal/picasso-calendar` now installs `date-fns@^4`, while its `react-day-picker@8` dependency still declares a `date-fns ^2 || ^3` peer that no 8.x release widens. npm 7+ and yarn consumers, direct or via `@toptal/picasso`, hit an `ERESOLVE` peer conflict on install until they add an override for `react-day-picker > date-fns` or install with `--legacy-peer-deps`. Runtime is unaffected: react-day-picker reaches date-fns only through named barrel imports, and all 31 functions it uses are unchanged in v4. The peer disappears with the react-day-picker v9 migration (PF-2297) +- upgrade `date-fns` to `^4.1.0`, the range `@base-ui/react` peers +- import `isWeekend` and `isSameMonth` from the `date-fns` barrel; date-fns v3 removed the default export from every submodule, so the old form resolves to `undefined` + +### DatePicker + +- upgrade `date-fns` to `^4.1.0` and `date-fns-tz` to `^3.2.0`, the first `date-fns-tz` line whose peer range admits date-fns v3 and v4 +- rename `utcToZonedTime` to `toZonedTime`, which is what `date-fns-tz@3` calls it. This is internal to `timezoneConvert`; the `timezone` prop behaves exactly as before +- import `parse`, `isValid`, `format`, `isWithinInterval`, `isEqual`, `isBefore` and `isAfter` from the `date-fns` barrel instead of per-function submodules, for the same reason as Calendar +- one absorbed upstream semantic: date-fns v3+ `isWithinInterval` normalizes reversed intervals instead of throwing, so a `DatePicker` given `minDate > maxDate` no longer crashes and treats the interval as swapped + +### Picasso + +- major for `@toptal/picasso-date-picker` and the `@toptal/picasso` aggregate alongside Calendar: both install `@toptal/picasso-calendar`, so the same install-time peer conflict reaches their consumers, with the same resolution + +No other API or behavior change in any package. diff --git a/.changeset/emoji-mart-react-19-support.md b/.changeset/emoji-mart-react-19-support.md new file mode 100644 index 0000000000..de01da38e9 --- /dev/null +++ b/.changeset/emoji-mart-react-19-support.md @@ -0,0 +1,8 @@ +--- +'@toptal/picasso-rich-text-editor': patch +--- + +### RichTextEditor + +- drop the `@emoji-mart/react` dependency and render emoji-mart's `Picker` through a local component instead. `@emoji-mart/react` has not been published since January 2023 and its peer range excludes React 19, so no upgrade could unblock React 19; the wrapper was around 20 lines and used no API React 19 removes. `emoji-mart` and `@emoji-mart/data` were already direct dependencies, so nothing is added to the dependency tree +- the local component pushes prop updates into the picker from an effect instead of during render, as the upstream wrapper did diff --git a/.changeset/final-form-v5-upgrade.md b/.changeset/final-form-v5-upgrade.md new file mode 100644 index 0000000000..ad2c793d86 --- /dev/null +++ b/.changeset/final-form-v5-upgrade.md @@ -0,0 +1,12 @@ +--- +'@toptal/picasso-forms': major +--- + +### Form + +- upgrade the final-form ecosystem to the versions that support React 19: `final-form@^5.0.1`, `final-form-arrays@^4.0.1`, `react-final-form@^7.0.1`, `react-final-form-arrays@^5.0.0` and `react-final-form-listeners@^3.0.1`. The majors are upstream's Flow-to-TypeScript rewrite; the re-exported `FieldArray`, `useFieldArray`, `OnChange`, `OnFocus`, `OnBlur` and `ExternallyChanged` surfaces are unchanged +- **consumer action**: `react-final-form` is a regular dependency of this package and its React context is instance-bound, so apps that also depend on `react-final-form` directly must move to `react-final-form@^7` / `final-form@^5` in the same upgrade. Staying on `^6` yields two library instances, and `useForm`/`useField` used inside a Picasso `
` then throws `"... must be used inside of a component"` +- `react-final-form@7` derives checkbox/radio `checked` from `parse` instead of `format` — consumers passing `format`/`parse` to `Form.Checkbox` or `Form.Radio` should re-verify checked-state behavior. Hidden checkbox inputs now render `value=""`, so consumer snapshots covering `Form.Checkbox` need a one-time regeneration +- fields register in an effect under `react-final-form@7` and render twice on mount (previously once) — visually inert, but render-count assertions in consumer tests will shift +- `react-final-form-listeners@3.0.1` ships a broken `types` path, corrected by a pnpm patch +- drop the `@types/react-final-form-listeners` dev dependency — the package now ships its own types diff --git a/.changeset/notistack-react-19-support.md b/.changeset/notistack-react-19-support.md new file mode 100644 index 0000000000..12365c2dc1 --- /dev/null +++ b/.changeset/notistack-react-19-support.md @@ -0,0 +1,11 @@ +--- +'@toptal/picasso': minor +'@toptal/picasso-shared': minor +'@toptal/picasso-provider': patch +'@toptal/picasso-notification': patch +--- + +### Notification + +- upgrade `notistack` from `3.0.1` to `3.0.2`, the first release whose peer range admits React 19. It ships no code changes, so notification behavior and appearance are unchanged +- **consumer action** for `@toptal/picasso` and `@toptal/picasso-shared`: `notistack` is a pinned peer dependency, so consumers must move to `notistack@3.0.2`. The pin stays exact to guarantee a single notistack instance, since `SnackbarProvider` and `useSnackbar` communicate through React context and would not find each other across two copies diff --git a/.changeset/provider-favicon-react-19-head.md b/.changeset/provider-favicon-react-19-head.md new file mode 100644 index 0000000000..038fc3c8d3 --- /dev/null +++ b/.changeset/provider-favicon-react-19-head.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso-provider': patch +--- + +### Favicon + +- render the favicon `` tags only once their icon URLs have resolved. React 19 decides whether a `` can be hoisted into `` when the element mounts, and a link mounted with an undefined `href` renders in place and stays there even after the href arrives — so under React 19 the favicons never reached the document head. The icons load asynchronously, so the links now mount with their hrefs already set diff --git a/.changeset/react-19-peer-range.md b/.changeset/react-19-peer-range.md new file mode 100644 index 0000000000..34e3b1178f --- /dev/null +++ b/.changeset/react-19-peer-range.md @@ -0,0 +1,99 @@ +--- +'@topkit/analytics-charts': minor +'@toptal/picasso': minor +'@toptal/picasso-accordion': minor +'@toptal/picasso-account-select': minor +'@toptal/picasso-alert': minor +'@toptal/picasso-amount': minor +'@toptal/picasso-application-update-notification': minor +'@toptal/picasso-autocomplete': minor +'@toptal/picasso-avatar': minor +'@toptal/picasso-avatar-upload': minor +'@toptal/picasso-backdrop': minor +'@toptal/picasso-badge': minor +'@toptal/picasso-breadcrumbs': minor +'@toptal/picasso-button': minor +'@toptal/picasso-calendar': minor +'@toptal/picasso-carousel': minor +'@toptal/picasso-charts': minor +'@toptal/picasso-checkbox': minor +'@toptal/picasso-codemod': minor +'@toptal/picasso-collapse': minor +'@toptal/picasso-container': minor +'@toptal/picasso-date-picker': minor +'@toptal/picasso-date-select': minor +'@toptal/picasso-drawer': minor +'@toptal/picasso-dropdown': minor +'@toptal/picasso-dropzone': minor +'@toptal/picasso-empty-state': minor +'@toptal/picasso-environment-banner': minor +'@toptal/picasso-fade': minor +'@toptal/picasso-file-input': minor +'@toptal/picasso-form': minor +'@toptal/picasso-form-label': minor +'@toptal/picasso-form-layout': minor +'@toptal/picasso-forms': minor +'@toptal/picasso-grid': minor +'@toptal/picasso-helpbox': minor +'@toptal/picasso-icons': minor +'@toptal/picasso-image': minor +'@toptal/picasso-input': minor +'@toptal/picasso-input-adornment': minor +'@toptal/picasso-link': minor +'@toptal/picasso-list': minor +'@toptal/picasso-loader': minor +'@toptal/picasso-logo': minor +'@toptal/picasso-menu': minor +'@toptal/picasso-modal': minor +'@toptal/picasso-modal-context': minor +'@toptal/picasso-note': minor +'@toptal/picasso-notification': minor +'@toptal/picasso-number-input': minor +'@toptal/picasso-outlined-input': minor +'@toptal/picasso-overview-block': minor +'@toptal/picasso-page': minor +'@toptal/picasso-pagination': minor +'@toptal/picasso-paper': minor +'@toptal/picasso-password-input': minor +'@toptal/picasso-pictograms': minor +'@toptal/picasso-popper': minor +'@toptal/picasso-prompt-modal': minor +'@toptal/picasso-provider': minor +'@toptal/picasso-query-builder': minor +'@toptal/picasso-quote': minor +'@toptal/picasso-radio': minor +'@toptal/picasso-rating': minor +'@toptal/picasso-rich-text-editor': minor +'@toptal/picasso-section': minor +'@toptal/picasso-select': minor +'@toptal/picasso-shared': minor +'@toptal/picasso-show-more': minor +'@toptal/picasso-skeleton-loader': minor +'@toptal/picasso-slide': minor +'@toptal/picasso-slider': minor +'@toptal/picasso-step': minor +'@toptal/picasso-switch': minor +'@toptal/picasso-table': minor +'@toptal/picasso-tabs': minor +'@toptal/picasso-tag': minor +'@toptal/picasso-tagselector': minor +'@toptal/picasso-tailwind-merge': minor +'@toptal/picasso-test-utils': minor +'@toptal/picasso-timeline': minor +'@toptal/picasso-timepicker': minor +'@toptal/picasso-tooltip': minor +'@toptal/picasso-tree-view': minor +'@toptal/picasso-typography': minor +'@toptal/picasso-typography-overflow': minor +'@toptal/picasso-user-badge': minor +'@toptal/picasso-utils': minor +--- + +Allow React 19: the `react` and `react-dom` peer ranges become `^17.0.0 || ^18.0.0 || ^19.0.0` on every Picasso package. + +- the `< 19.0.0` cap set in v100 is lifted now that the unit suite runs green on React 19 next to React 18 (`pnpm test:react19`, CI job `react19-validate`), Collapse no longer depends on `react-transition-group`, element refs are read from the location each React major stores them in, and the head-tag rendering React 19 hoists at mount is handled in the provider +- explicit majors rather than an open range, so the next React major stays opted out until it is validated the same way +- the floor stays React 17, the minimum `@base-ui/react` supports; `@toptal/picasso-show-more` joins the uniform range after its earlier uncapped `>=17.0.0` +- the published declarations type-check against `@types/react` 19 as well as 17 and 18: `JSX.Element` (a global React 19's types removed) becomes `React.ReactElement`, refs created with `useRef(null)` are accepted as `RefObject`, elements read through `cloneElement` carry the props they are read for, the `content` and `children` HTML attributes no longer collide with Picasso's own props on Accordion, Dropdown, Table.ExpandableRow and Tooltip, and `OverridableComponent` (picasso-shared) returns what the installed React's component types return. `Dropdown` now declares the render-function `children` it already supported, and that function receives `{ open }` (previously the untyped form passed `{ isOpen }`) +- a `@toptal/picasso-shared` names the two element shapes those declarations share, re-exported from `@toptal/picasso`: `IconElement` for the `icon`/`expandIcon`/`image` props that receive an ``-like element and add classes to it, and `TransitionChild` for the single child `Fade` and `Slide` clone with transition classes, style and a ref. Existing usages type-check unchanged; the types exist so the same shape is not spelled out on every package. `@toptal/picasso-utils` adds `NullableRefObject`, the ref object `useRef(null)` returns on every `@types/react` major, for refs that are both read and passed to a JSX `ref` prop +- no runtime changes beyond `Form` rendering a function child the way `react-final-form` documents diff --git a/.changeset/react-helmet-async-react-19-support.md b/.changeset/react-helmet-async-react-19-support.md new file mode 100644 index 0000000000..48229951ce --- /dev/null +++ b/.changeset/react-helmet-async-react-19-support.md @@ -0,0 +1,13 @@ +--- +'@toptal/picasso-provider': minor +'@toptal/picasso-page': patch +--- + +### Provider + +- upgrade `react-helmet-async` from `2.0.3` to `3.0.0`, the only release whose peer range admits React 19. No 2.x release supports it. The public API is unchanged — `Helmet` remains a class component, and `HelmetProvider`, `HelmetData` and the `HelmetProps` type are still exported — and React 16–18 keeps the existing code path, so behavior is unchanged for current consumers +- on React 19, `` renders real DOM elements for React to hoist and `` becomes a transparent passthrough. This changes four behaviors once consumers move to React 19: the SSR `context` object is no longer populated, and `prioritizeSeoTags`, `helmetData` and `canUseDOM` become inert. `htmlAttributes` and `bodyAttributes` continue to work on both code paths + +### Page + +- declare `react-helmet-async` as a dependency. `Page.Helmet` imported it without declaring it, so the import resolved to whatever copy the consumer happened to hoist — or to none at all. The dependency is now explicit and pinned to the same version the provider uses, which keeps `Page.Helmet` and `` on one instance diff --git a/.changeset/rte-emoji-picker-keyup-listener-leak.md b/.changeset/rte-emoji-picker-keyup-listener-leak.md new file mode 100644 index 0000000000..1831ed7a4f --- /dev/null +++ b/.changeset/rte-emoji-picker-keyup-listener-leak.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso-rich-text-editor': patch +--- + +### RichTextEditor + +- fix accumulating `keyup` listeners on `document.body` — the emoji picker's escape-key cleanup passed a new function reference to `removeEventListener`, so every time the picker opened another permanent listener was left behind diff --git a/.changeset/show-more-css-line-clamp.md b/.changeset/show-more-css-line-clamp.md new file mode 100644 index 0000000000..70615907fc --- /dev/null +++ b/.changeset/show-more-css-line-clamp.md @@ -0,0 +1,14 @@ +--- +'@toptal/picasso-show-more': minor +'@toptal/picasso': patch +--- + +### ShowMore + +- replace the unmaintained `react-truncate` dependency (last published 2018, React peer locked to `<= 16`) with a dependency-free CSS `line-clamp` implementation; the public API is unchanged and the toggle still appears only when content actually overflows +- the SSR output now contains the full text already clamped by CSS, where previously the text was truncated only client-side after measurement +- collapsed content now keeps the full text in the DOM (clamping is visual), so it stays selectable, findable and queryable in tests + +### Picasso + +- remove the unused `react-truncate` dependency diff --git a/.changeset/tailwind-merge-v3.md b/.changeset/tailwind-merge-v3.md new file mode 100644 index 0000000000..abc709e93f --- /dev/null +++ b/.changeset/tailwind-merge-v3.md @@ -0,0 +1,75 @@ +--- +'@toptal/picasso-tailwind-merge': major +'@toptal/picasso-avatar-upload': patch +'@toptal/picasso-calendar': patch +'@toptal/picasso-carousel': patch +'@toptal/picasso-collapse': minor +'@toptal/picasso-notification': patch +'@toptal/picasso-number-input': patch +'@toptal/picasso-page': patch +'@toptal/picasso-select': minor +'@toptal/picasso-slider': patch +'@toptal/picasso-table': minor +'@toptal/picasso-timeline': minor +--- + +### Tailwind Merge + +- upgrade `tailwind-merge` to `^3.6.0`. The `3.x` line is the one that understands the Tailwind CSS v4 class vocabulary; `2.x` only knows v3, so the package has been a major version behind the `tailwindcss@^4.2.1` peer the theme packages already require. The Picasso `CONFIG`'s `font-size`, `font-weight` and `text-alignment` groups are untouched — all three still exist as class group ids in v3 +- register the Picasso `shadow-0`…`shadow-24` scale in the `shadow` class group. `tailwind-merge` does not know the numeric scale and classified these utilities as shadow _colors_: numeric-vs-numeric pairs still conflicted (colors conflict with each other), but `shadow-none`, t-shirt sizes and arbitrary `shadow-[…]` values did not conflict with them — and a genuine shadow color (`shadow-blue-500`) coming later would drop a numeric box shadow entirely. All box shadow utilities now resolve in one group, and shadow colors coexist with the scale instead of erasing it +- `twMerge` now resolves conflicts for utilities that only exist in Tailwind v4 and were previously passed through unrecognised, so a consumer `className` finally wins over the component's own class. Affected utilities include `min-h-auto` / `max-h-auto`, `outline-hidden` (v4's rename of `outline-none`, which now correctly overrides it), the two-axis `translate-*` shorthand, `border-2` against `border-x`/`border-y`, `shrink-`, and the trailing-`!` important syntax (`no-underline!`) +- `bg-linear-to-*` is no longer misread as a background _color_. Under `2.x` a gradient direction and a `bg-` shared one conflict group, so whichever came last silently dropped the other — this affected `Modal.Content`'s scroll shades, where a `bg-*` class could remove the gradient +- conversely, Tailwind v3's removed `bg-gradient-to-*` spelling is no longer recognised as a gradient direction and now conflicts with `bg-`. It still compiles under `tailwindcss@4.2.1` as a deprecated alias, but use `bg-linear-to-*` instead +- no API change: `twMerge` and `twJoin` keep their signatures, and `tailwind-merge` v3 still ships CJS, ESM and types +- ship as a major: the package README mandates one on any `CONFIG` change, and the merge output changes either way. `@toptal/picasso-tailwind-merge` is a `workspace:^` peer of every component package and releases honor `onlyUpdatePeerDependentsWhenOutOfRange`, so only an out-of-range bump moves the dependents' peer floors — a minor would let consumers pair the new component code with the old `2.x` merge semantics + +### Carousel + +- rename `bg-gradient-to-r` to `bg-linear-to-r` in the edge gradients, matching the Tailwind v4 spelling. Both compile to the same CSS, so there is no visual change +- build `CarouselNavigation`'s dot styles with `twJoin` instead of a multi-line template literal, so the rendered `class` attribute no longer carries the literal's newlines and indentation. Same classes, same order + +### AvatarUpload + +- fix the cursor never changing away from `cursor-pointer`. The root class list was built with `twJoin`, which does not resolve conflicts, so the unconditional `cursor-pointer` and the conditional `cursor-no-drop` / `cursor-default` all reached the DOM and Tailwind's emission order decided the winner — always `cursor-pointer`. A disabled `AvatarUpload` now shows `cursor-no-drop` as intended +- `disabled` now takes precedence over `showAvatar`, so a disabled upload that already has an image reads as disabled (`cursor-no-drop`) rather than inert (`cursor-default`). Both conditions are independent, so the combination is reachable; it was simply unobservable while `cursor-pointer` always won +- the upload icon colour helper in `styles.ts` uses `twMerge` too, so the `error` status colour reliably beats the hover colour instead of depending on stylesheet order + +### Calendar + +- the `CalendarIndicators` resolves its today-dot colour with `twMerge`, so the `isSelected` white overrides the default blue by argument order rather than by stylesheet order. No visual change + +### Notification + +- the icon container resolves its `min-w` with `twMerge`, so the `yellow` variant's narrower width overrides the default by argument order rather than by stylesheet order. No visual change +- replace the root's arbitrary `shadow-[none]` with the `shadow-none` utility. Classified as shadow colors, `shadow-[none]` and the `elevated` variant's `shadow-3` conflicted by accident; with the numeric scale registered as box shadows they would stop conflicting and stylesheet order would decide again. `shadow-none` vs `shadow-3` resolves properly, and a non-elevated notification still renders shadowless + +### NumberInput + +- the `NumberInputEndAdornment` resolves its root class list with `twMerge`, and drops three dead classes: `bg-inherit`, immediately overridden by `bg-transparent`, plus `border-x-solid` and `active:[&+&]:border-t-solid`, which emit no CSS at all because Tailwind has no per-side border-style utility. The border style is already set unconditionally by `[&]:border-solid` and `[&+&]:border-solid`. No visual change + +### Slider + +- the thumb resolves its class list with `twMerge`, so `isThumbHidden`'s `hidden` overrides the base `flex` by argument order rather than by stylesheet order. No visual change + +### Collapse + +- switch the root class list to `twMerge` so a consumer `className` overrides the component's own classes. Previously `overflow-auto` passed by a consumer lost to the internal `overflow-hidden` / `overflow-visible`, because `twJoin` emitted both and the stylesheet order decided + +### Page + +- the `PageTopBar` builds its inner class list with `twMerge` instead of `twJoin`, with the unconditional `max-w` default moved ahead of the `width` overrides so precedence stays default -> `wide` -> `full`. No behavior change + +### Select + +- switch `SelectOptions` to `twMerge` so a consumer `className` overrides the internal `shadow-5` and the `fixedHeader` / `fixedFooter` padding. Previously `pt-*` overrides and shadows below `shadow-5` silently lost. Down the chain, `shadow-5` keeps replacing `Menu`'s default `shadow-1` (`Menu` already merges its `className` last) — now via the real `shadow` class group instead of the shadow-color accident described under Tailwind Merge +- the `SelectCaret` and `NonNativeSelect` resolve their class lists with `twMerge`, so the `disabled` caret colour and the `horizontal` layout width override their defaults by argument order rather than by stylesheet order. `SelectCaret` also drops a commented-out line referencing the removed `classes` API. No visual change +- the `SelectCaret` and `NativeSelectInput` pass their base classes to `twMerge` as one string per line instead of a multi-line template literal. No rendered change — `twMerge` already collapsed the literal's whitespace + +### Table + +- switch `TableExpandableRow` to `twMerge` and put the consumer `className` last, so it overrides the `stripeEven` background on the expanded row. The two rows the component renders now resolve a consumer `className` the same way + +### Timeline + +- switch `TimelineRow`'s icon class list to `twMerge` and put the icon's own `className` last, so a consumer can recolor the icon. Previously `} />` stayed grey because the internal `text-gray-600` won on stylesheet order +- build the default dot's class list with `twJoin` instead of a multi-line string literal, so the rendered `class` attribute no longer carries the literal's newlines and indentation. Same classes, same order diff --git a/.changeset/test-utils-rendered-props.md b/.changeset/test-utils-rendered-props.md new file mode 100644 index 0000000000..b27948275a --- /dev/null +++ b/.changeset/test-utils-rendered-props.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso-test-utils': minor +--- + +### TestUtils + +- add `renderedProps(mock)`, which returns the props each render of a mocked component received. React 19 no longer passes function components the legacy-context second argument, so assertions written as `toHaveBeenCalledWith(props, {})` pass on one React major only — compare the props alone instead diff --git a/.changeset/transitions-tailwind-migration.md b/.changeset/transitions-tailwind-migration.md new file mode 100644 index 0000000000..77ba915dae --- /dev/null +++ b/.changeset/transitions-tailwind-migration.md @@ -0,0 +1,37 @@ +--- +'@toptal/picasso-utils': minor +'@toptal/picasso-fade': minor +'@toptal/picasso-slide': minor +'@toptal/picasso-backdrop': minor +'@toptal/picasso-tailwind-merge': patch +--- + +### Utils + +- add `useTransitionStatus`, a timer-driven replacement for react-transition-group's `Transition` state machine that matches its settle timing. It returns the transition `status` together with the `duration` of the running phase, resolved from the number or per-phase object form of `timeout` +- add `getElementRef`, which reads an element's ref from where the running React major stores it: `props.ref` on React 19 and newer, where `element.ref` is deprecated and warns on every access, and `element.ref` on React 17 and 18, where dev builds warn on `props.ref` instead +- switch `ClickAwayListener` to `getElementRef` and `useMultipleForwardRefs` instead of its own `element.ref` cast and hand-rolled ref forwarding, so DatePicker, Dropdown and MenuItem inherit the version-aware read (behavior unchanged) + +### Fade + +- reimplement with Tailwind classes and drop the react-transition-group dependency; public props are unchanged +- `onEnter`/`onExited` now receive the transitioning DOM node, as their types always declared. Under react-transition-group's `nodeRef` mode the old runtime actually called `onEnter(isAppearing, undefined)` and `onExited()` with no arguments — consumers reading the first `onEnter` argument as the `isAppearing` boolean must switch to the second argument +- the shown state no longer forces inline `opacity: 1`, so a child's own `opacity-*` class now applies while visible (previously it was overridden while shown) +- express the hidden state via the `invisible` and `opacity-0` classes instead of inline `visibility`/`opacity` styles, and merge the child's `className` via `twMerge` +- the object form of `timeout` now sets the CSS transition duration correctly per direction, including a distinct `appear` duration for the mount transition (previously it produced an invalid inline value) + +### Slide + +- reimplement with Tailwind classes and drop the react-transition-group dependency; public props and the direction mapping are unchanged +- `onEnter`/`onExited` now receive the transitioning DOM node, exactly as described for Fade above +- express the hidden state via `translate-*` and `invisible` classes instead of inline `transform`/`visibility` styles, so a child's own `transform` is preserved while sliding and `transitionend` listeners observe `propertyName: 'translate'` +- the object form of `timeout` now sets the CSS transition duration correctly per direction, including a distinct `appear` duration for the mount transition (previously it produced an invalid inline value) + +### Backdrop + +- compose the Tailwind-based Fade so react-transition-group is no longer in the dependency tree +- merge the consumer `className` via `twMerge` so consumer utilities now win on conflicts, and remove the redundant `bg-black` class and a dead `-webkit-tap-highlight-color-transparent` class (no visual change) + +### TailwindMerge + +- remove the unused react-transition-group dependency diff --git a/.claude/launch.json b/.claude/launch.json index 1f082a7924..d31282c8e7 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "pnpm", "runtimeArgs": ["start:storybook"], "port": 9001 + }, + { + "name": "storybook-react19", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["start:storybook:react19"], + "port": 9001 } ] } diff --git a/.github/workflows/react19-validate.yml b/.github/workflows/react19-validate.yml new file mode 100644 index 0000000000..f367f3cd93 --- /dev/null +++ b/.github/workflows/react19-validate.yml @@ -0,0 +1,92 @@ +# [PF-2262] React 19 support: run the unit and component test suites against React 19. +# +# The workspace runs React 18 (pnpm-workspace.yaml overrides); the standalone +# react19/ install plus jest.react19.mjs map every react/react-dom/ +# @testing-library import to React 19 for this run only. See jest.react19.mjs +# for the wiring and jest/react-compat/README.md for how one snapshot set +# serves both React majors. +name: React 19 Validate + +on: + workflow_dispatch: + pull_request: + branches: + - master + - 'feature/**' + types: + - synchronize + - opened + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + react19-unit: + if: ${{ github.event.pull_request.head.ref != 'changeset-release/master' }} + name: Unit tests on React 19 + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 45 + + steps: + - name: Checkout + # Same as actions/checkout@v5 + uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 + + - name: Set up Node.js + # Same as actions/setup-node@v6.1 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version: 22.20.0 + + - name: Install Dependencies + uses: ./.github/actions/pnpm-install + + - name: Jest Tests on React 19 + run: pnpm test:react19 --maxWorkers=4 --ci + + - name: Type check against @types/react 19 + run: pnpm typecheck:react19 + + react19-component: + if: ${{ github.event.pull_request.head.ref != 'changeset-release/master' }} + name: Component tests on React 19 + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + index: [0, 1, 2] + # davinci-qa's parallelization plugin reads these to split the specs + env: + GROUP_INDEX: ${{ matrix.index }} + PARALLEL_GROUPS: ${{ strategy.job-total }} + + steps: + - name: Checkout + # Same as actions/checkout@v5 + uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 + + - name: Set up Node.js + # Same as actions/setup-node@v6.1 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version: 22.20.0 + + - name: Install Dependencies + uses: ./.github/actions/pnpm-install + + # The script installs the React 19 harness, builds the packages and runs + # cypress with CYPRESS_REACT_19=1; the action caches the Cypress binary + # and keeps screenshots of failures. No Happo keys, so no screenshots are + # uploaded — this job is about behavior, the React 18 run owns visuals. + - name: Component tests on React 19 + uses: ./.github/actions/integration-tests + with: + command: test:integration:react19 diff --git a/.storybook/main.js b/.storybook/main.js index c4a6c9c18a..055470d916 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -12,6 +12,26 @@ const PACKAGES_COMPONENT_DECLARATION_FILE_REGEXP = const { env } = process const isDevelopment = env.NODE_ENV !== 'production' && env.NODE_ENV !== 'test' +// [PF-2262] React 19 validation mode: STORYBOOK_REACT_19=1 swaps the +// preview's React for the standalone React 19 install in react19/ — the same +// install jest.react19.mjs uses; its header says why the harness must NOT be +// a workspace member and when it can go. Normal runs are byte-identical — +// every entry below is gated on the env var. +const useReact19 = env.STORYBOOK_REACT_19 === '1' +// Storybook's builder injects its own plain-key aliases +// (`react`/`react-dom` → the workspace React 18 dirs, +// builder-webpack5 iframe-webpack.config.js:202) — plain keys also shadow +// `$`-exact ones, so we must overwrite the SAME keys. A plain directory +// alias covers subpaths too (react/jsx-runtime, react-dom/client), which is +// why react-dom points at a shim *package* rather than a directory — see +// react-dom-19-shim/index.js for what the shim changes and why. +const react19Aliases = useReact19 + ? { + react: path.resolve(__dirname, '../react19/node_modules/react'), + 'react-dom': path.resolve(__dirname, 'react-dom-19-shim'), + } + : {} + const tsConfigFile = path.join(__dirname, './tsconfig.json') const threadLoaders = [{ loader: 'cache-loader' }, { loader: 'thread-loader' }] @@ -158,6 +178,16 @@ module.exports = { savePropValueAsString: true, }), ], + // webpack's persistent cache does not key on our alias swap — without + // a distinct cache version, switching STORYBOOK_REACT_19 on/off would + // serve modules resolved against the other mode's React + cache: + config.cache && typeof config.cache === 'object' + ? { + ...config.cache, + version: useReact19 ? 'react19' : 'react18', + } + : config.cache, node: { ...config.node, /* @@ -181,6 +211,7 @@ module.exports = { alias: { ...config.resolve.alias, '~': path.resolve(__dirname, '..'), + ...react19Aliases, }, }, } @@ -218,7 +249,8 @@ module.exports = { } }, reactOptions: { - fastRefresh: true, + // react-refresh 0.11 (storybook 6.5's line) predates React 19 + fastRefresh: !useReact19, strictMode: true, }, features: { diff --git a/.storybook/preview.js b/.storybook/preview.js index b365e94f8b..2391b522f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -6,6 +6,11 @@ import './styles.css' import Picasso from '@toptal/picasso-provider' +if (process.env.STORYBOOK_REACT_19 === '1') { + // eslint-disable-next-line no-console + console.log('[react19] preview running on React', React.version) +} + const baseViewportCheckpoints = getCheckpoints() const BASE_VIEWPORTS = { 'extra-small': { diff --git a/.storybook/react-dom-19-shim/client.js b/.storybook/react-dom-19-shim/client.js new file mode 100644 index 0000000000..c2f854235a --- /dev/null +++ b/.storybook/react-dom-19-shim/client.js @@ -0,0 +1 @@ +module.exports = require('../../react19/node_modules/react-dom/client.js') diff --git a/.storybook/react-dom-19-shim/index.js b/.storybook/react-dom-19-shim/index.js new file mode 100644 index 0000000000..51571736ea --- /dev/null +++ b/.storybook/react-dom-19-shim/index.js @@ -0,0 +1,28 @@ +// [PF-2262] React 19 storybook mode: re-export react-dom 19 (from the +// standalone react19/ install) with an '18.'-prefixed version string. +// @storybook/react@6.5 picks its renderer via `version.startsWith('18')` +// (dist/esm/client/preview/render.js:111); the legacy branch calls +// ReactDOM.render/unmountComponentAtNode, both removed in React 19, so the +// sniff must land on the modern createRoot path. +// +// Delete this shim with the Storybook 7+ upgrade: it no longer sniffs the +// version, so .storybook/main.js can alias react-dom straight at +// react19/node_modules/react-dom. +const reactDom = require('../../react19/node_modules/react-dom/index.js') + +module.exports = { + ...reactDom, + version: `18.999.0-react19-shim-${reactDom.version}`, + // storybook 6.5's unmountElement calls the legacy API unconditionally for + // elements it never attached a root to (render.js:118-123). No tracked + // root means nothing to unmount — legacy semantics return false for that. + unmountComponentAtNode: reactDom.unmountComponentAtNode || (() => false), + // the modern render path never calls this; fail loudly if something does + render: + reactDom.render || + (() => { + throw new Error( + '[react19 shim] legacy ReactDOM.render was called — find the caller, it cannot work on React 19' + ) + }), +} diff --git a/.storybook/react-dom-19-shim/package.json b/.storybook/react-dom-19-shim/package.json new file mode 100644 index 0000000000..6ee0c75c0d --- /dev/null +++ b/.storybook/react-dom-19-shim/package.json @@ -0,0 +1,7 @@ +{ + "name": "react-dom-19-shim", + "private": true, + "version": "0.0.0", + "main": "index.js", + "description": "react-dom 19 with an 18-prefixed version string, for @storybook/react@6.5's renderer sniff under STORYBOOK_REACT_19=1 — see index.js" +} diff --git a/.storybook/react-dom-19-shim/server.js b/.storybook/react-dom-19-shim/server.js new file mode 100644 index 0000000000..1f2e112c91 --- /dev/null +++ b/.storybook/react-dom-19-shim/server.js @@ -0,0 +1 @@ +module.exports = require('../../react19/node_modules/react-dom/server.browser.js') diff --git a/.storybook/react-dom-19-shim/test-utils.js b/.storybook/react-dom-19-shim/test-utils.js new file mode 100644 index 0000000000..e2a9bcdca0 --- /dev/null +++ b/.storybook/react-dom-19-shim/test-utils.js @@ -0,0 +1,4 @@ +// Bundled for the browser preview only (RTL 14 in picasso-test-utils requires +// react-dom/test-utils). Do not require this from Node CLI — react-dom 19's +// test-utils entry hangs outside a browser/jest environment. +module.exports = require('../../react19/node_modules/react-dom/test-utils.js') diff --git a/AGENTS.md b/AGENTS.md index 1eeb3dd1e3..2b0705541f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ pnpm test:unit -u # update jest snapshots pnpm test:unit -- # run a single file or pattern (jest CLI args after --) pnpm test:integration # cypress component tests (headless) pnpm test:integration:open # cypress in dev mode + watch build +pnpm test:react19 # jest against the standalone React 19 install in react19/ +pnpm typecheck:react19 # tsc against @types/react 19 (build stays on @types/react 17) +pnpm test:integration:react19 # cypress component tests against React 19 (CYPRESS_REACT_19=1) +pnpm start:storybook:react19 # storybook on http://localhost:9001 against the React 19 install (STORYBOOK_REACT_19=1) pnpm test # unit + integration (CI parity, slow) pnpm changeset # REQUIRED on PRs that change package code @@ -127,6 +131,7 @@ Adding a new package: update `tsconfig.json` paths, `.storybook/main.js` aliases - **Compose with `twMerge(...)`** from `@toptal/picasso-tailwind-merge`, and put the **consumer `className` LAST** so it wins on conflicts. - **Conditionals: `twMerge(cx({ 'm-0': expanded }))`** — `cx` (from `classnames`) expresses branching/variant classes (object syntax or `cond && 'x'`), preferred over scattering `&&`/ternaries across `twMerge` args; `twMerge` resolves Tailwind conflicts. (`twMerge` takes no object syntax — that's `cx`'s job.) Plain `twMerge('a', 'b', className)` is fine when nothing branches. +- **`twJoin` only when no two arguments can ever target the same CSS property** — it concatenates without resolving conflicts. The moment a conditional can collide with a base class, or a consumer `className` is in the list, use `twMerge`. - **State-driven styling uses `data-[…]:` variants** (`data-[checked]:bg-blue-500`, `data-[disabled]:opacity-50`). Read state from the DOM; don't mirror it into `useState` just to style it. - **Tokens over arbitrary values** — use Picasso token names (`text-graphite-800`, `shadow-2`, `p-4`). An `[arbitrary-value]` plus a `// TODO(tokens): …` comment is a last resort to raise with designers; never invent tokens. - **No `!important`.** If a utility won't win, walk the override ladder: don't-override → `data-[…]:` / `className` → `render` prop → (last resort) inline `style`. Reaching for `!important` means you skipped a rung. @@ -163,6 +168,7 @@ Adding a new package: update `tsconfig.json` paths, `.storybook/main.js` aliases - One top-level `describe('ComponentName', …)`; nest only for behavioral groupings, never 3+ deep. - Render through a local `renderComponent` helper that wraps `render()` from `@toptal/picasso-test-utils`. - Use user-centric queries (`getByRole`/`getByText`/`getByTestId`) with `userEvent` — no `fireEvent`, and no bare "renders without crashing" tests. +- Test hooks with `renderHook` from `@testing-library/react` — never `@testing-library/react-hooks` (archived, peers React ≤ 17, needs the deprecated `react-test-renderer`). - Keep 2–3 shape snapshots per component; the rest are explicit assertions. - Responsive components run Happo at all breakpoints (`screenshotBreakpoints: true`); fix every Violation the a11y addon reports. @@ -175,6 +181,7 @@ Adding a new package: update `tsconfig.json` paths, `.storybook/main.js` aliases - **`TODO` / `FIXME` / `@deprecated`** comments must include a Jira ref — `[ABC-1234]` or the full `https://toptal-core.atlassian.net/browse/...` URL. The `todo-plz/ticket-ref` ESLint rule warns otherwise. - **Icons/pictograms:** drop SVG into `packages/base/Icons/src/Icon/svg/` (16×16 and 24×24 variants) or `packages/picasso-pictograms/src/Pictograms/svg/` (64×64), strokes expanded to fills, then `pnpm generate:icons` / `pnpm generate:pictograms`. - **Dependencies:** caret (`^`) for npm deps, exact for workspace deps; install with plain `pnpm install`. + - One deliberate exception: the root devDependency `@cypress/request` is pinned exactly at `3.0.9` so Cypress 14 dedupes onto it and its `qs` stays off the Dependency Review advisory list (3.0.10 pins a vulnerable `qs`). Drop the pin with Cypress 15+, whose request client resolves a patched `qs` on its own. - **CI is repo-wide:** touched files must pass `pnpm prettier --check` and ESLint (`pnpm lint:fix` autofixes most). A lint error _anywhere_ in the repo blocks the PR. ## Reference docs diff --git a/bin/generate-responsive-icons.js b/bin/generate-responsive-icons.js index 87ed3b20c9..8e1ca1559d 100644 --- a/bin/generate-responsive-icons.js +++ b/bin/generate-responsive-icons.js @@ -26,7 +26,7 @@ const ${name}Responsive = (props: Props) => { xl: <${name}16 {...props} />, }, <${name}24 {...props} /> - ) as JSX.Element + ) as React.ReactElement } export default ${name}Responsive diff --git a/cypress.config.mjs b/cypress.config.mjs index 7c07a2887d..fdeadb1d65 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -1,14 +1,56 @@ +import path from 'path' +import { fileURLToPath } from 'url' import happoTask from 'happo-cypress/task.js' /* eslint-disable import/no-extraneous-dependencies */ import { defineConfig } from 'cypress' import davinciConfig from '@toptal/davinci-qa/src/configs/cypress.config.js' /* eslint-enable */ +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// CYPRESS_REACT_19=1 runs the component tests against the standalone React 19 +// install in react19/ (see jest.react19.mjs for why it is not a workspace +// member). A plain directory alias covers the subpaths too — react/jsx-runtime, +// react-dom/client (which cypress/react mounts through) — and react-dom 19 +// finds its own `scheduler` next to itself, so two aliases are enough. Normal +// runs are byte-identical: everything is gated on the env var. +const useReact19 = process.env.CYPRESS_REACT_19 === '1' +const react19Aliases = useReact19 + ? { + react: path.resolve(__dirname, 'react19/node_modules/react'), + 'react-dom': path.resolve(__dirname, 'react19/node_modules/react-dom'), + // picasso-test-utils (the TestingPicasso mount wrapper) bundles + // @testing-library/react; the workspace copy is built for react-dom 18 + '@testing-library/react': path.resolve( + __dirname, + 'react19/node_modules/@testing-library/react' + ), + '@testing-library/dom': path.resolve( + __dirname, + 'react19/node_modules/@testing-library/dom' + ), + } + : {} + +if (useReact19) { + console.log( + '[react19] cypress component tests resolve react from react19/node_modules' + ) +} + +const davinciResolve = davinciConfig.component.devServer.webpackConfig.resolve + export default defineConfig({ ...davinciConfig, component: { ...davinciConfig.component, - retries: { openMode: null, runMode: 2 }, + // Cypress 14 validates this as a number; `null` used to mean "no retries" + retries: { openMode: 0, runMode: 2 }, + // davinci-qa runs every spec in one tab (experimentalSingleTabRunMode); on + // Cypress 14's Chromium the renderer ran out of memory around the 27th spec + // and Cypress hung instead of failing. Freeing memory between tests keeps + // the single-tab speed without the crash. + experimentalMemoryManagement: true, setupNodeEvents: (on, config) => { davinciConfig.component.setupNodeEvents(on, config) @@ -50,7 +92,7 @@ export default defineConfig({ }, { test: /\.(js)$/, - type: "javascript/auto", + type: 'javascript/auto', resolve: { fullySpecified: false, }, @@ -61,21 +103,28 @@ export default defineConfig({ }, { test: /\.css$/i, - use: ['style-loader', 'css-loader', { - loader: 'postcss-loader', - options: { - postcssOptions: { - config: false, - plugins: { - '@tailwindcss/postcss': {}, + use: [ + 'style-loader', + 'css-loader', + { + loader: 'postcss-loader', + options: { + postcssOptions: { + config: false, + plugins: { + '@tailwindcss/postcss': {}, + }, }, }, }, - }], + ], }, ], }, - resolve: davinciConfig.component.devServer.webpackConfig.resolve, + resolve: { + ...davinciResolve, + alias: { ...davinciResolve.alias, ...react19Aliases }, + }, }, }, }, diff --git a/cypress/component/Autocomplete.spec.tsx b/cypress/component/Autocomplete.spec.tsx index 2064f61db4..ed78967cfd 100644 --- a/cypress/component/Autocomplete.spec.tsx +++ b/cypress/component/Autocomplete.spec.tsx @@ -103,6 +103,9 @@ export const DynamicOptionsAutocompleteExample = () => { } const openAutocompleteWithTab = () => { + // `.tab()` does not retry, and React 19 commits the mount asynchronously, + // so make sure the field is in the DOM before asking for the next tab stop + cy.getByTestId(testIds.input).should('exist') cy.get('body').tab() } diff --git a/cypress/component/ReactRuntime.spec.tsx b/cypress/component/ReactRuntime.spec.tsx new file mode 100644 index 0000000000..5bd209fec5 --- /dev/null +++ b/cypress/component/ReactRuntime.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { version as reactDomVersion } from 'react-dom' + +// Proves which React the component tests run on: the workspace's React 18 by +// default, the standalone react19/ install when CYPRESS_REACT_19=1 (exposed to +// specs as Cypress.env('REACT_19')). Guards the alias wiring in +// cypress.config.mjs — a broken alias would silently test React 18 twice. +const Probe = () => ( +
+ {React.version}/{reactDomVersion} +
+) + +describe('React runtime', () => { + it('mounts the React major the run was configured for', () => { + // Cypress coerces numeric env values, so CYPRESS_REACT_19=1 arrives as the number 1 + const expectedMajor = String(Cypress.env('REACT_19')) === '1' ? '19' : '18' + + cy.mount() + + cy.getByTestId('react-version') + .invoke('text') + .should( + 'match', + new RegExp(`^${expectedMajor}\\.[\\d.]+/${expectedMajor}\\.`) + ) + }) +}) diff --git a/cypress/component/Select.spec.tsx b/cypress/component/Select.spec.tsx index 6e494b6eee..bb1a2b561b 100644 --- a/cypress/component/Select.spec.tsx +++ b/cypress/component/Select.spec.tsx @@ -393,8 +393,12 @@ describe('Select', () => { cy.getByTestId('search-input').click('center') cy.getByTestId('search-input').find('input').should('be.focused') - // focuses on by click on the input wrapper - cy.getByTestId('select').click() + // Clicking the wrapper while the popup is open is two state changes: the + // search input's blur closes the popup, then the wrapper's click handler + // reopens it (and search refocuses). Cypress's synthetic `.click()` fires + // mousedown/blur/click in one task, so the click can still see the popup + // as open and close it instead — a real pointer click keeps them apart. + cy.getByTestId('select').realClick() cy.getByTestId('select').getByTestId('search-input').click('bottom') cy.getByTestId('select') .getByTestId('search-input') @@ -402,7 +406,7 @@ describe('Select', () => { .should('be.focused') // focuses on by click on the search icon - cy.getByTestId('select').click() + cy.getByTestId('select').realClick() cy.getByTestId('select') .getByTestId('search-input') .closest('[role="menuitem"]') @@ -413,7 +417,7 @@ describe('Select', () => { .should('be.focused') // focuses on by typing - cy.getByTestId('select').click() + cy.getByTestId('select').realClick() cy.getByTestId('select').type('option') cy.getByTestId('search-input').find('input').should('be.focused') }) diff --git a/jest.react19.mjs b/jest.react19.mjs new file mode 100644 index 0000000000..e0ee156450 --- /dev/null +++ b/jest.react19.mjs @@ -0,0 +1,52 @@ +import baseConfig from './jest.spec.mjs' + +// React 19 validation harness ([PF-2262] workstream E, PR #5096). +// +// Runs the unit-test suite against React 19 WITHOUT touching the workspace's +// React 18 install. The React 19 packages live in `react19/` as a STANDALONE +// pnpm project (own lockfile, installed with --ignore-workspace by the +// test:react19 script) — deliberately NOT a workspace member: inside the +// workspace, its React 19 copies join hoisting and peer resolution, and on a +// fresh CI install react-dom@19 can win the root `node_modules/react-dom` +// slot, breaking react-transition-group (`findDOMNode`) for the React 18 +// suite. A standalone install cannot interact with the main graph at all, +// and `/react19/node_modules/...` is a deterministic location on +// every machine (no hoisting involved). +// +// Needed only while the workspace itself installs React 18. Once the +// workspace moves to React 19, `pnpm test:unit` is the React 19 run and this +// file, `react19/` and `.storybook/react-dom-19-shim` can go — or be inverted +// into a React 18 harness if 18 must stay validated. +// +// The mappings rewrite every react / react-dom / @testing-library import to +// that install for this run only (@testing-library/react@16 is the first +// line whose peers admit React 19; it needs @testing-library/dom@10). +// +// Usage: pnpm test:react19 [jest args], e.g. +// pnpm test:react19 --testPathPattern react19/sanity +const config = { + ...baseConfig, + roots: [...baseConfig.roots, '/react19'], + // React 19 element tags are normalized before the shared serializers run + // (see jest/react-compat/README.md) + snapshotSerializers: [ + './jest/react-compat/react-element-serializer.cjs', + ...baseConfig.snapshotSerializers, + ], + moduleNameMapper: { + // react with useId rewritten to the React 18 id format the snapshots use + '^react$': '/jest/react-compat/react-useid-compat.cjs', + '^react/(.*)$': '/react19/node_modules/react/$1', + '^react-dom$': '/react19/node_modules/react-dom', + '^react-dom/(.*)$': '/react19/node_modules/react-dom/$1', + '^@testing-library/react$': + '/react19/node_modules/@testing-library/react', + '^@testing-library/react/(.*)$': + '/react19/node_modules/@testing-library/react/$1', + '^@testing-library/dom$': + '/react19/node_modules/@testing-library/dom', + ...baseConfig.moduleNameMapper, + }, +} + +export default config diff --git a/jest.spec.mjs b/jest.spec.mjs index 10f3e37a64..ad82638aa7 100644 --- a/jest.spec.mjs +++ b/jest.spec.mjs @@ -20,12 +20,21 @@ const NODE_MODULES_TO_IGNORE_TRANSFORM = [ const config = { ...davinciJestConfig, - roots: ['/packages'], + roots: ['/packages', '/jest'], moduleNameMapper: { ...davinciJestConfig.moduleNameMapper, }, setupFiles: ['jest-canvas-mock', './jest.setup.js'], + // Replaces davinci-qa's own `snapshotSerializers` entry on purpose: the guard + // below wraps that same serializer (see jest/react-compat/README.md). The + // input serializer must come first: it prints a clone, and the clone lacks + // the "processed" marker the JSS serializer sets — running after it would + // mangle class names twice. + snapshotSerializers: [ + './jest/react-compat/input-empty-value-serializer.cjs', + './jest/react-compat/jss-serializer-guard.cjs', + ], transformIgnorePatterns: [ // Match both flat (yarn 1 / pnpm node-linker=hoisted) and pnpm // nested (.pnpm/@ver/node_modules/...) layouts so transforms diff --git a/jest/react-compat/README.md b/jest/react-compat/README.md new file mode 100644 index 0000000000..8b02295968 --- /dev/null +++ b/jest/react-compat/README.md @@ -0,0 +1,19 @@ +# React compatibility layer for jest + +The unit suite runs on React 18 (`jest.spec.mjs`) and again on React 19 +(`jest.react19.mjs`, using the standalone install in `react19/`). Both runs +compare against the **same** snapshot files, so anything React renders +differently between the two majors has to be normalized here — otherwise +every such difference is a snapshot failure that hides real regressions. + +| File | Registered in | What it normalizes | Retire when | +| ---------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `input-empty-value-serializer.cjs` | both | React 18 reflects an empty `value` prop on `` as `value=""`, React 19 sets only the property | the suite runs on a single React major | +| `jss-serializer-guard.cjs` | both | davinci-qa's JSS class-name serializer throws on `null` instead of declining; React 19 elements hand it nulls | davinci-qa guards its own `test()` | +| `react-element-serializer.cjs` | React 19 only | React 19 tags elements `react.transitional.element`, which pretty-format 29 does not print as JSX | davinci-qa ships jest ≥ 30.4 | +| `react-useid-compat.cjs` | React 19 only (`react` module mapping) | `useId` output changed from `:r1:` to `_r_1_` in React 19.2 | the suite runs on a single React major | + +Each file is a plain pretty-format plugin (or, for `useId`, a `react` module +wrapper), so it needs no build step. The tests next to them run under both +configs. The "retire when" column is the plan: every entry here is a stopgap +for something that belongs in the shared tooling, not in Picasso. diff --git a/jest/react-compat/input-empty-value-serializer.cjs b/jest/react-compat/input-empty-value-serializer.cjs new file mode 100644 index 0000000000..fec893c6d4 --- /dev/null +++ b/jest/react-compat/input-empty-value-serializer.cjs @@ -0,0 +1,31 @@ +// React 18 reflects an empty `value` prop on an as a `value=""` +// attribute; React 19 sets only the property. Dropping the attribute keeps +// one snapshot valid for both — it carries nothing the property does not. +// +// Only : on