Miłosz Orzeł

.net, js/ts, html/css, arduino, java... no rants or clickbaits.

The Daily Grind (Quick Tips on Miscellaneous Issues, Ep. 4)

Intro

Here's a 4th round of random problems and quick tips for working around them (previous: 3, 21).

The issues:

 

CORS errors while fetching files from Firebase storage bucket

If you upload files to Firebase cloud storage you might be able to get them with curl or open directly through a link in a browser but it doesn't mean that your app will be able to fetch them. For that to work you need to setup CORS.

If you have some environment where CORS is already working as expected you can use gsutil command to obtain current settings with:

gsutil cors get gs://example1.firebasestorage.app > cors.json

and then apply the exported cors.json on another environment: 

gsutil cors set cors.json gs://example2.firebasestorage.app

You can obtain the gs:// link to your storage bucket on Storage page in Firebase Console:

Find the gs:// link. Click to enlarge...


If you don't have any cors.json yet, this might be a starter (you might want to tighten the origin).

[
  {
    maxAgeSeconds: 3600,
    method: ["GET"],
    origin: ["*"],
    responseHeader: ["Content-Type"],
  },
]

 

Leaking RTL styling in styled-components 5

If you are using styled-components 6 you can create styling that mixes conditional style blocks and [dir="rtl"] attribute selector for right to left text direction

Take this as example:

import styled, { css } from "styled-components";

const Text = styled.div<{ $first?: boolean; $last?: boolean }>`
  color: ${({ $first, $last }) =>
    $first ? "purple" : $last ? "green" : "unset"};
  margin: 5px;

  ${({ $first }) =>
    $first &&
    css`
      border: 1px solid purple;

      [dir="rtl"] & {
        border: 1px dashed purple;
      }
    `}

  ${({ $last }) =>
    $last &&
    css`
      border: 1px solid green;

      [dir="rtl"] & {
        border: 1px dashed green;
      }
    `}
`;

export const RtlTest = () => {
  return (
    <div style={{ border: "1px solid gray", margin: 20 }}>
      <Text $first>aaa</Text>
      <Text>bbb</Text>
      <Text $last>ccc</Text>
    </div>
  );
};

The RtlTest component aggregates 3 styled divs (Text components). Text applies different styles depending on $first and $last properties and it also varies style when page is in RTL mode.

styled-components 6 vs 5 comparison

In styled-components 6.0.0 it all works well but results get messy in version 5 (5.3.11 is the last last 5.x.x version):

Notice how the green dashed border styling, which is supposed to be only on Text instance with $last property, got applied to all Text instances! This happens because RTL styling for each conditional block goes to main sc- class and the last injected one wins.

This is simplified example but at work I've noticed it on complex component that applied some transform: rotate, and when such things start to add up you have some fun time debugging ;)

If upgrade to version 6 is not possible, you can avoid adding conditional blocks and instead inject custom attributes and write selectors that use these attributes:

import styled, { css } from "styled-components";

const Text = styled.div.attrs<{ $first?: boolean; $last?: boolean }>(
  ({ $first, $last }) => ({
    "data-first": $first || undefined,
    "data-last": $last || undefined,
  }),
)<{
  $first?: boolean;
  $last?: boolean;
}>(
  ({ $first, $last }) => css`
    color: ${$first ? "purple" : $last ? "green" : "unset"};
    margin: 5px;

    &[data-first] {
      border: 1px solid purple;
    }

    [dir="rtl"] &[data-first] {
      border: 1px dashed purple;
    }

    &[data-last] {
      border: 1px solid green;
    }

    [dir="rtl"] &[data-last] {
      border: 1px dashed green;
    }
  `,
);

export const RtlTest = () => {
  return (
    <div style={{ border: "1px solid gray", margin: 20 }}>
      <Text $first>aaa</Text>
      <Text>bbb</Text>
      <Text $last>ccc</Text>
    </div>
  );
};

With this approach styles that depend on property passed to Text component will not leak out in RTL mode.

 

Missing trace in console log wrapper

Let's say you want to create a custom logger that will automatically add a prefix to logged messages so you could easily spot logs coming from your code vs log entries added by some dependencies.

Here are two ways to do it:

const PREFIX = "[example]";

export const loggerWithoutBind = {
  log: (...args: unknown[]) => console.log(PREFIX, ...args),
};

export const loggerWithBind = {
  log: console.log.bind(console, PREFIX),
};

Both loggers will automatically prefix passed message and retain ability to add more data to log entries:

import { loggerWithoutBind, loggerWithBind } from "./logger.ts";

loggerWithoutBind.log("Lalala (without bind)", { a: "aaa", b: [1, 2] });
loggerWithBind.log("Lalala (with bind)", { a: "aaa", b: [1, 2] });

...but, the first one has a critical flaw. The entries will be marked as coming from the log wrapper function instead of the line that logged the message, which makes the trace/link useless:

Logger with and without bind. Click to enlarge...
The bind technique avoids this issue and should work in modern browsers (I've checked in Chrome and Firefox).

 

Header not found in Axios response interceptor

By the specs, HTTP header names are case-insensitive and you could use Fetch API get function to read headers in case-insensitive way.

For example, assuming that server added X-My-Example header with abc as value, you can read it both in original case and in lowercase:

// Fetch API
console.log(res.headers.get("x-my-example")); // abc
console.log(res.headers.get("X-My-Example")); // abc

If you list all headers names by:

console.log([...res.headers.keys()]);

You will see the names in lowercase...

Watch out though if you try to read the headers with Axios, for example in response interceptor:

// Axios
console.log(res.headers["x-my-example"]); // abc
console.log(res.headers["X-My-Example"]); // undefined

The names in Axios response headers object are normalized to lowercase!

Putting a commend with name in original casing may be good idea (one day you might run case-sensitive search/replace in client and server code)...

The Daily Grind (Quick Tips on Miscellaneous Issues, Ep. 3)

Intro

Here's a third round of random issues and quick tips for working around them. Part one is here, Part two is here.

The issues:

 

Firestore index disappearing after deploy

If you create a query that uses couple of fields, Firestore would want you to add and index to make it fast (indexes for basic queries are added automatically). To make the setup easy, Firestore checks if query index is present when a query is invoked and if index is missing, an error is dumped into browser console. The error message contains a special link to index configuration page with all fields preconfigured. Nice!

So, I have deployed a new version of app (GitHub Actions job doing firebase deploy) that used compound query. As expected, the first query call failed and I've used the provided index configuration link. After a while index was added and then the query worked. But, after the app was deployed again, the query failed on a missing index...

The issue was that during deployment, the index configuration from firestore.indexes.json file was reapplied, overriding index added by link. To make the changes permanent I needed to first add the index with a link and then run firebase firestore:indexes command to update the indexes config. With the firestore.indexes.json refreshed and saved in the repo, all indexes were available after next deploy.

 

Firestore deploy failing because "index already exists"

Normally Firestore is smart enough not to complain if you redeploy a project that doesn't make any changes to indexes. Unfortunately, suddenly, it stated to just that during a GitHub Action that used firebase deploy:

Error: Request to https://firestore.googleapis.com/v1/projects/example/databases/(default)/collectionGroups/something/indexes had HTTP Error: 409, index already exists
[2025-07-22T07:30:23.592Z] Error Context: ***
  "body": ***
    "error": ***
      "code": 409,
      "message": "index already exists",
      "status": "ALREADY_EXISTS"
    ***
  ***,
  "response": ***
    "statusCode": 409
  ***
***

This issue on GitHub is about the same problem. Yes, it's from 2019 and marked as closed but notice that it has fresh (July 2025) comments mentioning that the problem still appears. Maybe Firestore team will address it. In the meantime removing the index manually through Firestore console and triggering redeployment fixed the issue (indexes form firestore.indexes.json were recreated).

 

Deployment failing on installing Firebase CLI

GitHub Action with a step that installs firebase-tools was working just fine, then suddenly builds stated to fail with such error:

-- Setting permissions on binary... /usr/local/bin/firebase
/usr/local/bin/firebase: line 1: Not: command not found
Something went wrong, firebase has not been installed.
Please file a bug with your system information on Github.
https://github.com/firebase/firebase-tools/
-- All done!
Error: Process completed with exit code 1.

It turned out that many people were affected by the same problem and an issue on the CLI repo was raised quicky. The problem is now fixed and Firebase team offered a brief explanation (artifacts for outdated version were built). This is not the first time such CI/CD pipeline mishap happened, a workaround is suggested here.

 

MUI X Date Picker stopped informing about invalid date

Recently a project I work on was migrated from @mui/material 6.4.6 and @mui/x-date-pickers: 7.21.0 to @mui/material: 7.1.1 and @mui/x-date-pickers: 8.5.2. The migration was mostly painless... Except for one regression: suddenly validation on date field stopped rejecting incomplete inputs. It used to happen that when user removed part of date, and placeholder started to be partially visible, the validation kicked in and user was not able to progress through questionnaire. After the migration, incomplete input was simply ignored and nothing prevented user from going forward (the field is not mandatory). This is quite dangerous, it's easy for people to accidently remove part of input with complex placeholders and save the form assuming that a correct value is provided, while it is silently dropped.

The problem happens because previous version provided Invalid Date as a value to onChange handler if input was incomplete, but recent version gives null in that case. 

There's an issue about it and I'm asking MUI team to restore some easy way to recognize incomplete input. There's hope, the issue appears on a milestone.

 

Warning about unnecessary React useEffect dependency on ref

I was doing a bit of layout simplification and wanted to get rid of MUI PaperTrapFocus wrapper on certain panel and replace it with a simple Box wrapper. It all went well except that the wrapper needed to be automatically focused when screen loaded (for the sake of some keyboard event handling). Initially I've added the focus by keeping a ref to the wrapper div (the Box) and focusing it in a useEffect that depended on the ref. In this specific case it worked, but it felt a bit dirty and made React linter complain with such message:

81:6 warning React Hook useEffect has an unnecessary dependency: 'wrapperRef.current'. Either exclude it or remove the dependency array. Mutable values like 'wrapperRef.current' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps

Fortunately there's a better way. In React, refs can point to functions too! All I needed was a function wrapped by useCallback hook that set the focus when wrapper element became available:

const wrapperFocusRef = useCallback((wrapper: HTMLDivElement) => {
 wrapper?.focus();
}, []);

<Box
  ref={wrapperFocusRef}
  sx={{ outline: 'none' }}
  tabIndex={-1}
  onKeyDown={handleKeyDown}
>

Check this GH comment and this older piece of docs for details.

 

Toast message unusable for copying

The app I work on uses React-Toastify, lib works well, but I noticed one annoying thing with our setup: it was difficult to select text from message. Trying to highlight part of text moved the toast on horizontal axis and reduced opacity. 

This happened becasue the draggable option was set to true in ToastContainerProps. With draggable option active, user can close the toast by sliding it across the screen. In my case this was not needed becasue messages are closable by a button.

If really necessary, the draggable could stay active on mobile devices (where swiping motion is natural) and be disabled on desktop (where selecting with mouse is natural). In Capacitor app, the decision can be made with isNativePlatform call. If disabling draggable is out of the question, some CSS tricks with .Toastify__toast and .Toastify__toast:hover classes could help (pointer-eventstransformopacity...).

The Daily Grind (Quick Tips on Miscellaneous Issues, Ep. 2)

Intro

Here's a second round of random issues and quick tips for working around them. Part one is here.

The issues:

 

Firebase app GitHub Actions deploy failing (IAM_PERMISSION_DENIED)

I've added a feature that used Firebase Cloud Storage bucket. It all worked well on emulator but when I wanted to deploy it to test environment through GitHub Actions, the job failed with such error:

Error: Request to https://firebasestorage.googleapis.com/v1alpha/projects/example-project/defaultBucket had HTTP Error: 403, Permission 'firebasestorage.defaultBucket.get' denied on resource '//firebasestorage.googleapis.com/projects/example-project/defaultBucket' (or it may not exist).

"details": [
  ***
    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
    "reason": "IAM_PERMISSION_DENIED",
    "domain": "firebasestorage.googleapis.com",
    "metadata": ***
      "resource": "projects/example-project/defaultBucket",
      "permission": "firebasestorage.defaultBucket.get"
    ***
  ***
]

It happened because GitHub Action was using a service account which lacked firebasestorage.defaultBucket.get permission. The permission is listed under roles/firebasestorage.viewer, so such role should be added to the service account. 

You can see your project's service accounts in Project settings / Service account page in Firebase console and manage it on GCP IAM & Admin / IAM page (IAM stands for Identity and Access Management). My project uses Tarraform and Terragrunt so after adding the role through GCP console (for a quick test) I had to add it to .tf file and run terragrunt apply to modify all the environments...

 

SignatureDoesNotMatch error while fetching file from a bucket

I was fetching a file with unusual file extension in React app (in-house DSL that programs clinical trial questionnaires). To work correctly it needed to be recognized as text/plain MIME type instead of the default application/octet-stream. When the file was loaded from Firebase hosting, I could set 'Content-Type': 'text/plain' header while fetching. When I've tired to do the same after switching to Firestore bucket, an 403 error with SignatureDoesNotMatch in response stated to appear.

The signature feature didn't like the fetch header setting, but it was possible to go without it by setting the Content-Type property in the uploaded file metadata. To edit the metadata you can go to GCP storage browser and use Storage bucket details -> Edit metadata menu.

BTW: You might face another issue while working with cloud storage: your newly uploaded file will be reachable when opening file link in browser or by doing a GET in curl but it might fail with fetch due to lack of CORS settings. If you get this issue look here or search how to use gsutil cors set cors.json gs://your-bucket-name command.

 

No level config per handler in @datadog/browser-logs

Datadog has a pretty neat feature that allows sending logs from browser to Datadog servers (including automatic reporting of unhandled errors and rejected promises). You might want to use the http handler that sends data over network (debounced) and the console handler that forwards entries to browser's console. You can set the log level (e.g. info vs debug), but the issue is that you cannot configure different level for each handler. That's a bummer, it would be cool to use info level for logs sent over network but still see the debug logs in console...

Fortunately there is a simple workaround. The datadogLogs has beforeSend callback and you can use it to filter out debug entries:

datadogLogs.init({
  // More init props here
  beforeSend: logsEvent => {
    return logsEvent.status !== 'debug';
  },
});

const logger = datadogLogs.createLogger('example-logger', {
  level: 'debug',
  handler: ['http', 'console'],
});

 

Grep skipping hidden files (fzf-lua+ripgrep in Neovim)

I like ripgrep and fzf a lot! These are great not only as standalone command line tools, but can also be combined to provide awesome search experience in Vim/Neovim. For Vim there's a fzf.vim plugin and for Neovim I can recommend se fzf-lua.

I often use life_grep command for quick regexp search of the codebase (which can later be fine tuned by fuzzy search of files that contain a match). I have this mapping:

vim.keymap.set('n', '<leader>sg', ':FzfLua live_grep<CR>', { desc = '[s]earch live [g]rep' })

I used to have such config for the fzf-lua plugin:

require('fzf-lua').setup { 'max-perf' }

but there was an issue: grepping would skip hidden files and directories (.env files, .github folder etc.). I think it's much safer and useful to check the hidden files too, so now I'm using such config: 

require('fzf-lua').setup {
  'max-perf',
  grep = {
    rg_opts = "--hidden --glob '!.git/' --color=never --no-heading --column -n --smart-case",
  },
}

With the above, live_grep still avoids scanning the .git folder, and node_modules are still skipped due to ripgrep's sensible defaults but I won't miss occurrences in hidden files.

There's a simpler option, if you don't care about .git being included in search too:

require('fzf-lua').setup {
  'max-perf',
  grep = {
    hidden = true,
  },
}

 

The Daily Grind (Quick Tips on Miscellaneous Issues, Ep. 1)

Intro

This post will be different from other texts I've put on this blog so far. Instead of digging deep into a singe topic, I'll mention some issues that stumped me recently and suggest solutions. These issues might be quite niche, but I hope it will help at least one person get unstuck. Well, it might be me in the future. I'm 40 now - it's about time I started writing things down ;)

The issues:

 

Logout not working in React + Capacitor app (Auth Connect)

I was working on a PoC app with React and Capacitor for Web and Android platforms, based on Auth Connect tutorial. The app was initially using Auth0Provider to connect to Ionic's test client. It worked fine. Unfortunately as soon as I switched to OIDC auth used at my company, the logout action stopped working (while login still worked)! Logout was not invoking correct URL, it looked like the end_session_endpoint property from .well-known discovery was ignored.

Switching from Auth0Provider to OktaProvider solved the issue. If more problems appear, maybe it will be necessary to do a custom provider...

Login action stuck in React + Capacitor app (Auth Connect)

While working again on the same PoC app mentioned above, I've faced an issue with login on Android (the Web version was all good). First part of the login process worked fine: user was redirected to auth page, got a chance to enter email and input one-time password. But, after clicking the sign in button nothing happened (user should be redirected back to the Android app). Keycloak logs showed successful LOGIN event... Weird... I've checked out older version (based on Auth Connect tutorial) which worked ok and compared the logs in Android Studio Logcat: the broken version lacked App restarted, Auth Code, Login Token Provider and Fetch Token URL entries that follow successful Login...

It turned out that I didn't have proper AUTH_URL_SCHEME setting in android/variables.gradle file. If your Android URLs start with com.example.myapp:// then you should add AUTH_URL_SCHEME = 'com.example.myapp' variable (BTW: for production apps look into Deep Links). After fixing the setting, the login flow started to work correctly. Now Keyclock events log shows not only LOGIN entry but also CODE_TO_TOKEN following it.

No details in Capacitor/Console logs

It's easy to get used to how good modern console logging is. If you write console.log('Some message', {someObject}) then Chrome will display the logged message and show expandable object details (which you can copy to clipboard or save as variable). Node will show the details too (formatted and colored)... Watch out though, because if you are working on a Native target in Capacitor, the same console.log call will end up as useless 'Some message', [object Object] line that you can see in Android Studio Logcat.

You can make the log more useful by creating a small utility, that prepares parameters in such way that when the app runs on Native platform, the details object is serialized (you don't want to do it for Web unless you are ready to lose special object handling and see just a string instead):

import { Capacitor } from '@capacitor/core';

export const getLogArgs = (message: string, details: unknown) =>
  Capacitor.isNativePlatform()
    ? [message, JSON.stringify(details, null, 2)]
    : [message, details];

// Usage:
console.log(...getLogArgs('Some message', { someObject }));

It's tempting to create a simple function that wraps a call to console.log but that has a serious drawback: your log entries will be marked as coming from file and line that contains the wrapper function instead of a place that added the message.

Unable to start application on Android Virtual Device (activity class does not exist)

I occasionally get "Activity class {com.example.something/com.example.something.MainActivity} does not exist" error while trying to start application in Android emulator from Android Studio. Restarting virtual device, using "Invalidate Caches" or "Reload All from Disk" options in Android Studio doesn't help. Rebuilding the project, doing npx cap sync etc. also doesn't solve the issue. The error persists...

One thing that finally solves it is wiping out emulated device data. Losing the data is a bit annoying (for example I will need to onboard a fingerprint again), but it's much better than not being able to test the app at all :) There's probably a better way, but I don't do that much on Android... You can find "Wipe Data" option in virtual devices manager:

Wipe Data

 

Problems with accessing internal NPM dependency

I work on projects that mix dependencies on publicly accessible NPM packages with internal packages from GitHub Packages registry. Recently I couldn't get a project running after moving to new laptop, Yarn was not able to install an internal package and it did not provide useful info on why the authorization failed...

Your projects might be setup in a different way, but for me the issue was that my GITHUB_TOKEN was refreshed and it lost SSO (single sign-on) access to my company organization in GitHub. The issue was easy to spot after running this command:

curl -H "Authorization: Bearer ${GITHUB_TOKEN}" https://npm.pkg.github.com/your-org/your-package

because it gave such helpful information:

{"error":"Permission permission_denied: Resource protected by organization SAML enforcement. You must grant your Personal Access token access to this organization."}

Some other things worth checking if you use GITHUB_TOKEN and internal NPM registry is to verify if your shell gives you proper (current) value of token from environment variable by running env | grep GITHUB_TOKEN command. Check also if you have good config in .npmrc and run npm login at least once (in my case the expected password was the token, not my normal login password!)...

RTK Slice Reducers Performance

INTRO

Previous two posts (1, 2) were about rendering performance, now it's time for something from the back-of-the-fronted: state management. More specifically: checking the speed at which Redux Toolkit can create slices/reducers and handle dispatched actions. The RTK addressed Redux's primary criticism: amount of boilerplate. Now (read: for many years) a couple of lines of code is enough to get fully functioning store setup with dev tools enabled, reducers with Immer, actions (creators), thunks etc. All with great TypeScript support... Plenty of nice features, but how about the speed

TL;DR: The overhead of building a store and reducers with RTK should not significantly impact application performance in real-life scenarios.

BTW: Check RTK Query for fetching & caching!

 

THE TEST APP

The app was made with Vite Vanilla + TypeScript template (vite: 5.4.10, typescript: 5.6.2) with added Redux Toolkit (@reduxjs/toolkit: 2.3.0).

Live demo is here: https://morzel85.github.io/blog-post-rtk-slice-reducers-performance

The code is here: https://github.com/morzel85/blog-post-rtk-slice-reducers-performance

Performance test app... Click to enlarge...

When "Setup store ..." button is clicked, the application uses createSlice function to make slice reducers with requested amount of actions. Each reducer manages a state that contains numerical properties. The amount of properties matches the amount of actions. Each generated action is designed to control single state property. The count of slices and actions per each slice is controlled by SLICES_COUNT and ACTIONS_PER_SLICE_COUNT constants. On the published version of the app these are set to 500 and 25 respectively, which gives a total of 12.5K actions. Once the slices are defined, the store is created with a call to configureStore and a subscription to store changes is added. When "Dispatch random change" button is pressed, a single action is dispatched to modify single value in one of the state slices...

The 500 * 25 setup is quite big, so let's see a simpler example and assume that we want to have 2 slices with 3 actions in each. In that case, we would have such initial state in store:

{
  slice_0: {
    value_0: 0,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Now, let's assume that after clicking "Dispatch random change" button, such action was fired:

{
  type: 'slice_0/changed_0',
  payload: 0.715990726007008
}

After it's handled, the state should look like this:

{
  slice_0: {
    value_0: 0.715990726007008,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Here's the code (crammed into main.ts):

import { configureStore, createSlice } from '@reduxjs/toolkit';
import type { EnhancedStore, PayloadAction, ReducersMapObject } from '@reduxjs/toolkit';
import './style.css';

export const SLICES_COUNT = 500;
export const ACTIONS_PER_SLICE_COUNT = 25;

type SliceState = Record<string, number>;
type SliceAction = PayloadAction<number>;
type SliceReducer = Record<string, (state: SliceState, action: SliceAction) => void>;

const setupStore = () => {
  const slices = [];
  let createSliceDurationSum = 0;

  for (let s = 0; s < SLICES_COUNT; s++) {
    const initialState: SliceState = {};
    const reducers: SliceReducer = {};

    // Each slice state should have value_* properties and a matching amount
    // of changed_* actions that modify corresponding value in the state.
    for (let a = 0; a < ACTIONS_PER_SLICE_COUNT; a++) {
      initialState[`value_${a}`] = 0;
      reducers[`changed_${a}`] = (state: SliceState, action: SliceAction) => {
        state[`value_${a}`] = action.payload;
      };
    }

    // Creating slice (RTK API)
    const start = performance.now();
    const slice = createSlice({
      name: `slice_${s}`,
      initialState,
      reducers
    });
    const end = performance.now();
    createSliceDurationSum += end - start;

    slices.push(slice);
  }

  console.log(
    performance.measure('create-slices', {
      start: 0,
      duration: createSliceDurationSum
    })
  );

  // Collecting slice reducers config
  const reducer = slices.reduce((acc, { name, reducer }) => {
    acc[name] = reducer;
    return acc;
  }, {} as ReducersMapObject);

  // Configuring store (RTK API)
  performance.mark('configure-store-start');
  const store = configureStore({
    reducer
  });
  performance.mark('configure-store-end');
  console.log(
    performance.measure('configure-store', {
      start: 'configure-store-start',
      end: 'configure-store-end',
      detail: {
        slices: SLICES_COUNT,
        actionsPerSlice: ACTIONS_PER_SLICE_COUNT,
        actionsTotal: SLICES_COUNT * ACTIONS_PER_SLICE_COUNT,
        statePropertiesTotal: SLICES_COUNT + SLICES_COUNT * ACTIONS_PER_SLICE_COUNT
      }
    })
  );

  // Listening for changes in store (Redux API)
  store.subscribe(() => {
    performance.mark('action-end');
    console.log(performance.measure('action', 'action-start', 'action-end'));
  });

  return store;
};

let store: EnhancedStore | undefined;

// Buttons handling
const setupStoreButton = document.getElementById('setupStore') as HTMLButtonElement;
const randomChangeButton = document.getElementById('randomChange') as HTMLButtonElement;

setupStoreButton.textContent += ` (slices: ${SLICES_COUNT}, actions per slice: ${ACTIONS_PER_SLICE_COUNT})`;

setupStoreButton.addEventListener('click', () => {
  store = setupStore();
  setupStoreButton.disabled = true;
  randomChangeButton.disabled = false;
});

randomChangeButton.addEventListener('click', () => {
  if (store) {
    const sliceName = `slice_${Math.floor(Math.random() * SLICES_COUNT)}`;
    const actionName = `changed_${Math.floor(Math.random() * ACTIONS_PER_SLICE_COUNT)}`;
    const action = {
      type: `${sliceName}/${actionName}`,
      payload: Math.random()
    };

    // Dispatching action (Redux API)
    performance.mark('action-start');
    store.dispatch(action);
  }
});

The code uses Performance API, and logs measurements to the console.

THE RESULTS

The test were run on 7 years old PC with Intel Core i7-7700K and 16 GB RAM with Chrome 130 on Ubuntu 20.04. 

Each test consisted of creating a store and dispatching 5 times (to get average time for action, as these can vary).
I've run each SLICES_COUNT * ACTIONS_PER_SLICE_COUNT test case 3 times and reported the average duration:

  • 100 slices * 10 actions (total actions: 1K, state size: 1.1K properties): 
    create-slices: 15msconfigure-store: 16ms, action: 1ms
  • 50 slices * 50 actions (total actions: 2.5K, state size: 2.55K properties): 
    create-slices: 21msconfigure-store: 17ms, action: 1ms
  • 100 slices * 100 actions (total actions: 10K, state size: 10.1K properties): 
    create-slices: 38msconfigure-store: 18ms, action: 1.3ms
  • 500 slices * 25 actions (total actions: 12.5K, state size: 13K properties): 
    create-slices: 45msconfigure-store: 17ms, action: 2.6ms
  • 500 slices * 500 actions (total actions: 250K, state size: 250.5K properties): 
    create-slices: 320msconfigure-store: 530ms, action: 6ms
  • 1000 slices * 10 actions (total actions: 10K, state size: 11K properties): 
    create-slices: 46msconfigure-store: 17ms, action: 3ms
  • 1000 slices * 100 actions (total actions: 100K, state size: 101K properties): 
    create-slices: 140msconfigure-store: 120ms, action: 4ms
  • 1000 slices * 1000 actions (total actions: 1M - yeah, state size: 1.001M properties): 
    create-slices: 900msconfigure-store: 3320ms, action: 10ms

This is what I got, get the app and test on your machine with various SLICES_COUNT and ACTIONS_PER_SLICE_COUNT values. Just remember to run in release mode because development has safety features enabled and you are likely to see SerializableStateInvariantMiddleware and ImmutableStateInvariantMiddleware warnings for very large stores. For large test cases, and even in release mode, you can expect to see "[Violation] 'click' handler took <number>ms" when the "Setup store ..." button is clicked (the entire setup phase is done synchronously in button event handler to keep things simple).

You got to admit that these test cases start quite big (1K actions), I bet most apps don't need that much. The later test cases are just for fun. If your web app really needs a million unique actions then for sure you got bigger problems than state management :)

Would you like to test some more? Consider adding cases for various store structures (nesting, arrays) and features like slice selectors or extra reducers... GL&HF!

P.S. If tormenting RTK with thousands of actions is not your thing, maybe you will like this (slightly more pragmatic) post: https://en.morzel.net/post/accessing-state-in-redux-thunks