Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ jobs:
name: ${{env.BUILD_LOGS}}
path: ${{env.BUILD_LOGS}}.tar.br

React19:
# Forward-compatibility check: install React 19 + types into the cached
# node_modules (no save) and run typecheck + jest. Allowed to fail while
# the migration lands; flip to required once Repo 1 (backpack-web) is
# released and consumers can opt in.
runs-on: ubuntu-latest
needs: Build
continue-on-error: true
permissions:
statuses: write
pull-requests: write

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'

- name: Restore Cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: npm-cache
with:
path: |
node_modules/
packages/node_modules/
key: ${{ env.CACHE_NAME }}-${{ hashFiles('package-lock.json', 'packages/package-lock.json') }}

- name: Override React to 19.2.5
# The inner packages/node_modules tree has its own copy of react/react-dom
# populated from the cache. Overlay the root install into it so jest
# (which resolves react-dom from packages/) sees R19 too.
run: |
npm install --no-save react@19.2.5 react-dom@19.2.5 @types/react@19 @types/react-dom@19 @types/prop-types
rm -rf packages/node_modules/react packages/node_modules/react-dom
cp -r node_modules/react packages/node_modules/
cp -r node_modules/react-dom packages/node_modules/

- name: Generate component code (icons, flare, spinners)
run: npm run build:gulp

- name: Run typecheck (React 19)
run: npm run typecheck

- name: Run jest (React 19)
run: TZ=Etc/UTC npm run jest

Danger:
runs-on: ubuntu-latest
needs: Build
Expand Down
6 changes: 1 addition & 5 deletions .storybook/bpk-storybook-utils/src/BpkDarkExampleWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import STYLES from './BpkDarkExampleWrapper.module.scss';
const getClassName = cssModules(STYLES);

const BpkDarkExampleWrapper = (props: { padded: boolean }) => {
const { padded, ...rest } = props;
const { padded = false, ...rest } = props;
return (
/* $FlowFixMe[cannot-spread-inexact] - inexact rest. See 'decisions/flowfixme.md'. */
<div
Expand All @@ -37,8 +37,4 @@ const BpkDarkExampleWrapper = (props: { padded: boolean }) => {
);
};

BpkDarkExampleWrapper.defaultProps = {
padded: false,
};

export default BpkDarkExampleWrapper;
73 changes: 73 additions & 0 deletions REACT_19_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# React 19 Migration — `@skyscanner/backpack-web`

(This repo is **Repo 1 of 5** in the Skyscanner shared-library React 19 pre-release codemod pass.)

## What this thread should do

1. Read the master plan's "Repo 1" section. It contains the full audit baseline, recipe checklist, and empty result fields to fill in.
2. Execute the recipe inside this directory.
3. As you progress, update the master plan's Repo 1 section with results (codemod diff summary, test outcomes, surprises).
4. When done, append a one-paragraph "Surprises / runbook insights" entry, and open follow-up PRs against `web-documentation` and `web-migration-scripts` if any reusable insights came out.

## Audit baseline (2026-05-05) — corrected after re-audit on 2026-05-05

The original baseline overstated several categories. Re-audit confirmed:

- Lerna-style monorepo, published from `packages/package.json`
- React peerDep: `17.0.2 - 18.3.1` → narrowed-and-shifted to `18.3.1 - 19.2.5` (We shouldn't have any consumers below 18)
- `static defaultProps` / `defaultProps =`: **84 files** (manual sweep, no codemod)
- `forwardRef`: 14 files (none combined with `defaultProps`)
- `prop-types` imports: **67 files** (mostly `.js`; manual conversion — see runbook insight below)
- `act` from `react-dom/test-utils`: **0** (audit said 7; re-grep finds none)
- `ReactDOM.render` / `hydrate`: **0** (audit said 1; the only hit is example text inside a `<BpkCodeBlock>` JSX literal in `BpkCode.stories.tsx`, not a real call)
- String refs (`ref="..."`): **0**
- `findDOMNode`: **0**
- Legacy context API (`childContextTypes` / `getChildContext`): **0**
- `useFormState`: **0**
- Zero-arg `useRef()`: 0
- TS 5.9.2; `@types/react` 18.3.1
- Tests: Jest + `@testing-library/react` 16.3.0
- CI: `.github/workflows/{release,pr,main}.yml` — `npm publish` after transpile

### Runbook insight: `react/19/migration-recipe` is destructive on this codebase

Dry-running `react/19/migration-recipe` (codemod 1.9.1, which silently invokes legacy codemod 0.18.13) on backpack:

- **Strips Apache 2.0 license headers** from every modified file. Backpack requires these.
- Generates **wrong TypeScript interfaces** when files already have proper TS types (e.g. `BpkBasicMapMarker.tsx` keeps the existing `type Props = { children: ReactNode, position: LatLong }` orphaned and points the component at a new redundant `interface { ...; position: unknown }`).
- Only matched **6 of 67** prop-types files in this repo despite the recipe including `prop-types-typescript`.

We are skipping the bundled recipe. The four mechanical sub-codemods (`replace-act-import`, `replace-reactdom-render`, `replace-string-ref`, `replace-use-form-state`) all dry-run as 0-changes here, so they are also skipped. Only `types-react-codemod` (TS-types only, much narrower scope) is being run.

The `prop-types` and `defaultProps` migrations are being done with a custom jscodeshift transform tailored to backpack, preserving license headers and faithful types.

### Runbook insight: peerDep range narrowed instead of widened

Our intention is to **drop React 17 support** as part of this PR, to `18.3.1 - 19.2.5`. React 17 is unsupported upstream, no consumer is still on it, and dropping it shrinks the test/CI matrix surface.

## Recipe summary — adapted for this repo

1. [x] Pin `codemod` + `types-react-codemod` as devDeps with `--save-exact` (no `npx`/`pnpm dlx` — Skyscanner Security stance).
2. [x] ~~`react/19/migration-recipe`~~ — **skipped** (destructive on this codebase; see runbook insight above).
3. [x] ~~Individual mechanical codemods~~ — **skipped** (dry-runs as 0-changes for all four).
4. [x] `types-react-codemod preset-19` — applied with `refobject-defaults` and `useRef-required-initial` excluded (those two only typecheck against `@types/react@19`; deferred to the future `@types/react` bump PR).
5. [x] `types-react-codemod react-element-default-any-props` — confirmed no-op (already covered by preset-19).
6. [x] Custom jscodeshift transform applied (`scripts/react-19/transforms/strip-proptypes.js`, see scripts/react-19/README.md). Touched 27 files: removed prop-types from `.tsx`, migrated function-component `.defaultProps` to ES6 destructure defaults across `.js` and `.tsx`. License headers preserved.
7. [x] Manual cleanup of edge cases the transform left (1 eslint-disable + 2 dead-import removals).
8. [x] `forwardRef` ref-callback implicit-return scan — 0 issues, all 14 sites use block-body callbacks.
9. [x] Update `peerDependencies` in `packages/package.json` to `18.3.1 - 19.2.5` (drops React 17, adds 19; range syntax matching the existing peerDep style).
10. [x] Add CI matrix entry running tests against React 19.2.5 (`continue-on-error: true` initially).
11. [x] Uninstall codemod packages.
12. [ ] Coordinate version bump with the Backpack team.

## Deferred to follow-up PRs

The current PR establishes the scaffolding (peerDep range, CI matrix, codemod tooling, `.tsx` prop-types/defaultProps cleanup). The following items land in separate PRs:

- **35 class components with `static defaultProps`** (27 `.tsx` + 8 `.js`) — React 19 makes these no-ops, so defaults silently stop applying. Each needs either conversion to a functional component (preferred) or destructure-with-defaults inside `render()` (quick fix). See the React19 CI matrix output for the failure surface; group by package to keep PRs reviewable.
- **13 `.js` story/HOC files with leftover `Component.defaultProps = ...`** — Phase B of the transform couldn't merge defaults because the function bodies are implicit-return arrows or otherwise ineligible. React 19 silently ignores these.
- **`.js` (Flow) prop-types removal** — intentionally skipped by the transform because the project's `react/prop-types` lint rule treats removed prop-types as missing prop validation on `.js` files. Full removal happens during the parallel TS migration; React 19 ignores `propTypes` silently in the meantime.
- **`@types/react@19` bump** — running `types-react-codemod preset-19`'s `refobject-defaults` and `useRef-required-initial` sub-transforms together with bumping `@types/react` to 19. These were skipped here because they emit code that only typechecks against `@types/react@19`.
- **Track and fix the React19 CI matrix failures** — typecheck has 8 known errors (the deferred sub-transforms above plus a missing `@types/prop-types`), and jest has 326 suite failures (mostly transitive deps still using removed React 18 internals like `ReactCurrentDispatcher`). Once green, flip the matrix from `continue-on-error: true` to required.
- **Move the custom transform to `web-migration-scripts/migrations/2026-05-react-19/transforms/`** when that migration directory is set up.

37 changes: 22 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@
"setupFilesAfterEnv": [
"<rootDir>/scripts/jest/setup.js"
],
"snapshotSerializers": [
"<rootDir>/scripts/jest/normalizeUseIdSerializer.js"
],
"testEnvironment": "jsdom",
"testRegex": "(?:packages|token-sync)/.*-test\\.[jt]sx?$",
"transformIgnorePatterns": [
Expand Down
2 changes: 1 addition & 1 deletion packages/bpk-component-accordion/src/BpkAccordionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type BpkAccordionItemProps = {
className?: string;
expanded?: boolean;
initiallyExpanded?: boolean;
icon?: ReactElement;
icon?: ReactElement<any>;
onClick?: () => void;
tagName?: 'span' | 'p' | 'text' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
textStyle?: (typeof TEXT_STYLES)[keyof typeof TEXT_STYLES];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import { wrapDisplayName } from '../../bpk-react-utils';
import type { BpkAccordionProps } from './BpkAccordion';

const getInitiallyExpanded = (children: ReactNode) => {
const accordionItems = Children.toArray(children) as ReactElement[];
const accordionItems = Children.toArray(children) as Array<ReactElement<any>>;
const result = accordionItems.reduceRight(
(prev, item) => (item.props.initiallyExpanded ? item : prev),
{} as ReactElement,
{} as ReactElement<any>,
);
return (result || {}).key || null;
};
Expand Down Expand Up @@ -61,7 +61,7 @@ const withSingleItemAccordionState = <P extends BpkAccordionProps>(
this.setState({ expanded: key });
};

renderAccordionItem = (accordionItem: ReactElement) => {
renderAccordionItem = (accordionItem: ReactElement<any>) => {
const expanded = this.state.expanded === accordionItem.key;
const onClick = () => this.openAccordionItem(accordionItem?.key);

Expand All @@ -74,7 +74,7 @@ const withSingleItemAccordionState = <P extends BpkAccordionProps>(
return (
<ComposedComponent {...(rest as P)}>
{Children.toArray(children).map((el) =>
this.renderAccordionItem(el as ReactElement),
this.renderAccordionItem(el as ReactElement<any>),
)}
</ComposedComponent>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/bpk-component-ai-blurb/src/BpkAiBlurb-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { BpkProvider } from '../../bpk-component-layout';

import BpkAiBlurb from './BpkAiBlurb';

const renderWithProvider = (ui: ReactElement) =>
const renderWithProvider = (ui: ReactElement<any>) =>
render(<BpkProvider>{ui}</BpkProvider>);

describe('BpkAiBlurb.Root', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/bpk-component-ai-blurb/src/accessibility-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { BpkProvider } from '../../bpk-component-layout';

import BpkAiBlurb from './BpkAiBlurb';

const renderWithProvider = (ui: ReactElement) =>
const renderWithProvider = (ui: ReactElement<any>) =>
render(<BpkProvider>{ui}</BpkProvider>);

describe('BpkAiBlurb accessibility tests', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import STYLES from './BpkAriaLive.stories.module.scss';
const getClassName = cssModules(STYLES);

type AriaLiveDemoProps = {
preamble?: ReactElement | null;
children: ReactElement;
preamble?: ReactElement<any> | null;
children: ReactElement<any>;
className?: string | null;
style?: {};
visible?: Boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/bpk-component-aria-live/src/BpkAriaLive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type PolitenessSetting =
(typeof POLITENESS_SETTINGS)[keyof typeof POLITENESS_SETTINGS];

export type Props = {
children: ReactElement | string;
children: ReactElement<any> | string;
politenessSetting?: PolitenessSetting;
visible?: boolean;
className?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type Props = {

const BpkAutosuggestSuggestion = (props: Props) => {
const classNames = [getClassName('bpk-autosuggest__suggestion')];
const { className, icon, indent, subHeading, tertiaryLabel, value, ...rest } =
const { className = null, icon = null, indent = false, subHeading = null, tertiaryLabel = null, value, ...rest } =
props;
const Icon = icon;

Expand Down Expand Up @@ -98,12 +98,4 @@ BpkAutosuggestSuggestion.propTypes = {
className: PropTypes.string,
};

BpkAutosuggestSuggestion.defaultProps = {
subHeading: null,
tertiaryLabel: null,
icon: null,
indent: false,
className: null,
};

export default BpkAutosuggestSuggestion;
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import { Component } from 'react';
import type { ReactElement, InputHTMLAttributes, LegacyRef } from 'react';
import type { ReactElement, InputHTMLAttributes, Ref } from 'react';

import { userEvent, within } from 'storybook/test';

Expand Down Expand Up @@ -179,10 +179,10 @@ type Props = {
multiSection: boolean;
renderInputComponent?: (
inputProps: InputHTMLAttributes<HTMLInputElement> & {
ref?: LegacyRef<HTMLInputElement>;
ref?: Ref<HTMLInputElement>;
},
) => ReactElement;
renderSectionTitle: (section: Section) => ReactElement | null;
) => ReactElement<any>;
renderSectionTitle: (section: Section) => ReactElement<any> | null;
getSectionSuggestions: (section: Section) => Suggestion[];
};

Expand Down Expand Up @@ -322,7 +322,7 @@ export const HighlightFistSuggestion: Story = {

// --- Multi-section example ---

const renderSectionTitle = (section: { title: string }): ReactElement => (
const renderSectionTitle = (section: { title: string }): ReactElement<any> => (
<div style={{ padding: '16px 16px 0 16px' }}>{section.title}</div>
);

Expand Down Expand Up @@ -375,7 +375,7 @@ export const SmallInput: Story = {

const renderCustomInput = (
inputProps: InputHTMLAttributes<HTMLInputElement> & {
ref?: LegacyRef<HTMLInputElement>;
ref?: Ref<HTMLInputElement>;
},
) => (
<div
Expand Down
Loading
Loading