diff --git a/website/package.json b/website/package.json index c064dc05f3..7f2a6d1880 100644 --- a/website/package.json +++ b/website/package.json @@ -37,6 +37,7 @@ "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "7.26.0", "@cfaester/enzyme-adapter-react-18": "0.8.0", + "@jest/globals": "29.7.0", "@packtracker/webpack-plugin": "2.3.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@svgr/webpack": "8.1.0", @@ -63,6 +64,7 @@ "@types/react-router-dom": "5.3.3", "@types/react-scrollspy": "3.3.9", "@types/redux-mock-store": "1.5.0", + "@types/redux-state-sync": "3.1.4", "@types/use-subscription": "1.0.2", "@types/webpack-env": "1.18.5", "@typescript-eslint/eslint-plugin": "4.33.0", @@ -168,6 +170,7 @@ "react-scrollspy": "3.4.3", "redux": "4.2.1", "redux-persist": "6.0.0", + "redux-state-sync": "3.1.4", "redux-thunk": "2.4.2", "reselect": "4.1.8", "samlify": "2.7.7", diff --git a/website/scripts/test.js b/website/scripts/test.js index 91ba23ceee..f6e0277b82 100644 --- a/website/scripts/test.js +++ b/website/scripts/test.js @@ -1,4 +1,5 @@ import Adapter from '@cfaester/enzyme-adapter-react-18'; +import { jest } from '@jest/globals'; import { configure } from 'enzyme'; import { setAutoFreeze } from 'immer'; @@ -15,3 +16,6 @@ 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 setAutoFreeze(false); + +// Prevent causing errors during jest runs due to unclosed BroadcastChannel +jest.mock('redux-state-sync'); diff --git a/website/src/__mocks__/redux-state-sync.ts b/website/src/__mocks__/redux-state-sync.ts new file mode 100644 index 0000000000..98614e7c2b --- /dev/null +++ b/website/src/__mocks__/redux-state-sync.ts @@ -0,0 +1,7 @@ +import type { Middleware } from 'redux'; + +module.exports = { + createStateSyncMiddleware(): Middleware { + return () => (next) => (action) => next(action); + }, +}; diff --git a/website/src/bootstrapping/configure-store.ts b/website/src/bootstrapping/configure-store.ts index b03afc0120..9a29c4aee5 100644 --- a/website/src/bootstrapping/configure-store.ts +++ b/website/src/bootstrapping/configure-store.ts @@ -6,6 +6,7 @@ import { setAutoFreeze } from 'immer'; import rootReducer from 'reducers'; import requestsMiddleware from 'middlewares/requests-middleware'; import ravenMiddleware from 'middlewares/raven-middleware'; +import stateSyncMiddleware from 'middlewares/state-sync-middleware'; import getLocalStorage from 'storage/localStorage'; import type { GetState } from 'types/redux'; @@ -25,7 +26,7 @@ export default function configureStore(defaultState?: State) { // to reduce the amount of data NUSMods is using getLocalStorage().removeItem('reduxState'); - const middlewares = [ravenMiddleware, thunk, requestsMiddleware]; + const middlewares = [ravenMiddleware, thunk, requestsMiddleware, stateSyncMiddleware]; if (NUSMODS_ENV === 'development') { // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-extraneous-dependencies diff --git a/website/src/middlewares/state-sync-middleware.ts b/website/src/middlewares/state-sync-middleware.ts new file mode 100644 index 0000000000..54fcb4c24d --- /dev/null +++ b/website/src/middlewares/state-sync-middleware.ts @@ -0,0 +1,31 @@ +import type { AnyAction } from 'redux'; +import { PERSIST, PURGE, REHYDRATE } from 'redux-persist'; +import { createStateSyncMiddleware } from 'redux-state-sync'; + +const reduxStateSyncConfig = { + // Reference: https://github.com/aohua/redux-state-sync/issues/121#issuecomment-1770588046 + // TL/DR: Channel name (which is set to a string in the default config) is auto-converted + // to the string "undefined" in the browser, but not in the test (jest) environment + channel: 'redux_state_sync', + predicate: (action: AnyAction) => { + // Reference: https://github.com/aohua/redux-state-sync/issues/53 + const blacklist = [PERSIST, PURGE, REHYDRATE]; + + // redux-state-sync relies on BroadcastChannel, which only supports + // objects that are clonable by `structuredClone` + if (typeof action === 'function') { + return false; + } + + // `FETCH_` request actions should not be synced to other tabs + if (action.type.toString().startsWith('FETCH_')) { + return false; + } + + return !blacklist.includes(action.type); + }, +}; + +const stateSyncMiddleware = createStateSyncMiddleware(reduxStateSyncConfig); + +export default stateSyncMiddleware; diff --git a/website/yarn.lock b/website/yarn.lock index e198e423ff..8a197cedb2 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -1090,6 +1090,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a" + integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== + "@babel/template@^7.25.9", "@babel/template@^7.3.3": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -1269,7 +1274,7 @@ jest-mock "^29.7.0" jest-util "^29.7.0" -"@jest/globals@^29.7.0": +"@jest/globals@29.7.0", "@jest/globals@^29.7.0": version "29.7.0" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== @@ -2509,6 +2514,14 @@ dependencies: redux "^4.0.5" +"@types/redux-state-sync@3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/redux-state-sync/-/redux-state-sync-3.1.4.tgz#907fab8c7a127673fcc2c4d505cd7facc0e48852" + integrity sha512-tJjipBwPJzmeKhpLHWsgfAG3yHR+QJB5Dp6e9TPyp8RTXH7GIL7yj09A76LdydUUXE1+Ev3afJF2NuN9qf1sPw== + dependencies: + broadcast-channel "^2.1.8" + redux "^4.0.1" + "@types/retry@0.12.2": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" @@ -3537,6 +3550,11 @@ bfj@^6.1.1: hoopy "^0.1.4" tryer "^1.0.1" +big-integer@^1.6.16: + version "1.6.52" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" + integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -3629,6 +3647,33 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" +broadcast-channel@^2.1.8: + version "2.3.4" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-2.3.4.tgz#cede8a9cded517c7273063582da71fa4f809ee2c" + integrity sha512-cx1/dSb6KZ9HW1VtlqM/HLPjrdyzkKoteVmUpLXEpra00mDQW/F9ieDkoavuZMoh9/hC/6OplGzCERsZBfz/Wg== + dependencies: + "@babel/runtime" "^7.6.2" + detect-node "^2.0.4" + js-sha3 "0.8.0" + microseconds "0.1.0" + nano-time "1.0.0" + rimraf "2.6.3" + unload "2.2.0" + +broadcast-channel@^3.1.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.7.0.tgz#2dfa5c7b4289547ac3f6705f9c00af8723889937" + integrity sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg== + dependencies: + "@babel/runtime" "^7.7.2" + detect-node "^2.1.0" + js-sha3 "0.8.0" + microseconds "0.2.0" + nano-time "1.0.0" + oblivious-set "1.0.0" + rimraf "3.0.2" + unload "2.2.0" + browser-stdout@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" @@ -4828,7 +4873,7 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== -detect-node@^2.0.4: +detect-node@^2.0.4, detect-node@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== @@ -8031,6 +8076,11 @@ jiti@^1.20.0: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== +js-sha3@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" + integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -8768,6 +8818,16 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.3" picomatch "^2.3.1" +microseconds@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.1.0.tgz#47dc7bcf62171b8030e2152fd82f12a6894a7119" + integrity sha512-yF2K4aHXKxO4OGhW7Ek2KLgKEAFbSblBLKlF6KzwQUhjK7+uAzatRr6fZ82bftdnuDQrkBHAJp5s8quj1ME3wA== + +microseconds@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39" + integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA== + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" @@ -8941,6 +9001,13 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +nano-time@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef" + integrity sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA== + dependencies: + big-integer "^1.6.16" + nanoid@^3.3.7: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" @@ -9297,6 +9364,11 @@ object.values@^1.2.0, object.values@^1.2.1: define-properties "^1.2.1" es-object-atoms "^1.0.0" +oblivious-set@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" + integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== + obuf@^1.0.0, obuf@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -10732,12 +10804,19 @@ redux-persist@6.0.0: resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== +redux-state-sync@3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/redux-state-sync/-/redux-state-sync-3.1.4.tgz#b3a0a92a0c26d05b798c3e39e0ef215031a41323" + integrity sha512-nhJBzaXVXPXvUhQJ7m0LdoXBnrcw+cTYQ8bzW9DeJKdq6UNYynXwQWAlVUvsbT/hDV+vB6BC4DMLXkUVGpF2yQ== + dependencies: + broadcast-channel "^3.1.0" + redux-thunk@2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q== -redux@4.2.1, redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: +redux@4.2.1, redux@^4.0.0, redux@^4.0.1, redux@^4.0.4, redux@^4.0.5: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== @@ -10993,7 +11072,14 @@ rgba-regex@^1.0.0: resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" integrity sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg== -rimraf@^3.0.2: +rimraf@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +rimraf@3.0.2, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -12595,6 +12681,14 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== +unload@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/unload/-/unload-2.2.0.tgz#ccc88fdcad345faa06a92039ec0f80b488880ef7" + integrity sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA== + dependencies: + "@babel/runtime" "^7.6.2" + detect-node "^2.0.4" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"