`.
+- The dependencies that change:
+ - removed: `react-refetch`, `react-metrics-graphics`, `react-router-dom`, `react-helmet`,
+ `react-ga`;
+ - added: `react-router` 7.x, `react-helmet-async` 3.x, `react-ga4` 3.x;
+ - upgraded: `react` and `react-dom` to 18.3.1, `@testing-library/react` to 16.x,
+ `@testing-library/dom` to 10.x, `@testing-library/jest-dom` to 7.x.
+
+---
+
+Revision note (2026-10-26): Milestone 6 moved to React 19, measured a chart delay of about 600 ms,
+and reverted to React 18 at the user's choice. The title, Purpose, Milestone 6, Interfaces, and the
+living sections now describe a plan that ends on React 18, with React 19 cleared of dependency
+blockers but deferred.
diff --git a/eslint.config.js b/eslint.config.js
index 4c78f62..0697cce 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -24,6 +24,11 @@ module.exports = [
},
},
},
+ {
+ // The automatic JSX runtime doesn't need React in scope.
+ files: ['**/*.{js,jsx}'],
+ ...react.configs.flat['jsx-runtime'],
+ },
{
files: ['**/*.{js,jsx}'],
...jsxA11y.flatConfigs.recommended,
@@ -81,15 +86,10 @@ module.exports = [
globals: {
...globals.browser,
...globals.vitest,
- // src/setupTests.js puts React on the global object for every
- // Vitest test.
- React: 'readonly',
},
},
},
{
- // Defines the React global the block above declares, so it gets the
- // Vitest globals without that declaration.
files: ['src/setupTests.js'],
languageOptions: {
globals: {
diff --git a/package-lock.json b/package-lock.json
index fb5e235..0c11a5f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,22 +18,20 @@
"memoize-one": "6.0.0",
"metrics-graphics": "2.15.6",
"npm-run-all2": "9.0.3",
- "react": "16.14.0",
- "react-dom": "16.14.0",
- "react-ga": "3.3.1",
- "react-helmet": "6.1.0",
- "react-metrics-graphics": "1.1.2",
- "react-refetch": "3.0.1",
- "react-router-dom": "5.3.4",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "react-ga4": "3.0.1",
+ "react-helmet-async": "3.0.0",
+ "react-router": "7.18.4",
"react-spinners": "0.17.1",
"stylus": "0.64.0"
},
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.64.1",
- "@testing-library/dom": "8.20.1",
- "@testing-library/jest-dom": "6.9.1",
- "@testing-library/react": "12.1.5",
+ "@testing-library/dom": "10.4.2",
+ "@testing-library/jest-dom": "7.0.1",
+ "@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@vitejs/plugin-react": "6.1.2",
"@vitest/eslint-plugin": "1.6.27",
@@ -120,6 +118,7 @@
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1003,29 +1002,29 @@
"license": "MIT"
},
"node_modules/@testing-library/dom": {
- "version": "8.20.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
- "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
- "aria-query": "5.1.3",
- "chalk": "^4.1.0",
+ "aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
- "version": "6.9.1",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
- "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1037,9 +1036,18 @@
"redent": "^3.0.0"
},
"engines": {
- "node": ">=14",
+ "node": ">=22",
"npm": ">=6",
"yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@@ -1050,22 +1058,31 @@
"license": "MIT"
},
"node_modules/@testing-library/react": {
- "version": "12.1.5",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
- "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.12.5",
- "@testing-library/dom": "^8.0.0",
- "@types/react-dom": "<18.0.0"
+ "@babel/runtime": "^7.12.5"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
},
"peerDependencies": {
- "react": "<18.0.0",
- "react-dom": "<18.0.0"
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
}
},
"node_modules/@testing-library/user-event": {
@@ -1127,45 +1144,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/prop-types": {
- "version": "15.7.15",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
- "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
- "node_modules/@types/react": {
- "version": "17.0.93",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.93.tgz",
- "integrity": "sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/prop-types": "*",
- "@types/scheduler": "^0.16",
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "17.0.26",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz",
- "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^17.0.0"
- }
- },
- "node_modules/@types/scheduler": {
- "version": "0.16.8",
- "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
- "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/@typescript-eslint/project-service": {
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
@@ -1518,13 +1496,13 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "deep-equal": "^2.0.5"
+ "dequal": "^2.0.3"
}
},
"node_modules/array-buffer-byte-length": {
@@ -2039,6 +2017,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2074,14 +2065,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/d3": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-4.13.0.tgz",
@@ -2708,39 +2691,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/deep-equal": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
- "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-buffer-byte-length": "^1.0.0",
- "call-bind": "^1.0.5",
- "es-get-iterator": "^1.1.3",
- "get-intrinsic": "^1.2.2",
- "is-arguments": "^1.1.1",
- "is-array-buffer": "^3.0.2",
- "is-date-object": "^1.0.5",
- "is-regex": "^1.1.4",
- "is-shared-array-buffer": "^1.0.2",
- "isarray": "^2.0.5",
- "object-is": "^1.1.5",
- "object-keys": "^1.1.1",
- "object.assign": "^4.1.4",
- "regexp.prototype.flags": "^1.5.1",
- "side-channel": "^1.0.4",
- "which-boxed-primitive": "^1.0.2",
- "which-collection": "^1.0.1",
- "which-typed-array": "^1.1.13"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2797,6 +2747,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3018,27 +2978,6 @@
"node": ">= 0.4"
}
},
- "node_modules/es-get-iterator": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
- "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "has-symbols": "^1.0.3",
- "is-arguments": "^1.1.1",
- "is-map": "^2.0.2",
- "is-set": "^2.0.2",
- "is-string": "^1.0.7",
- "isarray": "^2.0.5",
- "stop-iteration-iterator": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/es-iterator-helpers": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz",
@@ -4111,35 +4050,6 @@
"node": ">= 0.4"
}
},
- "node_modules/history": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
- "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.1.2",
- "loose-envify": "^1.2.0",
- "resolve-pathname": "^3.0.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0",
- "value-equal": "^1.0.1"
- }
- },
- "node_modules/hoist-non-react-statics": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "react-is": "^16.7.0"
- }
- },
- "node_modules/hoist-non-react-statics/node_modules/react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "license": "MIT"
- },
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
@@ -4281,23 +4191,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-arguments": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
- "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.2",
- "has-tostringtag": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5782,6 +5675,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5800,23 +5694,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/object-is": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
- "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -6138,21 +6015,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
- "node_modules/path-to-regexp": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz",
- "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==",
- "license": "MIT",
- "dependencies": {
- "isarray": "0.0.1"
- }
- },
- "node_modules/path-to-regexp/node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "license": "MIT"
- },
"node_modules/path-type": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
@@ -6385,6 +6247,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -6396,6 +6259,7 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/punycode": {
@@ -6418,32 +6282,28 @@
}
},
"node_modules/react": {
- "version": "16.14.0",
- "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
- "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
- "loose-envify": "^1.1.0",
- "object-assign": "^4.1.1",
- "prop-types": "^15.6.2"
+ "loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "16.14.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
- "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
- "object-assign": "^4.1.1",
- "prop-types": "^15.6.2",
- "scheduler": "^0.19.1"
+ "scheduler": "^0.23.2"
},
"peerDependencies": {
- "react": "^16.14.0"
+ "react": "^18.3.1"
}
},
"node_modules/react-fast-compare": {
@@ -6452,29 +6312,24 @@
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
- "node_modules/react-ga": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz",
- "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==",
- "license": "Apache-2.0",
- "peerDependencies": {
- "prop-types": "^15.6.0",
- "react": "^15.6.2 || ^16.0 || ^17 || ^18"
- }
+ "node_modules/react-ga4": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-3.0.1.tgz",
+ "integrity": "sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA==",
+ "license": "MIT"
},
- "node_modules/react-helmet": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
- "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
- "license": "MIT",
+ "node_modules/react-helmet-async": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz",
+ "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==",
+ "license": "Apache-2.0",
"dependencies": {
- "object-assign": "^4.1.1",
- "prop-types": "^15.7.2",
- "react-fast-compare": "^3.1.1",
- "react-side-effect": "^2.1.0"
+ "invariant": "^2.2.4",
+ "react-fast-compare": "^3.2.2",
+ "shallowequal": "^1.1.0"
},
"peerDependencies": {
- "react": ">=16.3.0"
+ "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-is": {
@@ -6484,83 +6339,26 @@
"dev": true,
"license": "MIT"
},
- "node_modules/react-metrics-graphics": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/react-metrics-graphics/-/react-metrics-graphics-1.1.2.tgz",
- "integrity": "sha512-Y2j7V2YIi+F3ZHzzCO9cEWjqOSYrDiM+7rHAZ6c/LfWNueNTh6H0G845Qd7g/2fRL7RUYV9SsZEjIqH8ozvmAw==",
- "license": "MIT",
- "peerDependencies": {
- "metrics-graphics": "^2.11.0",
- "prop-types": "^15||^16",
- "react": "^15||^16",
- "react-dom": "^15||^16"
- }
- },
- "node_modules/react-refetch": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/react-refetch/-/react-refetch-3.0.1.tgz",
- "integrity": "sha512-jpsSjhHr9nVyTxJgDEmHdKVbv8DUyiDzr3vTSId0l4GSu8t2IeBY3fPsG7yOd7MSYifrSxPiUY5NjGEtOpUNIw==",
- "license": "MIT",
- "dependencies": {
- "hoist-non-react-statics": "^3.3.0",
- "invariant": "^2.2.4",
- "warning": "^4.0.3"
- },
- "peerDependencies": {
- "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0-0"
- }
- },
"node_modules/react-router": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
- "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "hoist-non-react-statics": "^3.1.0",
- "loose-envify": "^1.3.1",
- "path-to-regexp": "^1.7.0",
- "prop-types": "^15.6.2",
- "react-is": "^16.6.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
},
- "peerDependencies": {
- "react": ">=15"
- }
- },
- "node_modules/react-router-dom": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
- "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "loose-envify": "^1.3.1",
- "prop-types": "^15.6.2",
- "react-router": "5.3.4",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "engines": {
+ "node": ">=20.0.0"
},
"peerDependencies": {
- "react": ">=15"
- }
- },
- "node_modules/react-router/node_modules/react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "license": "MIT"
- },
- "node_modules/react-side-effect": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
- "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
}
},
"node_modules/react-spinners": {
@@ -6787,12 +6585,6 @@
"node": ">=4"
}
},
- "node_modules/resolve-pathname": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
- "license": "MIT"
- },
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
@@ -6931,13 +6723,12 @@
}
},
"node_modules/scheduler": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
- "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
- "loose-envify": "^1.1.0",
- "object-assign": "^4.1.1"
+ "loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
@@ -6960,6 +6751,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
+ "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==",
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7009,6 +6806,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7860,18 +7663,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/tiny-invariant": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
- "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
- "license": "MIT"
- },
- "node_modules/tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
- "license": "MIT"
- },
"node_modules/tinybench": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
@@ -8155,12 +7946,6 @@
"spdx-expression-parse": "^3.0.0"
}
},
- "node_modules/value-equal": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==",
- "license": "MIT"
- },
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
@@ -8335,15 +8120,6 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
- "node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
diff --git a/package.json b/package.json
index 738df84..0bdb64f 100644
--- a/package.json
+++ b/package.json
@@ -36,22 +36,20 @@
"memoize-one": "6.0.0",
"metrics-graphics": "2.15.6",
"npm-run-all2": "9.0.3",
- "react": "16.14.0",
- "react-dom": "16.14.0",
- "react-ga": "3.3.1",
- "react-helmet": "6.1.0",
- "react-metrics-graphics": "1.1.2",
- "react-refetch": "3.0.1",
- "react-router-dom": "5.3.4",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "react-ga4": "3.0.1",
+ "react-helmet-async": "3.0.0",
+ "react-router": "7.18.4",
"react-spinners": "0.17.1",
"stylus": "0.64.0"
},
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.64.1",
- "@testing-library/dom": "8.20.1",
- "@testing-library/jest-dom": "6.9.1",
- "@testing-library/react": "12.1.5",
+ "@testing-library/dom": "10.4.2",
+ "@testing-library/jest-dom": "7.0.1",
+ "@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@vitejs/plugin-react": "6.1.2",
"@vitest/eslint-plugin": "1.6.27",
diff --git a/public/contribute.json b/public/contribute.json
index 2dbac66..4097a95 100644
--- a/public/contribute.json
+++ b/public/contribute.json
@@ -22,8 +22,7 @@
"metrics-graphics",
"nightwatch",
"node",
- "react",
- "react-refetch"
+ "react"
],
"bugs": {
"list": "https://github.com/mozilla/ensemble/issues",
diff --git a/src/components/containers/DashboardContainer.jsx b/src/components/containers/DashboardContainer.jsx
index 299bc6d..01a0940 100644
--- a/src/components/containers/DashboardContainer.jsx
+++ b/src/components/containers/DashboardContainer.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { connect } from 'react-refetch';
+import withFetch from '../../lib/withFetch';
import Spinner from '../views/Spinner';
import ErrorComponent from '../views/Error';
@@ -70,6 +70,4 @@ class DashboardContainer extends React.Component {
}
}
-export default connect(props => ({
- dataFetch: { url: `${props.source}/index.json` },
-}))(DashboardContainer);
+export default withFetch(props => `${props.source}/index.json`)(DashboardContainer);
diff --git a/src/components/containers/MetricOverviewContainer.jsx b/src/components/containers/MetricOverviewContainer.jsx
index cba43c6..20e73a1 100644
--- a/src/components/containers/MetricOverviewContainer.jsx
+++ b/src/components/containers/MetricOverviewContainer.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { connect } from 'react-refetch';
+import withFetch from '../../lib/withFetch';
import ErrorComponent from '../views/Error';
import MetricOverview from '../views/MetricOverview';
@@ -74,6 +74,4 @@ class MetricOverviewContainer extends React.Component {
}
}
-export default connect(props => ({
- dataFetch: { url: `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json` },
-}))(MetricOverviewContainer);
+export default withFetch(props => `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json`)(MetricOverviewContainer);
diff --git a/src/components/containers/SummaryMetricContainer.jsx b/src/components/containers/SummaryMetricContainer.jsx
index 89c9129..8e98880 100644
--- a/src/components/containers/SummaryMetricContainer.jsx
+++ b/src/components/containers/SummaryMetricContainer.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { connect } from 'react-refetch';
+import withFetch from '../../lib/withFetch';
import SummaryMetric from '../views/SummaryMetric';
import ErrorComponent from '../views/Error';
@@ -112,6 +112,4 @@ class SummaryMetricContainer extends React.Component {
}
}
-export default connect(props => ({
- dataFetch: { url: `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json` },
-}))(SummaryMetricContainer);
+export default withFetch(props => `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json`)(SummaryMetricContainer);
diff --git a/src/components/decorators/withNextButton.jsx b/src/components/decorators/withNextButton.jsx
index c23c7e9..2e86ec0 100644
--- a/src/components/decorators/withNextButton.jsx
+++ b/src/components/decorators/withNextButton.jsx
@@ -1,31 +1,25 @@
import React from 'react';
+import { useLocation } from 'react-router';
import NextButton from '../views/NextButton';
import { nextButtons as nextButtonsConfig } from '../../config.json';
-export default WrappedComponent => {
- const HOC = class extends React.Component {
- render() {
- const nextButtonMeta = nextButtonsConfig.find(nbm => {
- return nbm.from === this.props.location.pathname;
- });
+export default WrappedComponent => props => {
+ const { pathname } = useLocation();
+ const nextButtonMeta = nextButtonsConfig.find(nbm => nbm.from === pathname);
- if (nextButtonMeta) {
- return (
-
-
-
-
- );
- } else {
- return
;
- }
- }
- };
+ if (nextButtonMeta) {
+ return (
+
+
+
+
+ );
+ }
- return HOC;
+ return
;
};
diff --git a/src/components/decorators/withTracker.jsx b/src/components/decorators/withTracker.jsx
index 568f76a..ed23d03 100644
--- a/src/components/decorators/withTracker.jsx
+++ b/src/components/decorators/withTracker.jsx
@@ -1,50 +1,21 @@
-import React from 'react';
-import reactGA from 'react-ga';
+import { useEffect } from 'react';
+import { useLocation } from 'react-router';
+import ReactGA from 'react-ga4';
-// This approach is recommended by react-ga
-// https://github.com/react-ga/react-ga/wiki/React-Router-v4-withTracker
+// gtag sends a page view on its own when it is configured. Turn that off so
+// the first page isn't counted twice: every page view, including the first,
+// is sent below when the path changes.
+ReactGA.initialize(import.meta.env.VITE_GA_MEASUREMENT_ID, {
+ gtagOptions: { send_page_view: false },
+});
-reactGA.initialize(import.meta.env.VITE_GA_TRACKING_ID);
+export default WrappedComponent => props => {
+ const page = useLocation().pathname;
-export default (WrappedComponent, options = {}) => {
- const trackPage = page => {
- reactGA.set({
- page,
- ...options,
- });
- reactGA.pageview(page);
- };
+ useEffect(() => {
+ ReactGA.send({ hitType: 'pageview', page });
+ }, [page]);
- const HOC = class extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- page: props.location.pathname,
- };
- }
-
- componentDidMount() {
- trackPage(this.state.page);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- const nextPage = nextProps.location.pathname;
-
- if (nextPage !== prevState.page) {
- trackPage(nextPage);
- return {
- page: nextPage,
- };
- }
-
- return null;
- }
-
- render() {
- return
;
- }
- };
-
- return HOC;
+ return
;
};
diff --git a/src/components/views/Application.jsx b/src/components/views/Application.jsx
index 8641760..5a80e66 100644
--- a/src/components/views/Application.jsx
+++ b/src/components/views/Application.jsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
+import { Helmet } from 'react-helmet-async';
import Header from './Header';
import Main from './Main';
diff --git a/src/components/views/Chart.jsx b/src/components/views/Chart.jsx
index b0f96e4..d290eb2 100644
--- a/src/components/views/Chart.jsx
+++ b/src/components/views/Chart.jsx
@@ -1,10 +1,9 @@
-import React from 'react';
-import MetricsGraphics from 'react-metrics-graphics';
import dateformat from 'dateformat';
import { curveCatmullRom } from 'd3-shape';
import distinctColors from 'distinct-colors';
import { prettifyNumber } from '../../lib/utils';
+import MetricsGraphics from './MetricsGraphics';
import 'metrics-graphics/dist/metricsgraphics.css';
import './css/Chart.css';
diff --git a/src/components/views/Contact.jsx b/src/components/views/Contact.jsx
index 6753cc3..1a648fc 100644
--- a/src/components/views/Contact.jsx
+++ b/src/components/views/Contact.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
+import { Helmet } from 'react-helmet-async';
import { getPageTitle } from '../../lib/utils';
diff --git a/src/components/views/Dashboard.jsx b/src/components/views/Dashboard.jsx
index 68f48d5..57e191b 100644
--- a/src/components/views/Dashboard.jsx
+++ b/src/components/views/Dashboard.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import markdownIt from 'markdown-it';
-import { Helmet } from 'react-helmet';
+import { Helmet } from 'react-helmet-async';
import DashboardSection from './DashboardSection';
import MetricOverviewCollection from './MetricOverviewCollection';
diff --git a/src/components/views/DashboardSection.jsx b/src/components/views/DashboardSection.jsx
index 0fe6494..0cbb66c 100644
--- a/src/components/views/DashboardSection.jsx
+++ b/src/components/views/DashboardSection.jsx
@@ -1,5 +1,3 @@
-import React from 'react';
-
import MetricOverviewCollection from './MetricOverviewCollection';
import './css/DashboardSection.css';
diff --git a/src/components/views/DataTable.jsx b/src/components/views/DataTable.jsx
index 48c3cb9..63c4be1 100644
--- a/src/components/views/DataTable.jsx
+++ b/src/components/views/DataTable.jsx
@@ -1,5 +1,3 @@
-import React from 'react';
-
import { prettifyNumber } from '../../lib/utils';
import './css/DataTable.css';
diff --git a/src/components/views/Error.jsx b/src/components/views/Error.jsx
index b7a8db5..a549b7d 100644
--- a/src/components/views/Error.jsx
+++ b/src/components/views/Error.jsx
@@ -1,6 +1,3 @@
-import React from 'react';
-
-
export default props => {
let maybeMessage = null;
diff --git a/src/components/views/Header.jsx b/src/components/views/Header.jsx
index 592109e..3535c19 100644
--- a/src/components/views/Header.jsx
+++ b/src/components/views/Header.jsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
+import { Link } from 'react-router';
import './css/Header.css';
diff --git a/src/components/views/Home.jsx b/src/components/views/Home.jsx
index 0b8b62b..1f8761b 100644
--- a/src/components/views/Home.jsx
+++ b/src/components/views/Home.jsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
-import { Link } from 'react-router-dom';
+import { Helmet } from 'react-helmet-async';
+import { Link } from 'react-router';
import StripedHeader from './StripedHeader';
import { getPageTitle } from '../../lib/utils';
diff --git a/src/components/views/Main.jsx b/src/components/views/Main.jsx
index 587213b..88ba6ad 100644
--- a/src/components/views/Main.jsx
+++ b/src/components/views/Main.jsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import { Switch, Route, withRouter } from 'react-router-dom';
+import { Routes, Route, useLocation } from 'react-router';
import withTracker from '../decorators/withTracker';
import withNextButton from '../decorators/withNextButton';
@@ -9,11 +8,16 @@ import LazyBoundary from '../../lib/LazyBoundary';
import { dashboards } from '../../config.json';
-const Main = mainProps => {
- const Home = lazyLoad(import('./Home'));
- const Contact = lazyLoad(import('./Contact'));
- const DashboardContainer = lazyLoad(import('../containers/DashboardContainer'));
- const NotFound = lazyLoad(import('./NotFound'));
+export default () => {
+ const { pathname } = useLocation();
+
+ // Created on each render, which only happens on navigation, so a route
+ // whose chunk failed to load retries the import when it is visited again.
+ // React.lazy remembers a failed load for good.
+ const Home = withTracker(withNextButton(lazyLoad(import('./Home'))));
+ const Contact = withTracker(lazyLoad(import('./Contact')));
+ const DashboardContainer = withTracker(withNextButton(lazyLoad(import('../containers/DashboardContainer'))));
+ const NotFound = withTracker(withNextButton(lazyLoad(import('./NotFound'))));
return (
@@ -21,32 +25,20 @@ const Main = mainProps => {
later navigation - the key change fully remounts the
boundary, resetting its error state, instead of leaving
"Load error" in place until a full page reload. */}
-
-
-
-
+
+
+ } />
+ } />
{dashboards.map(dashboard => (
{
- const ThisDashboardContainer = () => (
-
- );
- const Component = withTracker(withNextButton(ThisDashboardContainer));
-
- return ;
- }}
+ path={`/dashboard/${dashboard.key}`}
+ element={}
/>
))}
-
-
+ } />
+
);
};
-
-export default withRouter(Main);
diff --git a/src/components/views/MetricOverviewCollection.jsx b/src/components/views/MetricOverviewCollection.jsx
index cec294f..c6a8ad3 100644
--- a/src/components/views/MetricOverviewCollection.jsx
+++ b/src/components/views/MetricOverviewCollection.jsx
@@ -1,5 +1,3 @@
-import React from 'react';
-
import MetricOverviewContainer from '../containers/MetricOverviewContainer';
diff --git a/src/components/views/MetricsGraphics.jsx b/src/components/views/MetricsGraphics.jsx
new file mode 100644
index 0000000..7c70999
--- /dev/null
+++ b/src/components/views/MetricsGraphics.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import MG from 'metrics-graphics';
+
+
+// Draws a metrics-graphics chart into its own div. Every prop is passed to
+// MG.data_graphic as an option, and the chart is redrawn on every update.
+export default class extends React.Component {
+ chartElement = React.createRef();
+
+ componentDidMount() {
+ this._draw();
+ }
+
+ componentDidUpdate() {
+ this._draw();
+ }
+
+ _draw() {
+ MG.data_graphic({ ...this.props, target: this.chartElement.current });
+ }
+
+ render() {
+ return
;
+ }
+}
diff --git a/src/components/views/NextButton.jsx b/src/components/views/NextButton.jsx
index cd6c344..1dc4695 100644
--- a/src/components/views/NextButton.jsx
+++ b/src/components/views/NextButton.jsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
+import { Link } from 'react-router';
import './css/NextButton.css';
diff --git a/src/components/views/NotFound.jsx b/src/components/views/NotFound.jsx
index b988d37..ea0595c 100644
--- a/src/components/views/NotFound.jsx
+++ b/src/components/views/NotFound.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
+import { Helmet } from 'react-helmet-async';
import ErrorComponent from './Error';
diff --git a/src/index.jsx b/src/index.jsx
index a54fa67..c046ace 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,16 +1,18 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { BrowserRouter } from 'react-router-dom';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router';
+import { HelmetProvider } from 'react-helmet-async';
import { unregister } from './registerServiceWorker';
import Application from './components/views/Application';
-ReactDOM.render((
-
-
-
-), document.getElementById('root'));
+createRoot(document.getElementById('root')).render(
+
+
+
+
+
+);
// Service workers were causing issues with DataOps hosting. We struggled to
// understand what exactly was causing the problem. It's possible that #5 in the
diff --git a/src/lib/withFetch.jsx b/src/lib/withFetch.jsx
new file mode 100644
index 0000000..d38c88e
--- /dev/null
+++ b/src/lib/withFetch.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+
+
+const pendingState = { pending: true, rejected: false, fulfilled: false };
+
+async function fetchJSON(url) {
+ const response = await window.fetch(new Request(url, {
+ credentials: 'same-origin',
+ mode: 'cors',
+ }));
+
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
+ return undefined;
+ }
+
+ const body = await response.json();
+
+ if (!response.ok) {
+ throw new Error(body.error || body.message || response.statusText);
+ }
+
+ return body;
+}
+
+/**
+ * Fetch JSON from the URL that mapPropsToUrl returns and pass the request's
+ * state to the wrapped component as `dataFetch`: { pending, rejected,
+ * fulfilled, value, reason }. Refetches whenever the URL changes, going back to
+ * pending first, and ignores any response that arrives after a newer request
+ * has started.
+ */
+export default mapPropsToUrl => WrappedComponent => class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { dataFetch: pendingState };
+ this._latestRequest = 0;
+ }
+
+ componentDidMount() {
+ this._fetch(mapPropsToUrl(this.props));
+ }
+
+ componentDidUpdate() {
+ const url = mapPropsToUrl(this.props);
+ if (url !== this._url) {
+ this._fetch(url);
+ }
+ }
+
+ componentWillUnmount() {
+ this._latestRequest++;
+ }
+
+ _fetch(url) {
+ this._url = url;
+ const request = ++this._latestRequest;
+ const isCurrent = () => request === this._latestRequest;
+
+ if (this.state.dataFetch !== pendingState) {
+ this.setState({ dataFetch: pendingState });
+ }
+
+ fetchJSON(url).then(
+ value => {
+ if (isCurrent()) {
+ this.setState({ dataFetch: { pending: false, rejected: false, fulfilled: true, value } });
+ }
+ },
+ reason => {
+ if (isCurrent()) {
+ this.setState({ dataFetch: { pending: false, rejected: true, fulfilled: false, reason } });
+ }
+ }
+ );
+ }
+
+ render() {
+ return
;
+ }
+};
diff --git a/src/setupTests.js b/src/setupTests.js
index 2bfcccd..650b28a 100644
--- a/src/setupTests.js
+++ b/src/setupTests.js
@@ -1,11 +1,7 @@
-import React from 'react';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
-// The classic JSX runtime needs React in scope wherever JSX appears.
-global.React = React;
-
// React reports deprecations and misuse through console.error, so treat any
// unexpected call as a failure. A test that expects an error replaces
// console.error with its own spy.
diff --git a/src/tests/jest/Chart.test.jsx b/src/tests/jest/Chart.test.jsx
index 4e53c92..0c9f235 100644
--- a/src/tests/jest/Chart.test.jsx
+++ b/src/tests/jest/Chart.test.jsx
@@ -1,12 +1,11 @@
import { render } from '@testing-library/react';
-import MetricsGraphics from 'react-metrics-graphics';
-
import Chart from '../../components/views/Chart';
+import MetricsGraphics from '../../components/views/MetricsGraphics';
// metrics-graphics needs real layout; these tests check the options the view
// computes and hands to it.
-vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) }));
+vi.mock('../../components/views/MetricsGraphics', () => ({ default: vi.fn(() => null) }));
const twoPopulations = [
[{ x: new Date('2026-01-04'), y: 5 }, { x: new Date('2026-01-11'), y: 4 }, { x: new Date('2026-01-18'), y: 9 }],
diff --git a/src/tests/jest/Dashboard.test.jsx b/src/tests/jest/Dashboard.test.jsx
index b1719cb..5f203d1 100644
--- a/src/tests/jest/Dashboard.test.jsx
+++ b/src/tests/jest/Dashboard.test.jsx
@@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
+import { HelmetProvider } from 'react-helmet-async';
import Dashboard from '../../components/views/Dashboard';
@@ -17,7 +18,8 @@ it('When a description prop is present, p#dashboard-description is rendered', ()
+ />,
+ { wrapper: HelmetProvider }
);
expect(container.querySelector('#dashboard-description')).toHaveTextContent('Example description');
@@ -29,7 +31,8 @@ it('When a description prop is not present, an empty string, undefined, or null,
+ />,
+ { wrapper: HelmetProvider }
);
expect(container.querySelector('#dashboard-description')).toBeNull();
diff --git a/src/tests/jest/DashboardContainer.test.jsx b/src/tests/jest/DashboardContainer.test.jsx
index ae91856..95f3f6b 100644
--- a/src/tests/jest/DashboardContainer.test.jsx
+++ b/src/tests/jest/DashboardContainer.test.jsx
@@ -1,16 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { HelmetProvider } from 'react-helmet-async';
import DashboardContainer from '../../components/containers/DashboardContainer';
-// react-refetch reads window.fetch when connect() runs at import time, so the
-// stub has to exist before the container module loads.
-const fetchMock = vi.hoisted(() => {
- const mock = vi.fn();
- window.fetch = mock;
- return mock;
-});
+let fetchMock;
const source = 'https://data.example/user-activity';
@@ -43,13 +38,13 @@ const multiRegionDashboard = {
};
beforeEach(() => {
- fetchMock.mockReset();
+ fetchMock = vi.spyOn(window, 'fetch');
});
it('Requests the dashboard index from its source and shows a spinner while waiting', async () => {
fetchMock.mockImplementation(() => new Promise(() => {}));
- const { container } = render(
);
+ const { container } = render(
, { wrapper: HelmetProvider });
expect(container.querySelector('.spinner')).toBeInTheDocument();
await waitFor(() => expect(requestedUrls()).toEqual([`${source}/index.json`]));
@@ -59,7 +54,7 @@ it('When the request fails, #dashboard-fetch-error is rendered', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
fetchMock.mockImplementation(() => jsonResponse({ error: 'Internal Server Error' }, 500));
- const { container } = render(
);
+ const { container } = render(
, { wrapper: HelmetProvider });
await waitFor(() => expect(container.querySelector('#dashboard-fetch-error')).toBeInTheDocument());
});
@@ -67,7 +62,7 @@ it('When the request fails, #dashboard-fetch-error is rendered', async () => {
it('When the request succeeds, the dashboard renders and metrics load for the default region', async () => {
serveDashboard(multiRegionDashboard);
- const { container } = render(
);
+ const { container } = render(
, { wrapper: HelmetProvider });
await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('User Activity'));
expect(screen.getByLabelText('Region')).toHaveValue('Worldwide');
@@ -78,7 +73,7 @@ it('A preferred region saved in sessionStorage is used instead of the default',
sessionStorage.setItem('preferredRegion', 'Brazil');
serveDashboard(multiRegionDashboard);
- render(
);
+ render(
, { wrapper: HelmetProvider });
await waitFor(() => expect(screen.getByLabelText('Region')).toHaveValue('Brazil'));
await waitFor(() => expect(requestedUrls()).toContain(`${source}/Brazil/daily-usage/index.json`));
@@ -87,7 +82,7 @@ it('A preferred region saved in sessionStorage is used instead of the default',
it('Choosing a region saves it to sessionStorage and loads metrics for it', async () => {
const user = userEvent.setup();
serveDashboard(multiRegionDashboard);
- render(
);
+ render(
, { wrapper: HelmetProvider });
await screen.findByLabelText('Region');
await user.selectOptions(screen.getByLabelText('Region'), 'Germany');
@@ -106,7 +101,7 @@ it('A dashboard with one region shows no region selector and ignores the saved p
categories: ['default'],
});
- const { container } = render(
);
+ const { container } = render(
, { wrapper: HelmetProvider });
await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('Hardware'));
expect(container.querySelector('#region-selector')).toBeNull();
diff --git a/src/tests/jest/MetricOverviewContainer.test.jsx b/src/tests/jest/MetricOverviewContainer.test.jsx
index 23acc6a..26d1536 100644
--- a/src/tests/jest/MetricOverviewContainer.test.jsx
+++ b/src/tests/jest/MetricOverviewContainer.test.jsx
@@ -3,13 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import MetricOverviewContainer from '../../components/containers/MetricOverviewContainer';
-// react-refetch reads window.fetch when connect() runs at import time, so the
-// stub has to exist before the container module loads.
-const fetchMock = vi.hoisted(() => {
- const mock = vi.fn();
- window.fetch = mock;
- return mock;
-});
+let fetchMock;
const pendingRequests = {};
@@ -36,7 +30,7 @@ function renderForRegion(activeRegion, rerender) {
}
beforeEach(() => {
- fetchMock.mockReset();
+ fetchMock = vi.spyOn(window, 'fetch');
fetchMock.mockImplementation(request => new Promise(resolve => {
pendingRequests[request.url] = resolve;
}));
diff --git a/src/tests/jest/Routes.test.jsx b/src/tests/jest/Routes.test.jsx
index 3d34936..a9ff321 100644
--- a/src/tests/jest/Routes.test.jsx
+++ b/src/tests/jest/Routes.test.jsx
@@ -1,35 +1,31 @@
-import { render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import reactGA from 'react-ga';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { HelmetProvider } from 'react-helmet-async';
+import ReactGA from 'react-ga4';
import Application from '../../components/views/Application';
import { dashboards } from '../../config.json';
-// react-refetch reads window.fetch when connect() runs at import time, so the
-// stub has to exist before any container module loads.
-const fetchMock = vi.hoisted(() => {
- const mock = vi.fn();
- window.fetch = mock;
- return mock;
-});
+let fetchMock;
-vi.mock('react-ga', () => ({
- default: { initialize: vi.fn(), set: vi.fn(), pageview: vi.fn() },
+vi.mock('react-ga4', () => ({
+ default: { initialize: vi.fn(), send: vi.fn() },
}));
function renderAt(path) {
return render(
-
+ ,
+ { wrapper: HelmetProvider }
);
}
beforeEach(() => {
- fetchMock.mockReset();
+ fetchMock = vi.spyOn(window, 'fetch');
fetchMock.mockImplementation(() => new Promise(() => {}));
- reactGA.pageview.mockClear();
+ ReactGA.send.mockClear();
});
it('/ shows the introduction and a button to the first dashboard', async () => {
@@ -49,7 +45,7 @@ it('/contact shows the contact page', async () => {
it.each(dashboards)('/dashboard/$key requests its index from $source', async ({ key, source }) => {
const { container } = renderAt(`/dashboard/${key}`);
- await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0].url).toBe(`${source}/index.json`);
expect(container.querySelector('#not-found')).toBeNull();
});
@@ -62,7 +58,7 @@ it.each([
])('%s shows Not Found', async path => {
const { container } = renderAt(path);
- await vi.waitFor(() => expect(container.querySelector('#not-found')).toBeInTheDocument());
+ await waitFor(() => expect(container.querySelector('#not-found')).toBeInTheDocument());
});
it.each([
@@ -73,11 +69,12 @@ it.each([
])('%s sends a pageview for its path', async path => {
renderAt(path);
- await vi.waitFor(() => expect(reactGA.pageview).toHaveBeenCalledWith(path));
+ await waitFor(() => expect(ReactGA.send).toHaveBeenCalledWith({ hitType: 'pageview', page: path }));
});
-it('The header links to every dashboard in config.json and the contact page', () => {
+it('The header links to every dashboard in config.json and the contact page', async () => {
const { container } = renderAt('/');
+ await screen.findByRole('link', { name: 'Proceed to User Activity' });
const links = [...container.querySelectorAll('#main-navigation a')].map(link => [link.textContent, link.getAttribute('href')]);
expect(links).toEqual([
diff --git a/src/tests/jest/SummaryMetric.test.jsx b/src/tests/jest/SummaryMetric.test.jsx
index b25ec08..5a676b4 100644
--- a/src/tests/jest/SummaryMetric.test.jsx
+++ b/src/tests/jest/SummaryMetric.test.jsx
@@ -3,13 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer';
-// react-refetch reads window.fetch when connect() runs at import time, so the
-// stub has to exist before the container module loads.
-const fetchMock = vi.hoisted(() => {
- const mock = vi.fn();
- window.fetch = mock;
- return mock;
-});
+let fetchMock;
const activeDate = '2026-01-04';
@@ -43,7 +37,7 @@ async function renderSummaryBar(populationValues) {
}
beforeEach(() => {
- fetchMock.mockReset();
+ fetchMock = vi.spyOn(window, 'fetch');
});
it('Each population gets a segment sized by its share, largest first, with small ones combined into a final "Other"', async () => {
diff --git a/src/tests/jest/SummaryMetricContainer.test.jsx b/src/tests/jest/SummaryMetricContainer.test.jsx
index 8a04b69..8f4f065 100644
--- a/src/tests/jest/SummaryMetricContainer.test.jsx
+++ b/src/tests/jest/SummaryMetricContainer.test.jsx
@@ -4,13 +4,7 @@ import SummaryMetric from '../../components/views/SummaryMetric';
import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer';
-// react-refetch reads window.fetch when connect() runs at import time, so the
-// stub has to exist before the container module loads.
-const fetchMock = vi.hoisted(() => {
- const mock = vi.fn();
- window.fetch = mock;
- return mock;
-});
+let fetchMock;
// The real view draws with d3 into a measured parent node.
vi.mock('../../components/views/SummaryMetric', () => ({ default: vi.fn(() => null) }));
@@ -39,7 +33,7 @@ async function dataPassedToView() {
}
beforeEach(() => {
- fetchMock.mockReset();
+ fetchMock = vi.spyOn(window, 'fetch');
SummaryMetric.mockClear();
});
diff --git a/src/tests/jest/withFetch.test.jsx b/src/tests/jest/withFetch.test.jsx
new file mode 100644
index 0000000..ac92c05
--- /dev/null
+++ b/src/tests/jest/withFetch.test.jsx
@@ -0,0 +1,73 @@
+import { render, screen } from '@testing-library/react';
+
+import withFetch from '../../lib/withFetch';
+
+
+let fetchMock;
+const pendingRequests = {};
+
+function settle(url, body, httpStatus = 200) {
+ pendingRequests[url](new Response(JSON.stringify(body), {
+ status: httpStatus,
+ headers: { 'Content-Type': 'application/json' },
+ }));
+}
+
+const ShowFetchState = withFetch(props => `https://data.example/${props.slug}.json`)(({ dataFetch }) => {
+ if (dataFetch.pending) return
pending
;
+ if (dataFetch.rejected) return
rejected: {dataFetch.reason.message}
;
+ return
fulfilled: {dataFetch.value.title}
;
+});
+
+beforeEach(() => {
+ fetchMock = vi.spyOn(window, 'fetch');
+ fetchMock.mockImplementation(request => new Promise(resolve => {
+ pendingRequests[request.url] = resolve;
+ }));
+});
+
+it('Is pending until the response arrives, then passes the parsed JSON', async () => {
+ render(
);
+ expect(screen.getByText('pending')).toBeInTheDocument();
+
+ settle('https://data.example/one.json', { title: 'One' });
+
+ expect(await screen.findByText('fulfilled: One')).toBeInTheDocument();
+});
+
+it('Requests once per URL and refetches, pending again, only when the URL changes', async () => {
+ const { rerender } = render(
);
+ settle('https://data.example/one.json', { title: 'One' });
+ await screen.findByText('fulfilled: One');
+
+ rerender(
);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ rerender(
);
+ expect(await screen.findByText('pending')).toBeInTheDocument();
+ expect(fetchMock.mock.calls.map(([request]) => request.url)).toEqual([
+ 'https://data.example/one.json',
+ 'https://data.example/two.json',
+ ]);
+});
+
+it('Rejects a non-2xx response with the message from its JSON body', async () => {
+ render(
);
+
+ settle('https://data.example/one.json', { error: 'Metric not found' }, 500);
+
+ expect(await screen.findByText('rejected: Metric not found')).toBeInTheDocument();
+});
+
+it('Ignores a response that arrives after a newer request has started', async () => {
+ const { rerender } = render(
);
+ rerender(
);
+
+ settle('https://data.example/fast.json', { title: 'Fast' });
+ await screen.findByText('fulfilled: Fast');
+ settle('https://data.example/slow.json', { title: 'Slow' });
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(screen.getByText('fulfilled: Fast')).toBeInTheDocument();
+});
diff --git a/src/tests/jest/withNextButton.test.jsx b/src/tests/jest/withNextButton.test.jsx
index 11a59fd..f16ab17 100644
--- a/src/tests/jest/withNextButton.test.jsx
+++ b/src/tests/jest/withNextButton.test.jsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
-import { MemoryRouter, Route } from 'react-router-dom';
+import { MemoryRouter, Routes, Route } from 'react-router';
import withNextButton from '../../components/decorators/withNextButton';
import { nextButtons } from '../../config.json';
@@ -11,7 +11,9 @@ const PageWithNextButton = withNextButton(Page);
function renderAt(path) {
return render(
-
+
+ } />
+
);
}
diff --git a/tests/playwright/specs/fixtures/boundaries.spec.js b/tests/playwright/specs/fixtures/boundaries.spec.js
index b4689b2..586f996 100644
--- a/tests/playwright/specs/fixtures/boundaries.spec.js
+++ b/tests/playwright/specs/fixtures/boundaries.spec.js
@@ -31,7 +31,7 @@ test('A population with a single data point is drawn as a dot', async ({ page })
});
test.describe('A metric whose populations are all null', () => {
- // Pinned as current behavior, not as a design: react-metrics-graphics
+ // Pinned as current behavior, not as a design: metrics-graphics
// throws on a chart with no points, and the error boundary around the chart
// shows its generic "Load error". React logs the caught error too.
test.use({ allowedErrors: /Cannot read properties of undefined \(reading 'x'\)|The above error occurred/ });
diff --git a/vite.config.mjs b/vite.config.mjs
index 6db49ba..35f802e 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -3,30 +3,10 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
- plugins: [
- react({
- // React 16 doesn't support the automatic JSX runtime (added in React
- // 17); every file in this codebase already assumes the classic
- // runtime, where React must be in scope wherever JSX is used.
- jsxRuntime: 'classic',
- }),
- ],
+ plugins: [react()],
server: {
port: 3000,
},
- optimizeDeps: {
- // The React plugin sets the classic runtime for transforms but not for
- // Vite's dependency scan, which then assumes the automatic runtime,
- // fails to resolve react/jsx-dev-runtime (React 16 has none), and
- // skips pre-bundling. The dev server then discovers dependencies one
- // page at a time, answering "504 (Outdated Optimize Dep)" and
- // reloading on first visits.
- rolldownOptions: {
- transform: {
- jsx: { runtime: 'classic' },
- },
- },
- },
build: {
outDir: 'build',
// Needed for `npm run size` (source-map-explorer) to inspect the