diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61340b8568..55b2e346e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,7 +196,7 @@ importers: version: 1.0.1(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(oxlint@1.55.0)(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) + version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) documentation: specifier: 14.0.3 version: 14.0.3 @@ -223,7 +223,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) scrapers/cpex-scraper: dependencies: @@ -242,7 +242,7 @@ importers: version: 22.19.19 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) + version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) eslint-plugin-no-only-tests: specifier: 'catalog:' version: 3.3.0 @@ -260,7 +260,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) scrapers/nus-v2: dependencies: @@ -357,7 +357,7 @@ importers: version: 17.0.35 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) + version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) antlr4ts-cli: specifier: ^0.5.0-alpha.4 version: 0.5.0-alpha.4 @@ -390,7 +390,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) website: dependencies: @@ -406,6 +406,9 @@ importers: '@material/snackbar': specifier: 14.0.0 version: 14.0.0 + '@reduxjs/toolkit': + specifier: ^2.11.2 + version: 2.12.0(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@sentry/browser': specifier: 7.119.1 version: 7.119.1 @@ -523,9 +526,12 @@ importers: redux: specifier: 4.2.1 version: 4.2.1 - redux-persist: - specifier: 6.0.0 - version: 6.0.0(react@18.3.1)(redux@4.2.1) + redux-remember: + specifier: ^6.0.2 + version: 6.0.2(redux@4.2.1) + redux-remigrate: + specifier: ^6.0.6 + version: 6.0.6(redux-remember@6.0.2(redux@4.2.1))(redux@4.2.1)(typescript@5.9.3) redux-state-sync: specifier: 3.1.4 version: 3.1.4 @@ -691,7 +697,7 @@ importers: version: 1.15.4 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) + version: 4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))) babel-loader: specifier: 9.2.1 version: 9.2.1(@babel/core@7.26.0)(webpack@5.104.1) @@ -811,10 +817,10 @@ importers: version: 4.1.1(webpack@5.104.1) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) vitest: specifier: 'catalog:' - version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) webpack: specifier: 5.104.1 version: 5.104.1(webpack-cli@5.1.4) @@ -2492,6 +2498,17 @@ packages: react: ^17.0.1 react-dom: ^17.0.1 + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rollup/plugin-alias@3.1.9': resolution: {integrity: sha512-QI5fsEvm9bDzt32k39wpOwZhVzRcL5ydcffUHMyLVaVaLeC70I8TJZ17F1z1eMoLu4E/UOcH9BWVkKpIKdrfiw==} engines: {node: '>=8.0.0'} @@ -2786,6 +2803,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stylelint/postcss-css-in-js@0.37.3': resolution: {integrity: sha512-scLk3cSH1H9KggSniseb2KNAU5D9FWc3H7BxCSAIdtU9OWIyw0zkEZ9qEKHryRM+SExYXRKNb7tOOVNAsQ3iwg==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -6140,6 +6160,9 @@ packages: immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + immer@8.0.1: resolution: {integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==} @@ -6579,6 +6602,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} @@ -8280,6 +8307,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@3.0.1: resolution: {integrity: sha512-eb7ZAeUTgfh294cElcu51w+OTRp/6ItW758LjwJSK72LDevcuJn0P4eD71PLMDGPwwatXmAmYHTkzvpKlJE3ow==} engines: {node: '>=0.10.0'} @@ -8666,14 +8698,18 @@ packages: peerDependencies: redux: '*' - redux-persist@6.0.0: - resolution: {integrity: sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==} + redux-remember@6.0.2: + resolution: {integrity: sha512-Y5zvPujEEGYQMSJmODp3NjSj4BiXTHflhRs82btK/cHOwrJNh2CEVIBGlPTMRG6Pos3+Eea7M3vzhegEBFd0Lg==} peerDependencies: - react: '>=16' - redux: '>4.0.0' - peerDependenciesMeta: - react: - optional: true + redux: '>=5.0.0' + + redux-remigrate@6.0.6: + resolution: {integrity: sha512-DRGmq0LjuY7s/ocYVYbajChkPrSPlxwEwSqzAhPUEHAq7BO0b2WnWCKID4k10/E60F/YEt/a77oLZ7jmN4n1mw==} + hasBin: true + peerDependencies: + redux: '>=5.0.0' + redux-remember: '>=6.0.0' + typescript: '>=5.0.0' redux-state-sync@3.1.4: resolution: {integrity: sha512-nhJBzaXVXPXvUhQJ7m0LdoXBnrcw+cTYQ8bzW9DeJKdq6UNYynXwQWAlVUvsbT/hDV+vB6BC4DMLXkUVGpF2yQ==} @@ -8683,9 +8719,17 @@ packages: peerDependencies: redux: ^4 + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -8778,6 +8822,9 @@ packages: reselect@4.1.8: resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -12208,6 +12255,18 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@reduxjs/toolkit@2.12.0(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 18.3.1 + react-redux: 7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rollup/plugin-alias@3.1.9(rollup@2.80.0)': dependencies: rollup: 2.80.0 @@ -12511,6 +12570,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@stylelint/postcss-css-in-js@0.37.3(postcss-syntax@0.36.2)(postcss@7.0.39)': dependencies: '@babel/core': 7.26.0 @@ -13256,7 +13317,7 @@ snapshots: ts-node: 8.9.1(typescript@4.3.4) typescript: 4.3.4 - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)))': + '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -13268,7 +13329,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + vitest: 4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) '@vitest/expect@4.1.0': dependencies: @@ -13279,13 +13340,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) '@vitest/pretty-format@4.1.0': dependencies: @@ -16351,6 +16412,8 @@ snapshots: immer@10.1.1: {} + immer@11.1.8: {} + immer@8.0.1: {} immutability-helper@2.9.1: @@ -16743,6 +16806,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.7.0: {} + joi@17.13.3: dependencies: '@hapi/hoek': 9.3.0 @@ -18915,6 +18980,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.3: {} + pretty-bytes@3.0.1: dependencies: number-is-nan: 1.0.1 @@ -19452,11 +19519,18 @@ snapshots: lodash.isplainobject: 4.0.6 redux: 4.2.1 - redux-persist@6.0.0(react@18.3.1)(redux@4.2.1): + redux-remember@6.0.2(redux@4.2.1): dependencies: redux: 4.2.1 - optionalDependencies: - react: 18.3.1 + + redux-remigrate@6.0.6(redux-remember@6.0.2(redux@4.2.1))(redux@4.2.1)(typescript@5.9.3): + dependencies: + jiti: 2.7.0 + picocolors: 1.1.1 + prettier: 3.8.3 + redux: 4.2.1 + redux-remember: 6.0.2(redux@4.2.1) + typescript: 5.9.3 redux-state-sync@3.1.4: dependencies: @@ -19466,10 +19540,16 @@ snapshots: dependencies: redux: 4.2.1 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + redux@4.2.1: dependencies: '@babel/runtime': 7.28.6 + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -19610,6 +19690,8 @@ snapshots: reselect@4.1.8: {} + reselect@5.2.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -21119,18 +21201,18 @@ snapshots: unist-util-stringify-position: 3.0.3 vfile-message: 3.1.4 - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2): + vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -21141,15 +21223,15 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.7.0 sass: 1.83.1 terser: 5.46.0 yaml: 2.8.2 - vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)): + vitest@4.1.0(@types/node@22.19.19)(jsdom@24.1.3)(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -21166,7 +21248,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@22.19.19)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.19)(jiti@2.7.0)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 diff --git a/website/package.json b/website/package.json index bd1060af26..e016714637 100644 --- a/website/package.json +++ b/website/package.json @@ -29,6 +29,7 @@ "@material/button": "14.0.0", "@material/fab": "14.0.0", "@material/snackbar": "14.0.0", + "@reduxjs/toolkit": "^2.11.2", "@sentry/browser": "7.119.1", "@sentry/node": "6.19.7", "@sentry/tracing": "6.19.7", @@ -68,7 +69,8 @@ "react-router-dom": "5.3.4", "react-scrollspy": "3.4.3", "redux": "4.2.1", - "redux-persist": "6.0.0", + "redux-remember": "^6.0.2", + "redux-remigrate": "^6.0.6", "redux-state-sync": "3.1.4", "redux-thunk": "2.4.2", "reselect": "4.1.8", diff --git a/website/remigrate.config.ts b/website/remigrate.config.ts new file mode 100644 index 0000000000..e454197eed --- /dev/null +++ b/website/remigrate.config.ts @@ -0,0 +1,7 @@ +import { defineRemigrateConfig } from 'redux-remigrate'; + +export default defineRemigrateConfig({ + storagePath: './src/remigrate', + stateFilePath: './src/types/state.ts', + stateTypeExpression: 'State', +}); diff --git a/website/scripts/vitest-setup.ts b/website/scripts/vitest-setup.ts index 35d3219783..2b1da67e39 100644 --- a/website/scripts/vitest-setup.ts +++ b/website/scripts/vitest-setup.ts @@ -11,8 +11,7 @@ configureTestingLibrary({ asyncUtilTimeout: 5000 }); configure({ adapter: new Adapter() }); -// immer uses Object.freeze on returned state objects, which is incompatible with -// redux-persist. See https://github.com/rt2zz/redux-persist/issues/747 +// immer uses Object.freeze on returned state objects, which breaks undo history functionality setAutoFreeze(false); // Prevent causing errors during test runs due to unclosed BroadcastChannel diff --git a/website/src/bootstrapping/configure-store.ts b/website/src/bootstrapping/configure-store.ts index 9a29c4aee5..ce11108094 100644 --- a/website/src/bootstrapping/configure-store.ts +++ b/website/src/bootstrapping/configure-store.ts @@ -1,5 +1,4 @@ -import { createStore, applyMiddleware, compose, PreloadedState } from 'redux'; -import { persistStore } from 'redux-persist'; +import { applyMiddleware, compose } from 'redux'; import thunk from 'redux-thunk'; import { setAutoFreeze } from 'immer'; @@ -12,16 +11,15 @@ import getLocalStorage from 'storage/localStorage'; import type { GetState } from 'types/redux'; import type { State } from 'types/state'; import type { Actions } from 'types/actions'; +import { configureStore as RTKConfigureStore, StoreEnhancer } from '@reduxjs/toolkit'; +import { rememberEnhancer } from 'redux-remember'; +import { migrate } from 'remigrate'; +import storage from 'storage'; -// For redux-devtools-extensions - see -// https://github.com/zalmoxisus/redux-devtools-extension -const composeEnhancers: typeof compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - -// immer uses Object.freeze on returned state objects, which is incompatible with -// redux-persist. See https://github.com/rt2zz/redux-persist/issues/747 +// immer uses Object.freeze on returned state objects, which breaks undo history functionality setAutoFreeze(false); -export default function configureStore(defaultState?: State) { +export default function configureStore(defaultState?: State, usePersistence: boolean = false) { // Clear legacy reduxState deprecated by https://github.com/nusmodifications/nusmods/pull/669 // to reduce the amount of data NUSMods is using getLocalStorage().removeItem('reduxState'); @@ -38,25 +36,39 @@ export default function configureStore(defaultState?: State) { diff: true, // Avoid diffing actions that insert a lot of stuff into the state to prevent console from lagging diffPredicate: (_getState: GetState, action: Actions) => - !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('persist/'), + !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('@@REMEMBER_'), }); middlewares.push(logger); } const storeEnhancer = applyMiddleware(...middlewares); - const store = createStore( - rootReducer, - // Redux typings does not seem to allow non-JSON serialized values in PreloadedState so this needs to be casted - defaultState as PreloadedState | undefined, - composeEnhancers(storeEnhancer), - ); + const store = RTKConfigureStore({ + reducer: rootReducer, + preloadedState: defaultState, + enhancers: (getDefaultEnhancers) => + getDefaultEnhancers().concat( + (usePersistence + ? compose( + rememberEnhancer( + storage, + ['moduleBank', 'venueBank', 'timetables', 'theme', 'settings', 'planner'], + { + migrate, + serialize: (state, _key) => state, + unserialize: (state, _key) => state, + }, + ), + storeEnhancer, + ) + : storeEnhancer) as StoreEnhancer, + ), + }); if (module.hot) { // Enable webpack hot module replacement for reducers module.hot.accept('../reducers', () => store.replaceReducer(rootReducer)); } - const persistor = persistStore(store); - return { persistor, store }; + return store; } diff --git a/website/src/bootstrapping/migrate-persist-to-remember.test.ts b/website/src/bootstrapping/migrate-persist-to-remember.test.ts new file mode 100644 index 0000000000..b938cc792c --- /dev/null +++ b/website/src/bootstrapping/migrate-persist-to-remember.test.ts @@ -0,0 +1,19 @@ +import { mapValues, omit } from 'lodash'; +import storage from 'storage'; + +test('redux-persist JSON members should be parsed and _persist should be removed', () => { + const mockData = { + maps: {}, + arrays: [], + number: 0, + string: '', + _persist: true, + }; + + const mockDataWithStringifiedMembers = mapValues(mockData, JSON.stringify); + + storage.setItem('persist:test_key', mockDataWithStringifiedMembers); + + const recoveredData = storage.getItem('@@remember-test_key'); + expect(recoveredData).toStrictEqual(omit(mockData, '_persist')); +}); diff --git a/website/src/bootstrapping/migrate-persist-to-remember.ts b/website/src/bootstrapping/migrate-persist-to-remember.ts new file mode 100644 index 0000000000..aab9469179 --- /dev/null +++ b/website/src/bootstrapping/migrate-persist-to-remember.ts @@ -0,0 +1,22 @@ +import { mapValues, omit } from 'lodash-es'; +import { captureException } from 'utils/error'; + +/** + * Each member in the redux-persist data is stringified, and the entire map is stringified\ + * Redux-remember format stringifies the data without stringifying each member\ + * This function takes the redux-persist JSON string and converts it to the redux-remember data format\ + * @param persistJsonString + * @returns parsed data + */ +const migratePersistToRemember = (persistJsonString: string): any => { + try { + const parsedValue = JSON.parse(persistJsonString); + const data = omit(parsedValue, '_persist'); + return mapValues(data, JSON.parse); + } catch (error) { + captureException(error); + return null; + } +}; + +export default migratePersistToRemember; diff --git a/website/src/entry/App.tsx b/website/src/entry/App.tsx index 3d54a483dd..941cd27bfb 100644 --- a/website/src/entry/App.tsx +++ b/website/src/entry/App.tsx @@ -4,8 +4,6 @@ import * as React from 'react'; import type { FC, PropsWithChildren } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; -import { Persistor } from 'storage/persistReducer'; import { State } from 'types/state'; import AppShell from 'views/AppShell'; @@ -13,13 +11,13 @@ import Routes from 'views/routes/Routes'; import { DIMENSIONS, setCustomDimensions } from 'bootstrapping/matomo'; import ErrorBoundary from 'views/errors/ErrorBoundary'; import ErrorPage from 'views/errors/ErrorPage'; +import RehydrateGate from 'storage/RehydrateGate'; type Props = { store: Store; - persistor: Persistor; }; -const App: FC> = ({ store, persistor }) => { +const App: FC> = ({ store }) => { const onBeforeLift = () => { const { theme, settings } = store.getState(); @@ -32,13 +30,13 @@ const App: FC> = ({ store, persistor }) => { return ( }> - + - + ); diff --git a/website/src/entry/export/main.tsx b/website/src/entry/export/main.tsx index 0146f882b1..85192251af 100644 --- a/website/src/entry/export/main.tsx +++ b/website/src/entry/export/main.tsx @@ -23,7 +23,7 @@ declare global { } // Set up Redux store -const { store } = configureStore(); +const store = configureStore(undefined, true); window.store = store; // For Puppeteer to import data diff --git a/website/src/entry/main.tsx b/website/src/entry/main.tsx index fbb3586322..8b5a826483 100644 --- a/website/src/entry/main.tsx +++ b/website/src/entry/main.tsx @@ -2,9 +2,6 @@ import 'bootstrapping/sentry'; // core-js has issues with Promise feature detection on Edge, and hence // polyfills Promise incorrectly. Importing this polyfill directly resolves that. -// This is necessary as PersistGate used in ./App uses `Promise.prototype.finally`. -// See: https://github.com/zloirock/core-js/issues/579#issuecomment-504325213 -import 'core-js/es/promise/finally'; import { createRoot } from 'react-dom/client'; import ReactModal from 'react-modal'; @@ -18,7 +15,7 @@ import 'styles/main.scss'; import App from './App'; -const { store, persistor } = configureStore(); +const store = configureStore(undefined, true); subscribeOnlineEvents(store); @@ -30,7 +27,7 @@ if (!container) { throw new Error('#app element not found'); } const root = createRoot(container); -root.render(); +root.render(); if ( ((NUSMODS_ENV === 'preview' || NUSMODS_ENV === 'staging' || NUSMODS_ENV === 'production') && diff --git a/website/src/middlewares/state-sync-middleware.ts b/website/src/middlewares/state-sync-middleware.ts index 3f5f6f8a0b..f58cffaab5 100644 --- a/website/src/middlewares/state-sync-middleware.ts +++ b/website/src/middlewares/state-sync-middleware.ts @@ -1,5 +1,5 @@ import type { AnyAction } from 'redux'; -import { PERSIST, PURGE, REHYDRATE } from 'redux-persist'; +import { REMEMBER_REHYDRATED, REMEMBER_PERSISTED } from 'redux-remember'; import { createStateSyncMiddleware, type Config } from 'redux-state-sync'; const reduxStateSyncConfig = { @@ -9,7 +9,7 @@ const reduxStateSyncConfig = { channel: 'redux_state_sync', predicate: (action: AnyAction) => { // Reference: https://github.com/aohua/redux-state-sync/issues/53 - const blacklist = [PERSIST, PURGE, REHYDRATE]; + const blacklist = [REMEMBER_REHYDRATED, REMEMBER_PERSISTED]; // redux-state-sync relies on BroadcastChannel, which only supports // objects that are clonable by `structuredClone` diff --git a/website/src/reducers/app.ts b/website/src/reducers/app.ts index d6429198aa..7f60136e47 100644 --- a/website/src/reducers/app.ts +++ b/website/src/reducers/app.ts @@ -19,7 +19,7 @@ import { TOGGLE_FEEDBACK_MODAL, } from 'actions/app'; -const defaultAppState = (): AppState => ({ +export const defaultAppState = (): AppState => ({ // Default to the current semester from config. activeSemester: config.semester, // The lesson being modified on the timetable. diff --git a/website/src/reducers/index.ts b/website/src/reducers/index.ts index 4f789a7527..511ff1741b 100644 --- a/website/src/reducers/index.ts +++ b/website/src/reducers/index.ts @@ -1,29 +1,24 @@ import { REMOVE_MODULE, SET_TIMETABLE } from 'actions/timetables'; -import persistReducer from 'storage/persistReducer'; import { State } from 'types/state'; import { Actions } from 'types/actions'; // Non-persisted reducers import requests from './requests'; import app from './app'; -import createUndoReducer from './undoHistory'; +import createUndoReducer, { defaultUndoHistoryState } from './undoHistory'; // Persisted reducers -import moduleBankReducer, { persistConfig as moduleBankPersistConfig } from './moduleBank'; -import venueBankReducer, { persistConfig as venueBankPersistConfig } from './venueBank'; -import timetablesReducer, { persistConfig as timetablesPersistConfig } from './timetables'; +import moduleBankReducer from './moduleBank'; +import venueBankReducer from './venueBank'; +import timetablesReducer from './timetables'; import themeReducer from './theme'; -import settingsReducer, { persistConfig as settingsPersistConfig } from './settings'; -import plannerReducer, { persistConfig as plannerPersistConfig } from './planner'; - -// Persist reducers -const moduleBank = persistReducer('moduleBank', moduleBankReducer, moduleBankPersistConfig); -const venueBank = persistReducer('venueBank', venueBankReducer, venueBankPersistConfig); -const timetables = persistReducer('timetables', timetablesReducer, timetablesPersistConfig); -const theme = persistReducer('theme', themeReducer); -const settings = persistReducer('settings', settingsReducer, settingsPersistConfig); -const planner = persistReducer('planner', plannerReducer, plannerPersistConfig); +import settingsReducer from './settings'; +import plannerReducer from './planner'; +import { rememberReducer } from 'redux-remember'; +import reduxRemember from './reduxRemember'; +import { UndoHistoryState } from 'types/reducers'; +import { combineReducers } from 'redux'; // State default is delegated to its child reducers. const defaultState = {} as unknown as State; @@ -33,18 +28,26 @@ const undoReducer = createUndoReducer({ storedKeyPaths: ['timetables', 'theme.colors'], }); -export default function reducers(state: State = defaultState, action: Actions): State { - // Update every reducer except the undo reducer - const newState: State = { - moduleBank: moduleBank(state.moduleBank, action), - venueBank: venueBank(state.venueBank, action), - requests: requests(state.requests, action), - timetables: timetables(state.timetables, action), - app: app(state.app, action), - theme: theme(state.theme, action), - settings: settings(state.settings, action), - planner: planner(state.planner, action), - undoHistory: state.undoHistory, - }; +const reducers = { + moduleBank: moduleBankReducer, + venueBank: venueBankReducer, + requests, + timetables: timetablesReducer, + app, + theme: themeReducer, + settings: settingsReducer, + planner: plannerReducer, + reduxRemember: reduxRemember.reducer, + // State members are required to have a reducer + // The reducer is required to return a state, but the history reducer runs after state reducer + // Thus we initialize undo history state if it was uninitialized + undoHistory: (state: UndoHistoryState = defaultUndoHistoryState, _action: Actions) => + state, +}; + +const reducer = rememberReducer(combineReducers(reducers)); + +export default function rootReducer(state: State = defaultState, action: Actions): State { + const newState = reducer(state, action); return undoReducer(state, newState, action); } diff --git a/website/src/reducers/moduleBank.ts b/website/src/reducers/moduleBank.ts index 76f3d90b70..93c45b8746 100644 --- a/website/src/reducers/moduleBank.ts +++ b/website/src/reducers/moduleBank.ts @@ -1,7 +1,6 @@ import { produce, Draft } from 'immer'; import { keyBy, map, omit, size, zipObject } from 'lodash-es'; -import { createMigrate, REHYDRATE } from 'redux-persist'; import type { Actions } from 'types/actions'; import type { Module } from 'types/modules'; import type { ModuleBank, ModuleList } from 'types/reducers'; @@ -15,8 +14,9 @@ import { SET_EXPORTED_DATA, } from 'actions/constants'; import { SUCCESS_KEY } from 'middlewares/requests-middleware'; +import { REMEMBER_REHYDRATED } from 'redux-remember'; -const defaultModuleBankState: ModuleBank = { +export const defaultModuleBankState: ModuleBank = { moduleList: [], // List of basic modules data (module code, name, semester) modules: {}, // Object of Module code -> Module details moduleCodes: {}, @@ -106,7 +106,7 @@ function moduleBank(state: ModuleBank = defaultModuleBankState, action: Actions) modules: keyBy(action.payload.modules, (module: Module) => module.moduleCode), }; - case REHYDRATE: + case REMEMBER_REHYDRATED: if (!size(state.moduleCodes) && state.moduleList) { return { ...state, @@ -122,23 +122,3 @@ function moduleBank(state: ModuleBank = defaultModuleBankState, action: Actions) } export default moduleBank; - -export const persistConfig = { - version: 1, - throttle: 1000, - whitelist: ['modules', 'moduleList'], - migrate: createMigrate({ - // Clear out modules - after switching to API v2 we need to flush all of the - // old module data - 1: (state) => ({ - ...state, - modules: {}, - moduleList: [], - // FIXME: Remove the next line when _persist is optional again. - // Cause: https://github.com/rt2zz/redux-persist/pull/919 - // Issue: https://github.com/rt2zz/redux-persist/pull/1170 - // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain - _persist: state?._persist!, - }), - }), -}; diff --git a/website/src/reducers/planner.test.ts b/website/src/reducers/planner.test.ts index 3aacbccc54..75d0f91717 100644 --- a/website/src/reducers/planner.test.ts +++ b/website/src/reducers/planner.test.ts @@ -19,7 +19,7 @@ import { clearPlanner, } from 'actions/planner'; import { PlannerState } from 'types/reducers'; -import reducer, { defaultPlannerState, migrateV0toV1, nextId } from './planner'; +import reducer, { defaultPlannerState, nextId } from './planner'; const defaultState: PlannerState = { ...defaultPlannerState, @@ -239,23 +239,3 @@ describe(CLEAR_PLANNER, () => { expect(reducer(initial, clearPlanner())).toEqual(defaultPlannerState); }); }); - -describe(migrateV0toV1, () => { - test('should migrate old modules state to new modules state', () => { - expect( - migrateV0toV1({ - _persist: {} as any, - ...defaultState, - modules: { - CS1010S: ['2018/2019', 1, 0], - MA1101R: ['2018/2019', 1, 1], - CS1231: ['2018/2019', 2, 0], - }, - }), - ).toHaveProperty('modules', { - 0: { id: '0', moduleCode: 'CS1010S', year: '2018/2019', semester: 1, index: 0 }, - 1: { id: '1', moduleCode: 'MA1101R', year: '2018/2019', semester: 1, index: 1 }, - 2: { id: '2', moduleCode: 'CS1231', year: '2018/2019', semester: 2, index: 0 }, - }); - }); -}); diff --git a/website/src/reducers/planner.ts b/website/src/reducers/planner.ts index 7c9b109a60..f99a052140 100644 --- a/website/src/reducers/planner.ts +++ b/website/src/reducers/planner.ts @@ -1,6 +1,5 @@ import { produce } from 'immer'; -import { each, max, min, pull } from 'lodash-es'; -import { createMigrate, PersistedState } from 'redux-persist'; +import { max, min, pull } from 'lodash-es'; import { PlannerState } from 'types/reducers'; import { Actions } from 'types/actions'; @@ -165,44 +164,3 @@ export default function planner( return state; } } - -// Migration from state V0 -> V1 -type PlannerStateV0 = Omit & { - modules: { [moduleCode: string]: [string, Semester, number] }; -}; -export function migrateV0toV1( - oldState: PlannerStateV0 & PersistedState, -): PlannerState & PersistedState { - // Map the old module time mapping of module code to module time tuple - // to the new mapping of id to module time object - let id = 0; - - const newModules: PlannerState['modules'] = {}; - - each(oldState.modules, ([year, semester, index], moduleCode) => { - newModules[id] = { - id: String(id), - year, - semester, - index, - moduleCode, - }; - - id += 1; - }); - - return { - ...oldState, - // Map old ModuleTime type to new PlannerTime shape - modules: newModules, - }; -} - -export const persistConfig = { - version: 1, - migrate: createMigrate({ - // The typings for this seems really weird - // eslint-disable-next-line @typescript-eslint/no-explicit-any - 1: migrateV0toV1 as any, - }), -}; diff --git a/website/src/reducers/reduxRemember.ts b/website/src/reducers/reduxRemember.ts new file mode 100644 index 0000000000..dc3659e27e --- /dev/null +++ b/website/src/reducers/reduxRemember.ts @@ -0,0 +1,23 @@ +import { createAction, createSlice } from '@reduxjs/toolkit'; +import { REMEMBER_REHYDRATED, REMEMBER_PERSISTED } from 'redux-remember'; + +export const defaultReduxRememberState = { + isRehydrated: false, + isPersisted: false, +}; + +const reduxRemember = createSlice({ + name: 'redux-remember', + initialState: defaultReduxRememberState, + reducers: {}, + extraReducers: (builder) => + builder + .addCase(createAction(REMEMBER_REHYDRATED), (state, _action) => { + state.isRehydrated = true; + }) + .addCase(createAction(REMEMBER_PERSISTED), (state, _action) => { + state.isPersisted = true; + }), +}); + +export default reduxRemember; diff --git a/website/src/reducers/settings.test.ts b/website/src/reducers/settings.test.ts index 336d1936f2..b3df891df4 100644 --- a/website/src/reducers/settings.test.ts +++ b/website/src/reducers/settings.test.ts @@ -105,7 +105,10 @@ describe('modRegNotification settings', () => { test('clear out dismissed notifications when semester changes', () => { config.getSemesterKey = () => '2017/2018 Semester 2'; - const nextState: SettingsState = reducer(settingsWithDismissedNotifications, rehydrateAction()); + const nextState: SettingsState = reducer( + settingsWithDismissedNotifications, + rehydrateAction({ settings: settingsWithDismissedNotifications }), + ); expect(nextState.modRegNotification).toMatchObject({ semesterKey: '2017/2018 Semester 2', dismissed: [], @@ -123,7 +126,10 @@ describe('modRegNotification settings', () => { }, }; - const nextState: SettingsState = reducer(settingsState, rehydrateAction()); + const nextState: SettingsState = reducer( + settingsState, + rehydrateAction({ settings: settingsState }), + ); expect(nextState.modRegNotification).toHaveProperty('enabled', false); }); diff --git a/website/src/reducers/settings.ts b/website/src/reducers/settings.ts index 5f6b0deb80..9005cc688e 100644 --- a/website/src/reducers/settings.ts +++ b/website/src/reducers/settings.ts @@ -1,6 +1,5 @@ import { isEqual } from 'lodash-es'; import { produce } from 'immer'; -import { REHYDRATE, createMigrate } from 'redux-persist'; import { SettingsState } from 'types/reducers'; import { Actions } from 'types/actions'; @@ -24,6 +23,7 @@ import { SYSTEM_COLOR_SCHEME_PREFERENCE } from 'types/settings'; import config from 'config'; import { isRoundDismissed } from 'selectors/modreg'; import { colorSchemeToPreference } from 'utils/colorScheme'; +import { REMEMBER_REHYDRATED } from 'redux-remember'; export const defaultModRegNotificationState = { semesterKey: config.getSemesterKey(), @@ -32,7 +32,7 @@ export const defaultModRegNotificationState = { scheduleType: 'Undergraduate' as const, }; -const defaultSettingsState: SettingsState = { +export const defaultSettingsState: SettingsState = { newStudent: false, faculty: '', colorScheme: SYSTEM_COLOR_SCHEME_PREFERENCE, @@ -122,7 +122,7 @@ function settings(state: SettingsState = defaultSettingsState, action: Actions): prereqTreeOnLeft: action.payload, }; - case REHYDRATE: { + case REMEMBER_REHYDRATED: { let nextState = state; // Rehydrating from store - check that the key is the same, and if not, @@ -142,16 +142,3 @@ function settings(state: SettingsState = defaultSettingsState, action: Actions): } export default settings; - -export const persistConfig = { - version: 1, - migrate: createMigrate({ - // any is used because migration typing is hard - // eslint-disable-next-line @typescript-eslint/no-explicit-any - 1: ({ corsNotification, ...state }: any) => ({ - // Rename corsNotification to modRegNotification and set the default modRegScheduleType - modRegNotification: defaultSettingsState.modRegNotification, - ...state, - }), - }), -}; diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index bf4da21ff4..ffd80b0c09 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -1,5 +1,4 @@ -import { PersistConfig } from 'redux-persist/es/types'; -import reducer, { defaultTimetableState, persistConfig } from 'reducers/timetables'; +import reducer, { defaultTimetableState, stateReconciler } from 'reducers/timetables'; import { ADD_MODULE, hideLessonInTimetable, @@ -253,7 +252,7 @@ describe('lesson reducer', () => { }); }); -describe('stateReconciler', () => { +describe(stateReconciler, () => { const oldArchive = { '2015/2016': { [1]: { @@ -296,17 +295,10 @@ describe('stateReconciler', () => { archive: oldArchive, }; - const { stateReconciler } = persistConfig; - if (!stateReconciler) { - throw new Error('No stateReconciler'); - } - - const reconcilerPersistConfig = { debug: false } as PersistConfig; + const debug = false; test('should return inbound state when academic year is the same', () => { - expect(stateReconciler(inbound, initialState, initialState, reconcilerPersistConfig)).toEqual( - inbound, - ); + expect(stateReconciler(inbound, initialState, debug)).toEqual(inbound); }); test('should archive old timetables and clear state when academic year is different', () => { @@ -315,9 +307,7 @@ describe('stateReconciler', () => { academicYear: '2016/2017', }; - expect( - stateReconciler(oldInbound, initialState, initialState, reconcilerPersistConfig), - ).toEqual({ + expect(stateReconciler(oldInbound, initialState, debug)).toEqual({ ...initialState, archive: { ...oldArchive, diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index 9c7a8695a4..6263822198 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -1,8 +1,6 @@ import { get, omit, uniq, values } from 'lodash-es'; import { produce } from 'immer'; -import { createMigrate } from 'redux-persist'; -import { PersistConfig } from 'storage/persistReducer'; import { LessonIndex, ModuleCode } from 'types/modules'; import { ModuleLessonConfig, SemTimetableConfig } from 'types/timetables'; import { ColorMapping, TimetablesState } from 'types/reducers'; @@ -28,59 +26,29 @@ import { import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; import { Actions } from '../types/actions'; +import { REMEMBER_REHYDRATED } from 'redux-remember'; + +export const stateReconciler = ( + inbound: TimetablesState, + original: TimetablesState, + debug: boolean, +): TimetablesState => { + if (inbound.academicYear === original.academicYear) { + return inbound; + } -export const persistConfig = { - /* eslint-disable no-useless-computed-key */ - migrate: createMigrate({ - 1: (state) => ({ - ...state, - archive: {}, - // FIXME: Remove the next line when _persist is optional again. - // Cause: https://github.com/rt2zz/redux-persist/pull/919 - // Issue: https://github.com/rt2zz/redux-persist/pull/1170 - // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain - _persist: state?._persist!, - }), - 2: (state) => ({ - ...state, - ta: {}, - // FIXME: Remove the next line when _persist is optional again. - // Cause: https://github.com/rt2zz/redux-persist/pull/919 - // Issue: https://github.com/rt2zz/redux-persist/pull/1170 - // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain - _persist: state?._persist!, - }), - }), - /* eslint-enable */ - version: 2, - - // Our own state reconciler archives old timetables if the acad year is different, - // otherwise use the persisted timetable state - stateReconciler: ( - inbound: TimetablesState, - original: TimetablesState, - _reduced: TimetablesState, - { debug }: PersistConfig, - ): TimetablesState => { - if (inbound.academicYear === original.academicYear) { - return inbound; - } - - if (debug) { - // eslint-disable-next-line no-console - console.log( - 'New academic year detected - resetting timetable and adding timetable to archive', - ); - } + if (debug) { + // eslint-disable-next-line no-console + console.log('New academic year detected - resetting timetable and adding timetable to archive'); + } - return { - ...original, - archive: { - ...inbound.archive, - [inbound.academicYear]: inbound.lessons, - }, - }; - }, + return { + ...original, + archive: { + ...inbound.archive, + [inbound.academicYear]: inbound.lessons, + }, + }; }; // Map of lessonType to ClassNo. @@ -318,6 +286,29 @@ function timetables( }); } + case REMEMBER_REHYDRATED: { + const inbound = action.payload.timetables; + + if (inbound.academicYear === config.academicYear) { + return inbound; + } + + if (NUSMODS_ENV === 'development') { + // eslint-disable-next-line no-console + console.log( + 'New academic year detected - resetting timetable and adding timetable to archive', + ); + } + + return { + ...defaultTimetableState, + archive: { + ...inbound.archive, + [inbound.academicYear]: inbound.lessons, + }, + }; + } + default: return state; } diff --git a/website/src/reducers/undoHistory.ts b/website/src/reducers/undoHistory.ts index 11dbaae689..24442cdb13 100644 --- a/website/src/reducers/undoHistory.ts +++ b/website/src/reducers/undoHistory.ts @@ -10,16 +10,18 @@ export type UndoHistoryConfig = { storedKeyPaths: string[]; }; +export const defaultUndoHistoryState = { + past: [], + present: undefined, // Don't pretend to know the present + future: [], +}; + // Update undo history using the action and app states // Basically a reducer but not really, as it needs to know the previous state. // Passing state in even though state === presentAppState[config.reducerName] as the "reducer" // doesn't need to know that. export function computeUndoStacks }>( - state: UndoHistoryState = { - past: [], - present: undefined, // Don't pretend to know the present - future: [], - }, + state: UndoHistoryState = defaultUndoHistoryState, action: Actions, previousAppState: Partial, presentAppState: Partial, diff --git a/website/src/reducers/venueBank.ts b/website/src/reducers/venueBank.ts index b5817fd46c..2883d8eb8d 100644 --- a/website/src/reducers/venueBank.ts +++ b/website/src/reducers/venueBank.ts @@ -3,11 +3,11 @@ import { VenueBank } from 'types/reducers'; import { Actions } from 'types/actions'; import { SUCCESS_KEY } from '../middlewares/requests-middleware'; -const defaultModuleBankState: VenueBank = { +export const defaultVenueBankState: VenueBank = { venueList: [], // List of venue strings }; -function venueBank(state: VenueBank = defaultModuleBankState, action: Actions): VenueBank { +function venueBank(state: VenueBank = defaultVenueBankState, action: Actions): VenueBank { switch (action.type) { case SUCCESS_KEY(FETCH_VENUE_LIST): return { diff --git a/website/src/remigrate/index.ts b/website/src/remigrate/index.ts new file mode 100644 index 0000000000..d156e5a0a8 --- /dev/null +++ b/website/src/remigrate/index.ts @@ -0,0 +1,13 @@ +/** + * ⚠ WARNING: AUTO-GENERATED FILE - DO NOT EDIT + * This file is managed by Redux Remigrate and will be overwritten on creation of new migrations. + * If you deleted any of your old migrations, run "redux-remigrate cleanup" to regenerate. + **/ + +import { createRemigrate } from 'redux-remigrate'; + +export const migrate = createRemigrate({ + firstVersion: '', + latestVersion: '', + migrators: {}, +}); diff --git a/website/src/storage/RehydrateGate.tsx b/website/src/storage/RehydrateGate.tsx new file mode 100644 index 0000000000..090155ebc1 --- /dev/null +++ b/website/src/storage/RehydrateGate.tsx @@ -0,0 +1,21 @@ +import React, { FC, PropsWithChildren } from 'react'; +import { useSelector } from 'react-redux'; +import { State } from 'types/state'; + +type RehydrateGateProps = PropsWithChildren<{ + onBeforeLift: () => void; +}>; + +const RehydrateGate: FC = ({ children, onBeforeLift }) => { + const isRehydrated = useSelector((state) => state.reduxRemember.isRehydrated); + + React.useEffect(() => { + if (isRehydrated) onBeforeLift(); + }, [isRehydrated, onBeforeLift]); + + if (!isRehydrated) return null; + + return children; +}; + +export default RehydrateGate; diff --git a/website/src/storage/index.ts b/website/src/storage/index.ts index 7c8e03e14b..b067cacc88 100644 --- a/website/src/storage/index.ts +++ b/website/src/storage/index.ts @@ -1,6 +1,7 @@ -import { isString } from 'lodash-es'; +import { get, isString } from 'lodash-es'; import { captureException } from 'utils/error'; import getLocalStorage from './localStorage'; +import migratePersistToRemember from 'bootstrapping/migrate-persist-to-remember'; // Simple wrapper around localStorage to automagically parse and stringify payloads. function setItem(key: string, value: unknown) { @@ -26,17 +27,34 @@ function setItem(key: string, value: unknown) { } } +/** + * This function is augmented with logic to migrate data from redux-persist to redux-remember\ + * Redux-remember uses the `@@remember-` prefix whereas redux-persist used the `persist:` prefix\ + * This function attempts to look for data stored by redux-remember (with the prefix `@@remember-`) and if the key does not exists, it constructs the key used by redux-persist (with the prefix `persist:`), gets and parses the value. + * @param key used as the key in localStorage + * @returns the value stored in `localStorage`.\ + * If no value is found and the key has redux-remember's prefix, it checks if the same key with redux-persist's prefix exists. If yes, it accesses, migrates and returns the data.\ + * Otherwise, returns null. + */ function getItem(key: string): unknown { - let value; + const reduxRememberValue = getLocalStorage().getItem(key); + + if (reduxRememberValue === null) { + const rememberPrefixMatches = key.match(/(?<=@@remember-)(.*)/); + if (!rememberPrefixMatches) return null; + const baseKey = get(rememberPrefixMatches, 0); + + const persistValue = getLocalStorage().getItem(`persist:${baseKey}`); + if (persistValue === null) return null; + + return migratePersistToRemember(persistValue); + } + try { - value = getLocalStorage().getItem(key); - if (value && value !== '') { - return JSON.parse(value); - } - return undefined; - } catch (e) { - captureException(e); - return value; + return JSON.parse(reduxRememberValue); + } catch (error) { + captureException(error); + return reduxRememberValue; } } diff --git a/website/src/storage/persistReducer.ts b/website/src/storage/persistReducer.ts deleted file mode 100644 index 1f16e936bd..0000000000 --- a/website/src/storage/persistReducer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Reducer } from 'redux'; -import { PersistConfig, Persistor } from 'redux-persist/lib/types'; -import { persistReducer as basePersistReducer } from 'redux-persist'; -import storage from 'redux-persist/lib/storage'; - -import { Actions } from 'types/actions'; - -// Re-export type for easier consumption in other parts of the project -export { PersistConfig, Persistor }; - -/** - * Wrapper function around persistReducer from Redux Persist. - */ -export default function persistReducer( - key: string, - reducer: Reducer, - options: Pick< - PersistConfig, - Exclude, keyof { key: string; storage: Record }> - > = {}, -) { - return basePersistReducer( - { - key, - storage, - debug: NUSMODS_ENV === 'development', - ...options, - }, - reducer, - ) as unknown as Reducer; // We'll pretend the persist keys don't exist - the base reducers shouldn't access them anyway -} diff --git a/website/src/test-utils/redux.ts b/website/src/test-utils/redux.ts index e3ca21ac42..01b7ab85f3 100644 --- a/website/src/test-utils/redux.ts +++ b/website/src/test-utils/redux.ts @@ -1,4 +1,14 @@ -import { REHYDRATE } from 'redux-persist'; +import { defaultAppState } from 'reducers/app'; +import { defaultModuleBankState } from 'reducers/moduleBank'; +import { defaultPlannerState } from 'reducers/planner'; +import { defaultReduxRememberState } from 'reducers/reduxRemember'; +import { defaultSettingsState } from 'reducers/settings'; +import { defaultThemeState } from 'reducers/theme'; +import { defaultTimetableState } from 'reducers/timetables'; +import { defaultUndoHistoryState } from 'reducers/undoHistory'; +import { defaultVenueBankState } from 'reducers/venueBank'; +import { REMEMBER_REHYDRATED } from 'redux-remember'; +import { State } from 'types/state'; export function initAction() { return { @@ -7,9 +17,21 @@ export function initAction() { }; } -export function rehydrateAction() { +export function rehydrateAction(state: Partial) { return { - type: REHYDRATE, - payload: null, - }; + type: REMEMBER_REHYDRATED, + payload: { + moduleBank: defaultModuleBankState, + venueBank: defaultVenueBankState, + requests: {}, + timetables: defaultTimetableState, + app: defaultAppState(), + theme: defaultThemeState, + settings: defaultSettingsState, + planner: defaultPlannerState, + undoHistory: defaultUndoHistoryState, + reduxRemember: defaultReduxRememberState, + ...state, + }, + } as const; } diff --git a/website/src/types/actions.ts b/website/src/types/actions.ts index 5c9f957de7..ffd95d0966 100644 --- a/website/src/types/actions.ts +++ b/website/src/types/actions.ts @@ -1,5 +1,3 @@ -import { REHYDRATE } from 'redux-persist/es/constants'; - import * as app from 'actions/app'; import * as exportActions from 'actions/export'; import * as moduleBank from 'actions/moduleBank'; @@ -10,6 +8,8 @@ import * as timetables from 'actions/timetables'; import * as undoHistory from 'actions/undoHistory'; import * as venueBank from 'actions/venueBank'; import { ExtractActionShape } from './redux'; +import { REMEMBER_REHYDRATED } from 'redux-remember'; +import { State } from 'types/state'; type AppAction = ExtractActionShape; type ExportActionsAction = ExtractActionShape; @@ -31,8 +31,8 @@ type InitActions = { }; type ReduxPersistActions = { - type: typeof REHYDRATE; - payload: null; + type: typeof REMEMBER_REHYDRATED; + payload: State; }; export type Actions = diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index 31aaa08ddf..c6d13ae73c 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -215,3 +215,11 @@ export type UndoHistoryState }> = { export type VenueBank = { readonly venueList: VenueList; }; + +/** + * reduxRemember types + */ +export type ReduxRememberState = { + isRehydrated: boolean; + isPersisted: boolean; +}; diff --git a/website/src/types/state.ts b/website/src/types/state.ts index d4a381a8e4..f19a2cc240 100644 --- a/website/src/types/state.ts +++ b/website/src/types/state.ts @@ -8,6 +8,7 @@ import { UndoHistoryState, VenueBank, ModuleBank, + ReduxRememberState, } from './reducers'; export type State = { @@ -20,4 +21,5 @@ export type State = { settings: SettingsState; planner: PlannerState; undoHistory: UndoHistoryState; + reduxRemember: ReduxRememberState; }; diff --git a/website/src/types/vendor/window.d.ts b/website/src/types/vendor/window.d.ts index cce58a7853..324c669691 100644 --- a/website/src/types/vendor/window.d.ts +++ b/website/src/types/vendor/window.d.ts @@ -20,9 +20,6 @@ declare global { // Injected by Matomo Piwik: { getTracker(url: string, id: string): Tracker }; - // For the Redux developer extension - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; - // Allows debugging [debugHooks: DEBUG_HOOK_NAMES]: (newValue: any) => void; } diff --git a/website/src/views/layout/GlobalSearchContainer.test.tsx b/website/src/views/layout/GlobalSearchContainer.test.tsx index 36d40d1aee..26f35c293c 100644 --- a/website/src/views/layout/GlobalSearchContainer.test.tsx +++ b/website/src/views/layout/GlobalSearchContainer.test.tsx @@ -40,7 +40,7 @@ const relevantStoreContents = { const initialState = reducers(undefined, initAction()); function make(storeOverrides: Partial = {}) { - const { store } = configureStore( + const store = configureStore( produce(initialState, (draft) => { draft.moduleBank.moduleList = (storeOverrides.moduleBank?.moduleList ?? relevantStoreContents.moduleBank.moduleList) as typeof draft.moduleBank.moduleList; diff --git a/website/src/views/layout/Navtabs.test.tsx b/website/src/views/layout/Navtabs.test.tsx index 82ddf897a1..3687922c7a 100644 --- a/website/src/views/layout/Navtabs.test.tsx +++ b/website/src/views/layout/Navtabs.test.tsx @@ -18,7 +18,7 @@ const relevantStoreContents = { const initialState = reducers(undefined, initAction()); function make(storeOverrides: Partial = {}) { - const { store } = configureStore( + const store = configureStore( produce(initialState, (draft) => { draft.app.activeSemester = storeOverrides.app?.activeSemester ?? relevantStoreContents.app.activeSemester; diff --git a/website/src/views/modules/ModuleArchiveContainer.test.tsx b/website/src/views/modules/ModuleArchiveContainer.test.tsx index f5dd812c48..064900f804 100644 --- a/website/src/views/modules/ModuleArchiveContainer.test.tsx +++ b/website/src/views/modules/ModuleArchiveContainer.test.tsx @@ -58,7 +58,7 @@ const CANONICAL = '/archive/CS1010S/2017-2018/programming-methodology'; const initialState = reducers(undefined, initAction()); function make(location: string = CANONICAL) { - const { store } = configureStore(initialState); + const store = configureStore(initialState); return renderWithRouterMatch( diff --git a/website/src/views/modules/ModulePageContainer.test.tsx b/website/src/views/modules/ModulePageContainer.test.tsx index 76d51c4509..a451633ec7 100644 --- a/website/src/views/modules/ModulePageContainer.test.tsx +++ b/website/src/views/modules/ModulePageContainer.test.tsx @@ -58,7 +58,7 @@ const CANONICAL = '/courses/CS1010S/programming-methodology'; const initialState = reducers(undefined, initAction()); function make(location: string = CANONICAL) { - const { store } = configureStore(initialState); + const store = configureStore(initialState); return renderWithRouterMatch( diff --git a/website/src/views/modules/ModulePageContent.test.tsx b/website/src/views/modules/ModulePageContent.test.tsx index 3459914081..492afc18bb 100644 --- a/website/src/views/modules/ModulePageContent.test.tsx +++ b/website/src/views/modules/ModulePageContent.test.tsx @@ -14,7 +14,7 @@ import ModulePageContent from './ModulePageContent'; describe('ModulePageContent', () => { function make(module: Module = CS1010S) { const initialState = reducers(undefined, initAction()); - const { store } = configureStore(initialState); + const store = configureStore(initialState); return renderWithRouterMatch( diff --git a/website/src/views/planner/PlannerSemester.test.tsx b/website/src/views/planner/PlannerSemester.test.tsx index e3beb80c71..26bd8ca687 100644 --- a/website/src/views/planner/PlannerSemester.test.tsx +++ b/website/src/views/planner/PlannerSemester.test.tsx @@ -19,7 +19,7 @@ vi.mock('react-beautiful-dnd', () => ({ })); function makePlannerSemester(year: string, semester: number, modules: PlannerModuleInfo[]) { - const { store } = configureStore(); + const store = configureStore(); const { history } = createHistory(); const addModule = jest.fn(); diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index aa32bf26fe..f00a48566c 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -58,7 +58,7 @@ function make( renderOptions?: Omit | undefined; } = {}, ) { - const { store } = configureStore( + const store = configureStore( produce(initialState, (draft) => { draft.app.activeSemester = options.storeOverrides?.app?.activeSemester ?? relevantStoreContents.app.activeSemester;