diff --git a/.eslintrc.js b/.eslintrc.js index 6aac8591e81..d4a59c9d81f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,11 @@ module.exports = { '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/restrict-plus-operands': 'off', '@typescript-eslint/restrict-template-expressions': 'off', + // v7 recommended rules we keep off for now + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-duplicate-type-constituents': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', 'import/no-cycle': 'off', 'react/no-unknown-property': [ 'error', @@ -81,5 +86,27 @@ module.exports = { 'gamut/no-kbd-element': 'error', }, }, + { + files: ['script/**/*.js'], + env: { node: true, es2020: true }, + rules: { + 'no-console': 'off', + 'no-plusplus': 'off', + }, + }, + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: + "MemberExpression[object.type='Identifier'][object.name='JSX']", + message: + 'Use React.JSX.* instead of global JSX namespace for React 19 compatibility.', + }, + ], + }, + }, ], }; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3322ad9daf8..cf3340d049b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,3 +74,98 @@ jobs: fail_ci_if_error: false directory: ./coverage report_type: test_results + + test-react-18: + name: Test suite (React 18) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + # Allow lockfile update after resolution change (hardened mode forbids it on public PRs) + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Fetch main branch + run: git fetch origin main:main + if: github.event_name == 'pull_request' + - name: Setup Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version-file: .nvmrc + - name: Enable Corepack and prepare Yarn 4 + run: corepack enable + shell: bash + - name: Set React 18 resolutions + run: node script/set-react-resolutions.js 18 + - name: Install dependencies + run: yarn install + shell: bash + - name: Patch React 18 ref types + run: node script/patch-react-18-ref-types.js + shell: bash + - name: Build all packages + run: yarn build + shell: bash + - name: Typecheck Gamut (React 18) + run: yarn nx run gamut:verify + shell: bash + - name: Run test suite + run: | + if [ "${{ github.ref_name }}" == "main" ]; then + npx nx run-many --target=test --all --parallel=4 --ci --coverage --runInBand --skip-nx-cache + else + npx nx affected --target=test --parallel=4 --ci --coverage --runInBand + fi + shell: bash + - name: Upload test results (React 18) + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: ${{ !cancelled() }} + with: + name: test-results-react-18 + path: ./coverage + retention-days: 30 + + test-react-19: + name: Test suite (React 19) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Fetch main branch + run: git fetch origin main:main + if: github.event_name == 'pull_request' + - uses: ./.github/actions/yarn + - name: Set React 19 resolutions + run: node script/set-react-resolutions.js 19 + - name: Install dependencies + run: yarn install + shell: bash + - name: Build all packages + run: yarn build + shell: bash + - name: Typecheck Gamut (React 19) + run: yarn nx run gamut:verify + shell: bash + - name: Consumer-style type-check (React 19) + run: yarn verify:gamut:consumer + shell: bash + - name: Run test suite + run: | + if [ "${{ github.ref_name }}" == "main" ]; then + npx nx run-many --target=test --all --parallel=4 --ci --coverage --runInBand --skip-nx-cache + else + npx nx affected --target=test --parallel=4 --ci --coverage --runInBand + fi + shell: bash + - name: Upload test results (React 19) + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: ${{ !cancelled() }} + with: + name: test-results-react-19 + path: ./coverage + retention-days: 30 diff --git a/.gitignore b/.gitignore index 724fad2facd..34da920df80 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ tmp # Yarn 3+ files .yarn/* !.yarn/patches +patches/*.patch.disabled !.yarn/plugins !.yarn/releases !.yarn/sdks @@ -51,6 +52,9 @@ tmp dist/storybook dist/docs +# ESLint cache +.eslintcache + # NX dist/out-tsc *.tsbuildinfo diff --git a/package.json b/package.json index 6fe9d7a80b2..9f1bcd11842 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "@vidstack/react": "^1.12.12", "core-js": "3.7.0", "lodash": "^4.17.23", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "^18.3.0", + "react-dom": "^18.3.0", "react-helmet-async": "^2.0.5" }, "devDependencies": { @@ -44,28 +44,26 @@ "@storybook/react-webpack5": "^8.6.15", "@storybook/theming": "^8.6.15", "@svgr/cli": "5.5.0", - "@testing-library/dom": "^8.11.1", + "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^5.16.1", - "@testing-library/react": "15.0.6", - "@testing-library/react-hooks": "^7.0.2", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/classnames": "2.2.10", "@types/invariant": "2.2.29", "@types/konami-code-js": "^0.8.0", "@types/lodash": "4.17.23", - "@types/react": "18.3.27", - "@types/react-dom": "18.3.1", - "@types/react-test-renderer": "18.3.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@types/stylis": "^4.2.0", - "@typescript-eslint/eslint-plugin": "^5.15.0", - "@typescript-eslint/parser": "^5.15.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", "babel-jest": "29.6.4", "babel-plugin-macros": "3.0.1", "component-test-setup": "^0.3.1", "conventional-changelog-cli": "^2.0.34", "conventional-changelog-conventionalcommits": "^4.3.0", "cpy-cli": "^4.1.0", - "eslint": "^8.11.0", + "eslint": "^8.56.0", "eslint-plugin-gamut": "^2.0.0", "eslint-plugin-local-rules": "^1.1.0", "eslint-plugin-lodash": "^7.4.0", @@ -83,8 +81,8 @@ "nx": "21.4.0", "nx-cloud": "^19.1.0", "onchange": "^7.0.2", + "patch-package": "^8.0.0", "prettier": "^2.8.7", - "react-test-renderer": "18.3.1", "storybook": "^8.6.15", "storybook-addon-deep-controls": "^0.9.5", "style-loader": "^4.0.0", @@ -113,15 +111,17 @@ "private": true, "repository": "git@github.com:Codecademy/gamut.git", "resolutions": { - "@react-aria/interactions": "3.25.0", - "@typescript-eslint/utils": "^5.15.0", - "@types/react": "18.3.27", - "@types/react-dom": "18.3.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "error-ex": "1.3.4" + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/utils": "^7.0.0", + "error-ex": "1.3.4", + "react": "^18.3.0", + "react-dom": "^18.3.0" }, "scripts": { + "postinstall": "patch-package", "build": "nx run-many --target=build --all", "build-all": "yarn build", "build-storybook": "nx run styleguide:build-storybook", @@ -131,14 +131,22 @@ "deploy": "rm -rf ./dist/docs && mv ./dist/storybook/styleguide ./dist/docs && cp -r ./dist/static/* ./dist/docs && gh-pages -b gh-pages -d dist", "format": "yarn lint:fix && yarn prettier --write", "format:verify": "yarn prettier --check", - "lint": "eslint --ignore-path .eslintignore \"./**/*.{mdx,js,ts,tsx,json}\" --max-warnings 0", + "lint": "eslint --cache --cache-location .eslintcache --ignore-path .eslintignore \"./**/*.{mdx,js,ts,tsx,json}\" --max-warnings 0", "lint:fix": "yarn lint --fix", "prettier": "prettier --ignore-path .prettierignore \"./**/*.{mdx,js,ts,tsx,json,css,scss}\"", "start": "yarn && yarn start:storybook", "start:storybook": "nx storybook styleguide", "test": "nx run-many --target=test --all", + "test:gamut": "jest --config=packages/gamut/jest.config.ts", + "test:gamut:react18": "node script/test-gamut-react-version.js 18", + "test:gamut:react19": "node script/test-gamut-react-version.js 19", + "test:gamut:all": "yarn test:gamut:react18 && yarn test:gamut:react19", "verify": "nx run-many --target=verify --parallel=3 --all", - "verify-all": "yarn verify" + "verify-all": "yarn verify", + "verify:gamut:react18": "node script/verify-gamut-react18-types.js", + "verify:gamut:react19": "node script/verify-gamut-react19-types.js", + "verify:gamut:types": "yarn verify:gamut:react18 && yarn verify:gamut:react19", + "verify:gamut:consumer": "tsc --noEmit -p packages/gamut/consumer-typecheck" }, "workspaces": { "packages": [ diff --git a/packages/eslint-plugin-gamut/package.json b/packages/eslint-plugin-gamut/package.json index 2c14378072c..2487cffc21a 100644 --- a/packages/eslint-plugin-gamut/package.json +++ b/packages/eslint-plugin-gamut/package.json @@ -6,6 +6,9 @@ "dependencies": { "@typescript-eslint/utils": "^5.15.0" }, + "devDependencies": { + "@typescript-eslint/rule-tester": "^7.0.0" + }, "files": [ "dist" ], diff --git a/packages/eslint-plugin-gamut/src/gamut-import-paths.ts b/packages/eslint-plugin-gamut/src/gamut-import-paths.ts index a7fcb9601fb..df175e930f9 100644 --- a/packages/eslint-plugin-gamut/src/gamut-import-paths.ts +++ b/packages/eslint-plugin-gamut/src/gamut-import-paths.ts @@ -59,7 +59,7 @@ export default createRule({ meta: { docs: { description: 'Ensure Gamut import statements have proper module paths.', - recommended: 'error', + recommended: 'recommended', }, fixable: 'code', messages: { diff --git a/packages/eslint-plugin-gamut/src/no-css-standalone.test.ts b/packages/eslint-plugin-gamut/src/no-css-standalone.test.ts index cada3987044..1094f1e4993 100644 --- a/packages/eslint-plugin-gamut/src/no-css-standalone.test.ts +++ b/packages/eslint-plugin-gamut/src/no-css-standalone.test.ts @@ -1,9 +1,9 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESLint } from '@typescript-eslint/utils'; import rule from './no-css-standalone'; -const ruleTester = new ESLintUtils.RuleTester({ - parser: '@typescript-eslint/parser', +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), }); ruleTester.run('no-css-standalone', rule, { diff --git a/packages/eslint-plugin-gamut/src/no-css-standalone.ts b/packages/eslint-plugin-gamut/src/no-css-standalone.ts index fe1c66a8656..1dc2c8c20b8 100644 --- a/packages/eslint-plugin-gamut/src/no-css-standalone.ts +++ b/packages/eslint-plugin-gamut/src/no-css-standalone.ts @@ -15,7 +15,7 @@ export default createRule({ meta: { docs: { description: 'Ensure no standalone .css or .scss files.', - recommended: 'error', + recommended: 'recommended', }, messages: { noCssStandalone: diff --git a/packages/eslint-plugin-gamut/src/no-inline-style.test.ts b/packages/eslint-plugin-gamut/src/no-inline-style.test.ts index a1c24e75981..ab7c5c53e13 100644 --- a/packages/eslint-plugin-gamut/src/no-inline-style.test.ts +++ b/packages/eslint-plugin-gamut/src/no-inline-style.test.ts @@ -1,9 +1,9 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESLint } from '@typescript-eslint/utils'; import rule from './no-inline-style'; -const ruleTester = new ESLintUtils.RuleTester({ - parser: '@typescript-eslint/parser', +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), parserOptions: { ecmaFeatures: { jsx: true, diff --git a/packages/eslint-plugin-gamut/src/no-inline-style.ts b/packages/eslint-plugin-gamut/src/no-inline-style.ts index 8e063bbde13..6d7d8492aa6 100644 --- a/packages/eslint-plugin-gamut/src/no-inline-style.ts +++ b/packages/eslint-plugin-gamut/src/no-inline-style.ts @@ -17,7 +17,7 @@ export default createRule({ meta: { docs: { description: 'Disallow inline style props on JSX elements.', - recommended: 'error', + recommended: 'recommended', }, messages: { noInlineStyle: diff --git a/packages/eslint-plugin-gamut/src/no-kbd-element.test.ts b/packages/eslint-plugin-gamut/src/no-kbd-element.test.ts index fc2c7f13da1..30dee531773 100644 --- a/packages/eslint-plugin-gamut/src/no-kbd-element.test.ts +++ b/packages/eslint-plugin-gamut/src/no-kbd-element.test.ts @@ -1,9 +1,9 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESLint } from '@typescript-eslint/utils'; import rule from './no-kbd-element'; -const ruleTester = new ESLintUtils.RuleTester({ - parser: '@typescript-eslint/parser', +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), parserOptions: { ecmaFeatures: { jsx: true, diff --git a/packages/eslint-plugin-gamut/src/no-kbd-element.ts b/packages/eslint-plugin-gamut/src/no-kbd-element.ts index f90de6d1cbc..53e1beb44a2 100644 --- a/packages/eslint-plugin-gamut/src/no-kbd-element.ts +++ b/packages/eslint-plugin-gamut/src/no-kbd-element.ts @@ -18,7 +18,7 @@ export default createRule({ docs: { description: 'Intended to be used in Storybook docs to disallow use of the `kbd` HTML element in favor of the `KeyboardKey` component for styling purposes.', - recommended: 'error', + recommended: 'recommended', }, messages: { noKbdElement: 'Please use the `KeyboardKey` component instead.', diff --git a/packages/eslint-plugin-gamut/src/prefer-themed.test.ts b/packages/eslint-plugin-gamut/src/prefer-themed.test.ts index 1a438cd3247..3d9383afb0c 100644 --- a/packages/eslint-plugin-gamut/src/prefer-themed.test.ts +++ b/packages/eslint-plugin-gamut/src/prefer-themed.test.ts @@ -1,9 +1,9 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESLint } from '@typescript-eslint/utils'; import rule from './prefer-themed'; -const ruleTester = new ESLintUtils.RuleTester({ - parser: '@typescript-eslint/parser', +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), }); ruleTester.run('prefer-themed', rule, { diff --git a/packages/eslint-plugin-gamut/src/prefer-themed.ts b/packages/eslint-plugin-gamut/src/prefer-themed.ts index 45892f197e5..9ff4c6a2a99 100644 --- a/packages/eslint-plugin-gamut/src/prefer-themed.ts +++ b/packages/eslint-plugin-gamut/src/prefer-themed.ts @@ -48,7 +48,7 @@ export default createRule({ meta: { docs: { description: 'Prefer themed style utility', - recommended: 'error', + recommended: 'recommended', }, fixable: 'code', messages: { diff --git a/packages/gamut-icons/package.json b/packages/gamut-icons/package.json index 79e375de093..ff06967882b 100644 --- a/packages/gamut-icons/package.json +++ b/packages/gamut-icons/package.json @@ -17,7 +17,7 @@ "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "lodash": "^4.17.23", - "react": "^17.0.2 || ^18.3.0" + "react": "^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-illustrations/package.json b/packages/gamut-illustrations/package.json index 39360d522a2..16805e158b0 100644 --- a/packages/gamut-illustrations/package.json +++ b/packages/gamut-illustrations/package.json @@ -18,8 +18,8 @@ "peerDependencies": { "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "react": "^17.0.2 || ^18.3.0", - "react-dom": "^17.0.2 || ^18.3.0" + "react": "^18.3.0 || ^19.0.0", + "react-dom": "^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-patterns/package.json b/packages/gamut-patterns/package.json index 7d142f5fdb5..87d48e459fb 100644 --- a/packages/gamut-patterns/package.json +++ b/packages/gamut-patterns/package.json @@ -19,8 +19,8 @@ "peerDependencies": { "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", - "react": "^17.0.2 || ^18.3.0", - "react-dom": "^17.0.2 || ^18.3.0" + "react": "^18.3.0 || ^19.0.0", + "react-dom": "^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index 2d70876244f..0dda6e4325b 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -6,8 +6,8 @@ "dependencies": { "@codecademy/variance": "0.26.0", "@emotion/is-prop-valid": "^1.1.0", - "framer-motion": "^11.18.0", "get-nonce": "^1.0.0", + "motion": "^12.0.0", "polished": "^4.1.2" }, "files": [ @@ -26,7 +26,7 @@ "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "lodash": "^4.17.23", - "react": "^17.0.2 || ^18.3.0", + "react": "^18.3.0 || ^19.0.0", "stylis": "^4.0.7" }, "publishConfig": { diff --git a/packages/gamut-styles/src/GamutProvider.tsx b/packages/gamut-styles/src/GamutProvider.tsx index 2ac7896a4e7..6d4ac838aab 100644 --- a/packages/gamut-styles/src/GamutProvider.tsx +++ b/packages/gamut-styles/src/GamutProvider.tsx @@ -5,8 +5,8 @@ import { Theme, ThemeProvider, } from '@emotion/react'; -import { MotionConfig } from 'framer-motion'; import { setNonce } from 'get-nonce'; +import { MotionConfig } from 'motion/react'; import { useContext, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; diff --git a/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx b/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx index 8588c23304d..6280f4aa005 100644 --- a/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx +++ b/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx @@ -5,6 +5,7 @@ import { render } from '@testing-library/react'; import { AssetProvider, createFontLinks } from '../AssetProvider'; import { coreTheme, percipioTheme } from '../themes'; +import { clearPreloadLinks, getPreloadLinks } from './preloadLinks'; const renderView = setupRtl(AssetProvider, {}); @@ -48,6 +49,7 @@ const mockGetFonts = require('../utils/fontUtils').getFonts; describe('AssetProvider', () => { beforeEach(() => { jest.clearAllMocks(); + clearPreloadLinks(); }); describe('createFontLinks', () => { @@ -65,8 +67,8 @@ describe('AssetProvider', () => { }, ]; - const { container } = render(<>{createFontLinks(fonts)}); - const links = container.querySelectorAll('link[rel="preload"]'); + render(<>{createFontLinks(fonts)}); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( @@ -79,14 +81,14 @@ describe('AssetProvider', () => { }); it('should handle empty fonts array', () => { - const { container } = render(<>{createFontLinks([])}); - const links = container.querySelectorAll('link[rel="preload"]'); + render(<>{createFontLinks([])}); + const links = getPreloadLinks(); expect(links).toHaveLength(0); }); it('should handle undefined fonts parameter', () => { - const { container } = render(<>{createFontLinks(undefined)}); - const links = container.querySelectorAll('link[rel="preload"]'); + render(<>{createFontLinks(undefined)}); + const links = getPreloadLinks(); expect(links).toHaveLength(2); }); @@ -109,8 +111,8 @@ describe('AssetProvider', () => { }, ]; - const { container } = render(<>{createFontLinks(fonts)}); - const links = container.querySelectorAll('link[rel="preload"]'); + render(<>{createFontLinks(fonts)}); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( 'href', @@ -138,8 +140,8 @@ describe('AssetProvider', () => { }, ]; - const { container } = render(<>{createFontLinks(fonts)}); - const links = container.querySelectorAll('link[rel="preload"]'); + render(<>{createFontLinks(fonts)}); + const links = getPreloadLinks(); expect(links).toHaveLength(2); }); }); @@ -154,8 +156,8 @@ describe('AssetProvider', () => { }, ]); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( @@ -174,8 +176,8 @@ describe('AssetProvider', () => { }, ]); - const { view } = renderView({ theme: percipioTheme as any }); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView({ theme: percipioTheme as any }); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( @@ -206,32 +208,32 @@ describe('AssetProvider', () => { throw new Error('Font loading failed'); }); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(0); }); it('should fallback to core fonts when getFonts returns undefined', () => { mockGetFonts.mockReturnValue(undefined); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(2); }); it('should fallback to core fonts when getFonts returns null', () => { mockGetFonts.mockReturnValue(null); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(2); }); it('should fallback to core fonts when getFonts returns non-array', () => { mockGetFonts.mockReturnValue('not-an-array'); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(0); }); @@ -254,8 +256,8 @@ describe('AssetProvider', () => { }, ]); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(3); expect(links[0]).toHaveAttribute( @@ -291,8 +293,8 @@ describe('AssetProvider', () => { } as any, ]); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( @@ -320,8 +322,8 @@ describe('AssetProvider', () => { }, ]); - const { view } = renderView(); - const links = view.container.querySelectorAll('link[rel="preload"]'); + renderView(); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( diff --git a/packages/gamut-styles/src/__tests__/fontLoading.test.tsx b/packages/gamut-styles/src/__tests__/fontLoading.test.tsx index 6a46a50c3ab..2aa6e0651dd 100644 --- a/packages/gamut-styles/src/__tests__/fontLoading.test.tsx +++ b/packages/gamut-styles/src/__tests__/fontLoading.test.tsx @@ -2,6 +2,7 @@ import { render } from '@testing-library/react'; import { AssetProvider } from '../AssetProvider'; import { coreTheme, percipioTheme } from '../themes'; +import { clearPreloadLinks, getPreloadLinks } from './preloadLinks'; // Type assertion to satisfy Theme interface in GamutProvider from theme.d.ts - this lib is typed to the CoreTheme interface const typedPercipioTheme = percipioTheme as any; @@ -53,6 +54,7 @@ describe('Font Loading and Error Handling', () => { mockDocumentFonts.load.mockClear(); mockDocumentFonts.check.mockClear(); mockFetch.mockClear(); + clearPreloadLinks(); }); describe('Font Preloading', () => { @@ -65,9 +67,9 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(1); expect(links[0]).toHaveAttribute( 'href', @@ -91,9 +93,9 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(2); expect(links[0]).toHaveAttribute( 'href', @@ -112,10 +114,10 @@ describe('Font Loading and Error Handling', () => { throw new Error('Font loading failed'); }); - const { container } = render(); + render(); // Should not render any links when getFonts fails - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(0); }); @@ -139,9 +141,9 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(2); }); }); @@ -161,10 +163,10 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); // Should render preload links for all fonts - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(2); expect(links[0]).toHaveAttribute( 'href', @@ -193,9 +195,9 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(1); Object.defineProperty(document, 'fonts', { @@ -216,9 +218,9 @@ describe('Font Loading and Error Handling', () => { }, ]); - const { container } = render(); + render(); - const links = container.querySelectorAll('link[rel="preload"]'); + const links = getPreloadLinks(); expect(links).toHaveLength(1); global.fetch = originalFetch; diff --git a/packages/gamut-styles/src/__tests__/preloadLinks.ts b/packages/gamut-styles/src/__tests__/preloadLinks.ts new file mode 100644 index 00000000000..a24336c7a00 --- /dev/null +++ b/packages/gamut-styles/src/__tests__/preloadLinks.ts @@ -0,0 +1,10 @@ +/** + * React 19 hoists into document.head, so tests must query document + * for preload links and clear them between tests. + */ +export const getPreloadLinks = (): NodeListOf => + document.querySelectorAll('link[rel="preload"]'); + +export const clearPreloadLinks = (): void => { + getPreloadLinks().forEach((el) => el.remove()); +}; diff --git a/packages/gamut-styles/src/variance/utils.ts b/packages/gamut-styles/src/variance/utils.ts index a88b1bfb116..efccefdd8cd 100644 --- a/packages/gamut-styles/src/variance/utils.ts +++ b/packages/gamut-styles/src/variance/utils.ts @@ -1,5 +1,6 @@ import { ThemeProps } from '@codecademy/variance'; import isPropValid from '@emotion/is-prop-valid'; +import type React from 'react'; import { all as allProps } from './config'; @@ -17,10 +18,10 @@ const validPropnames = allPropnames.filter(isPropValid); export type SystemPropNames = (typeof allPropnames)[number]; -export type ElementOrProps = keyof JSX.IntrinsicElements | ThemeProps; +export type ElementOrProps = keyof React.JSX.IntrinsicElements | ThemeProps; export type ForwardableProps = Exclude< - El extends keyof JSX.IntrinsicElements - ? keyof JSX.IntrinsicElements[El] + El extends keyof React.JSX.IntrinsicElements + ? keyof React.JSX.IntrinsicElements[El] : keyof Element, Additional | SystemPropNames >; diff --git a/packages/gamut-tests/package.json b/packages/gamut-tests/package.json index 5b67b691ee7..b9692882564 100644 --- a/packages/gamut-tests/package.json +++ b/packages/gamut-tests/package.json @@ -22,7 +22,7 @@ "main": "dist/index.js", "module": "dist/index.js", "peerDependencies": { - "react": "^17.0.2 || ^18.3.0" + "react": "^18.3.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-tests/src/index.tsx b/packages/gamut-tests/src/index.tsx index 700132e1cd4..ff5d61022f6 100644 --- a/packages/gamut-tests/src/index.tsx +++ b/packages/gamut-tests/src/index.tsx @@ -24,7 +24,7 @@ export const MockGamutProvider: React.FC<{ ); }; -function withMockGamutProvider( +function withMockGamutProvider( WrappedComponent: React.ComponentType ) { const WithBoundaryComponent: React.FC = (props) => ( diff --git a/packages/gamut/README.md b/packages/gamut/README.md index 184751a86e4..d80a6dbe83c 100644 --- a/packages/gamut/README.md +++ b/packages/gamut/README.md @@ -18,3 +18,20 @@ When considering whether to add a component to Gamut, answer these questions: Components are written using the [`:focus-visible`](https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo) selector, which is not supported in all major browsers. The neighboring `@codecademy/webpack-config` package uses [`postcss-focus-visible`](https://www.npmjs.com/package/postcss-focus-visible) to support the selector, which assumes your app uses the [`postcss-visible`](https://www.npmjs.com/package/focus-visible) polyfill. + +## Testing + +From the repo root, run the Gamut test suite: + +- **`yarn test:gamut`** – runs Jest directly with the repo’s current install (React 19 by default). Use this for normal development (recommended if `nx run gamut:test` fails with "Failed to start plugin worker"). +- **`nx run gamut:test`** – runs via Nx (requires Nx plugin worker). + +## React version compatibility + +Gamut supports **React 18.3+** and **React 19** (see `peerDependencies` in `package.json`). CI runs the full test suite (all packages) on React 19 in the main Test Suite job and on React 18 in the "Test suite (React 18)" job. + +To run the same locally from the repo root: + +- **`yarn test:gamut:react18`** – installs React 18, runs the Gamut suite, then restores package.json. +- **`yarn test:gamut:react19`** – same with React 19. +- **`yarn test:gamut:all`** – runs both (React 18 then React 19). diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 8511c5708f6..6eb7cd19f35 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -17,6 +17,7 @@ exports[`Gamut Exported Keys 1`] = ` "Checkbox", "Coachmark", "Column", + "CompatibleComponentProps", "ConnectedCheckbox", "ConnectedForm", "ConnectedFormGroup", @@ -59,6 +60,7 @@ exports[`Gamut Exported Keys 1`] = ` "GridFormContent", "HiddenText", "IconButton", + "IconComponentType", "iFrameWrapper", "InfoTip", "Input", @@ -73,6 +75,7 @@ exports[`Gamut Exported Keys 1`] = ` "MenuSeparator", "Modal", "omitProps", + "OptionalScrollProps", "Overlay", "Pagination", "Popover", @@ -120,5 +123,7 @@ exports[`Gamut Exported Keys 1`] = ` "useLocalQuery", "useSubmitState", "Video", + "WithChildrenProp", + "WithOptionalScrollProps", ] `; diff --git a/packages/gamut/consumer-typecheck/refs.tsx b/packages/gamut/consumer-typecheck/refs.tsx new file mode 100644 index 00000000000..18f579218a4 --- /dev/null +++ b/packages/gamut/consumer-typecheck/refs.tsx @@ -0,0 +1,24 @@ +/** + * Consumer-style type-check: validates that Gamut's emitted .d.ts accept + * RefObject from useRef(null) when consumed with React 18/19. + * Run: tsc --noEmit -p packages/gamut/consumer-typecheck (after building gamut). + */ +import { useRef } from 'react'; + +import { Box, Text, TextArea } from '../dist'; + +export function ConsumerRefs() { + const titleRef = useRef(null); + const alertRef = useRef(null); + const inputRef = useRef(null); + + return ( + <> + + Title + + Alert +