Repository: inovex/scrumlr.io (ts)

Issue:
<button> cannot appear as a descendant of <button>
### The problem

when checking the dev tools, you see the following warning:

> [!WARNING]  
> Warning: validateDOMNesting(...): \<button> cannot appear as a descendant of \<button>.

The notes in the columns are using a `button` element for their outermost element. The reaction add button inside of the note is the second `button` mentioned in the warning.


### Browser

Firefox, Chrome

### Steps to reproduce the behavior

- start server and frontend
- add a note
- check the console

### Screenshots

_No response_

### Additional context

_No response_

Pull request:
fix: console warnings
<!--
Please make sure the title of your PR starts with a semantic prefix:

feat: A new feature.
fix: A bug fix.
improvement: Implemented enhancements to existing functionality.
dependency: Updates or modifications to project dependencies or external libraries.
localization: Changes related to localization or internationalization of the codebase.
accessibility: Improvements made to enhance the accessibility of the application or website.
docs: Documentation only changes.
style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).
refactor: A code change that neither fixes a bug nor adds a feature.
perf: A code change that improves performance.
test: Adding missing tests or correcting existing tests.
build: Changes that affect the build system.
chore: Other changes that don't modify src or test files.
revert: Reverts a previous commit.
-->

## Description
<!-- Explain the changes you’ve made. It doesn’t need to be fancy and you don’t have to get too technical. -->
When you start the project and check the dev console, there are a few avoidable warnings/errors. Most are fixed with this PR.
The only error left is from the [avataaars](https://www.npmjs.com/package/avataaars) package wihch hasn't been updated recently and uses some legacy functionality.

## Changelog
<!-- Please provide a detailed list of the changes you have made to the codebase. -->
- fixed #3567
  - changed `button` to `div`
  - added `tabIndex={0}` and `role="button"`
  - updated the test snapshot
- fixed "A non-serializable value was detected in an action, in the path: `serverTime`."
  - pass the timestamp as a number instead of a date object
- fixed "Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders."
  - i added and removed some `isEqual` checks according to this schema:
  - ➖ wherever the exact object from state as a reference is returned, no isEqual is necessary
    since the objects aren't mutated, the check of reference is enought to detect state changes
  ```ts
  // example
  useAppSelector((state) => state.participants!.self)
  useAppSelector((state) => state.notes.find((n) => n.id === props.noteId))
  ```
  - ➖ wherever a basic type that is compareable with `===` is returned (`string`, `boolean`, `number`, ...) is returned, no Equal is necessary
  - ➕ wherever a new object is created and returned, isEqual is necessary
  ```ts
  // example
  useAppSelector(
    (state) => ({
      them: state.participants!.others.filter((participant) => participant.connected),
      me: state.participants!.self,
    }),
    isEqual
  );
  ```

## Checklist

- [x] I have performed a self-review of my own code
- [x] The application was tested in the most commonly used browsers (e.g. Chrome, Firefox, Safari)


Patch:
diff --git a/src/components/BoardUsers/BoardUsers.tsx b/src/components/BoardUsers/BoardUsers.tsx
index bd280195..a18b35a5 100644
--- a/src/components/BoardUsers/BoardUsers.tsx
+++ b/src/components/BoardUsers/BoardUsers.tsx
@@ -5,6 +5,7 @@ import {useNavigate} from "react-router";
 import {useTranslation} from "react-i18next";
 import {ProgressCircle} from "components/BoardHeader/ParticipantsList/ProgressCircle";
 import {ReactComponent as CheckIcon} from "assets/icon-ready.svg";
+import {isEqual} from "underscore";
 import {UserAvatar} from "./UserAvatar";
 
 const getWindowDimensions = () => {
@@ -38,10 +39,13 @@ export const BoardUsers = () => {
     NUM_OF_DISPLAYED_USERS = 4;
   }
 
-  const {me, them} = useAppSelector((state) => ({
-    them: state.participants!.others.filter((participant) => participant.connected),
-    me: state.participants!.self,
-  }));
+  const {me, them} = useAppSelector(
+    (state) => ({
+      them: state.participants!.others.filter((participant) => participant.connected),
+      me: state.participants!.self,
+    }),
+    isEqual
+  );
 
   const usersRest = them.slice();
   const usersToShow = usersRest.splice(0, them.length > NUM_OF_DISPLAYED_USERS ? NUM_OF_DISPLAYED_USERS - 1 : NUM_OF_DISPLAYED_USERS);
diff --git a/src/components/Column/Column.tsx b/src/components/Column/Column.tsx
index 86ade7ef..9bdab002 100644
--- a/src/components/Column/Column.tsx
+++ b/src/components/Column/Column.tsx
@@ -42,8 +42,8 @@ export const Column = ({id, name, color, visible, index}: ColumnProps) => {
         : [],
     _.isEqual
   );
-  const moderating = useAppSelector((state) => state.view.moderating, _.isEqual);
-  const viewer = useAppSelector((state) => state.participants!.self, _.isEqual);
+  const moderating = useAppSelector((state) => state.view.moderating);
+  const viewer = useAppSelector((state) => state.participants!.self);
 
   const colorClassName = getColorClassName(color);
   const isModerator = viewer.role === "OWNER" || viewer.role === "MODERATOR";
diff --git a/src/components/DragAndDrop/CustomDndContext.tsx b/src/components/DragAndDrop/CustomDndContext.tsx
index 47b711c2..b30d474b 100644
--- a/src/components/DragAndDrop/CustomDndContext.tsx
+++ b/src/components/DragAndDrop/CustomDndContext.tsx
@@ -21,6 +21,7 @@ import {MOVE_THRESHOLD} from "constants/misc";
 import {ReactNode, useState} from "react";
 import {useAppSelector} from "store";
 import {Column} from "types/column";
+import {isEqual} from "underscore";
 
 type CustomDndContextProps = {
   children: ReactNode;
@@ -30,7 +31,7 @@ export const CustomDndContext = ({children}: CustomDndContextProps) => {
   const [maxCollision, setMaxCollision] = useState<Collision | undefined>();
   const [dragActive, setDragActive] = useState<{id: string; colorClassName: string} | undefined>();
 
-  const {self, columns, notes} = useAppSelector((state) => ({self: state.participants?.self, columns: state.columns, notes: state.notes}));
+  const {self, columns, notes} = useAppSelector((state) => ({self: state.participants?.self, columns: state.columns, notes: state.notes}), isEqual);
 
   const sensors = useSensors(useSensor(MouseSensor, {activationConstraint: {distance: 2}}), useSensor(TouchSensor, {activationConstraint: {delay: 200, tolerance: 5}}));
   const dropAnimation: DropAnimation = {
diff --git a/src/components/Note/Note.tsx b/src/components/Note/Note.tsx
index 658ad8a1..f8b518a2 100644
--- a/src/components/Note/Note.tsx
+++ b/src/components/Note/Note.tsx
@@ -27,9 +27,9 @@ export const Note = (props: NoteProps) => {
   const {t} = useTranslation();
   const dispatch = useDispatch();
   const navigate = useNavigate();
-  const noteRef = useRef<HTMLButtonElement>(null);
+  const noteRef = useRef<HTMLDivElement>(null);
 
-  const note = useAppSelector((state) => state.notes.find((n) => n.id === props.noteId), isEqual);
+  const note = useAppSelector((state) => state.notes.find((n) => n.id === props.noteId));
   const isStack = useAppSelector((state) => state.notes.filter((n) => n.position.stack === props.noteId).length > 0);
   const isShared = useAppSelector((state) => state.board.data?.sharedNote === props.noteId);
   const allowStacking = useAppSelector((state) => state.board.data?.allowStacking ?? true);
@@ -99,7 +99,7 @@ export const Note = (props: NoteProps) => {
       className={classNames("note__root", props.colorClassName)}
       disabled={!(isModerator || allowStacking)}
     >
-      <button className={`note note--${stackSetting}`} onClick={handleClick} onKeyDown={handleKeyPress} ref={noteRef}>
+      <div tabIndex={0} role="button" className={`note note--${stackSetting}`} onClick={handleClick} onKeyDown={handleKeyPress} ref={noteRef}>
         <header className="note__header">
           <div className="note__author-container">
             <NoteAuthorList authors={authors} showAuthors={showAuthors} viewer={props.viewer} />
@@ -121,7 +121,7 @@ export const Note = (props: NoteProps) => {
         <footer className={classNames("note__footer", {"note__footer--collapsed": !showNoteReactions})}>
           <NoteReactionList noteId={props.noteId} dimensions={dimensions} colorClassName={props.colorClassName} show={showNoteReactions} />
         </footer>
-      </button>
+      </div>
       {isStack && <div className="note__in-stack" />}
     </Sortable>
   );
diff --git a/src/components/Note/NoteReactionList/NoteReactionPopup/NoteReactionPopup.tsx b/src/components/Note/NoteReactionList/NoteReactionPopup/NoteReactionPopup.tsx
index f9bd6cac..b26201fb 100644
--- a/src/components/Note/NoteReactionList/NoteReactionPopup/NoteReactionPopup.tsx
+++ b/src/components/Note/NoteReactionList/NoteReactionPopup/NoteReactionPopup.tsx
@@ -2,7 +2,6 @@ import React, {useEffect, useRef, useState} from "react";
 import {Portal} from "components/Portal";
 import classNames from "classnames";
 import {REACTION_EMOJI_MAP, ReactionType} from "types/reaction";
-import _ from "underscore";
 import {useAppSelector} from "store";
 import {useIsScrolling} from "utils/hooks/useIsScrolling";
 import {useDispatch} from "react-redux";
@@ -29,7 +28,7 @@ export const NoteReactionPopup = (props: NoteReactionPopupProps) => {
   const isScrolling = useIsScrolling(containerRef);
   // sum total reactions
   const totalReactions = props.reactionsReduced.reduce((acc: number, curr: ReactionModeled) => acc + curr.amount, 0);
-  const viewer = useAppSelector((state) => state.participants!.self, _.isEqual);
+  const viewer = useAppSelector((state) => state.participants!.self);
 
   // activeTab === undefined -> all are active
   const [activeTab, setActiveTab] = useState<ReactionType>();
diff --git a/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx b/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
index 35600cd1..8657d4d4 100644
--- a/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
+++ b/src/components/SettingsDialog/BoardSettings/BoardSettings.tsx
@@ -13,6 +13,7 @@ import {Toast} from "utils/Toast";
 import {generateRandomString} from "utils/random";
 import {Toggle} from "components/Toggle";
 import {ConfirmationDialog} from "components/ConfirmationDialog";
+import {isEqual} from "underscore";
 import {SettingsButton} from "../Components/SettingsButton";
 import {SettingsInput} from "../Components/SettingsInput";
 import "./BoardSettings.scss";
@@ -20,11 +21,14 @@ import "./BoardSettings.scss";
 export const BoardSettings = () => {
   const {t} = useTranslation();
 
-  const state = useAppSelector((applicationState) => ({
-    board: applicationState.board.data!,
-    me: applicationState.participants?.self,
-    currentUserIsModerator: applicationState.participants?.self.role === "OWNER" || applicationState.participants?.self.role === "MODERATOR",
-  }));
+  const state = useAppSelector(
+    (applicationState) => ({
+      board: applicationState.board.data!,
+      me: applicationState.participants?.self,
+      currentUserIsModerator: applicationState.participants?.self.role === "OWNER" || applicationState.participants?.self.role === "MODERATOR",
+    }),
+    isEqual
+  );
 
   const [boardName, setBoardName] = useState<string>(state.board.name ?? "");
   const [password, setPassword] = useState<string>("");
diff --git a/src/components/SettingsDialog/Components/AvatarSettings.tsx b/src/components/SettingsDialog/Components/AvatarSettings.tsx
index b2fa08bf..cb490cd9 100644
--- a/src/components/SettingsDialog/Components/AvatarSettings.tsx
+++ b/src/components/SettingsDialog/Components/AvatarSettings.tsx
@@ -20,6 +20,7 @@ import {useTranslation} from "react-i18next";
 import store, {useAppSelector} from "store";
 import {Actions} from "store/action";
 import "./AvatarSettings.scss";
+import {isEqual} from "underscore";
 import {SettingsAccordion} from "./SettingsAccordion";
 import {SettingsCarousel} from "./SettingsCarousel";
 
@@ -29,9 +30,12 @@ export interface AvatarSettingsProps {
 
 export const AvatarSettings: FC<AvatarSettingsProps> = ({id}) => {
   const {t} = useTranslation();
-  const state = useAppSelector((applicationState) => ({
-    participant: applicationState.participants!.self,
-  }));
+  const state = useAppSelector(
+    (applicationState) => ({
+      participant: applicationState.participants!.self,
+    }),
+    isEqual
+  );
 
   let initialState = state.participant.user.avatar;
   if (initialState === null || initialState === undefined) {
diff --git a/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx b/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx
index 153f5246..29ec28c4 100644
--- a/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx
+++ b/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx
@@ -7,6 +7,7 @@ import "./ProfileSettings.scss";
 import {useDispatch} from "react-redux";
 import {ReactComponent as InfoIcon} from "assets/icon-info.svg";
 import {Toggle} from "components/Toggle";
+import {isEqual} from "underscore";
 import {AvatarSettings} from "../Components/AvatarSettings";
 import {SettingsInput} from "../Components/SettingsInput";
 import {SettingsButton} from "../Components/SettingsButton";
@@ -15,10 +16,13 @@ export const ProfileSettings = () => {
   const {t} = useTranslation();
   const dispatch = useDispatch();
 
-  const state = useAppSelector((applicationState) => ({
-    participant: applicationState.participants!.self,
-    hotkeysAreActive: applicationState.view.hotkeysAreActive,
-  }));
+  const state = useAppSelector(
+    (applicationState) => ({
+      participant: applicationState.participants!.self,
+      hotkeysAreActive: applicationState.view.hotkeysAreActive,
+    }),
+    isEqual
+  );
 
   const [userName, setUserName] = useState<string>(state.participant?.user.name);
   const [id] = useState<string | undefined>(state.participant?.user.id);
diff --git a/src/routes/StackView/StackView.tsx b/src/routes/StackView/StackView.tsx
index ead6b508..3dd0da04 100644
--- a/src/routes/StackView/StackView.tsx
+++ b/src/routes/StackView/StackView.tsx
@@ -38,10 +38,10 @@ export const StackView = () => {
 
   const note = useAppSelector((state) => state.notes.find((n) => n.id === noteId));
   const prevNote = useRef<Note | undefined>(note);
-  const columns = useAppSelector((state) => state.columns, _.isEqual);
+  const columns = useAppSelector((state) => state.columns);
   const author = useAppSelector((state) => state.participants?.others.find((participant) => participant.user.id === note?.author) ?? state.participants?.self);
   const authorName = useAppSelector((state) => (author?.user.id === state.participants?.self.user.id ? t("Note.me") : author?.user.name));
-  const viewer = useAppSelector((state) => state.participants!.self, _.isEqual);
+  const viewer = useAppSelector((state) => state.participants!.self);
   const showNotesOfOtherUsers = useAppSelector((state) => state.board?.data?.showNotesOfOtherUsers);
   const stackedNotes = useAppSelector(
     (state) =>
@@ -126,7 +126,7 @@ export const StackView = () => {
     const authors = stack.map((n) => n.author);
     return authors.some((item) => item !== authors[0]);
   };
-  const entireStack = useAppSelector((state) => state.notes.filter((n) => n.id === note?.id || n.position.stack === note?.id));
+  const entireStack = useAppSelector((state) => state.notes.filter((n) => n.id === note?.id || n.position.stack === note?.id), _.isEqual);
   const stackHasMixedAuthors = hasMixedAuthors(entireStack);
 
   // update transition config when note changes so that the visible notes are updated without any animation
diff --git a/src/store/action/view.ts b/src/store/action/view.ts
index 3ccf8821..18989695 100644
--- a/src/store/action/view.ts
+++ b/src/store/action/view.ts
@@ -25,7 +25,7 @@ export const ViewActionFactory = {
     language,
   }),
 
-  setServerInfo: (enabledAuthProvider: string[], serverTime: Date, feedbackEnabled: boolean) => ({
+  setServerInfo: (enabledAuthProvider: string[], serverTime: number, feedbackEnabled: boolean) => ({
     type: ViewAction.SetServerInfo,
     enabledAuthProvider,
     serverTime,
diff --git a/src/store/middleware/view.tsx b/src/store/middleware/view.tsx
index de8ace05..35cf8f53 100644
--- a/src/store/middleware/view.tsx
+++ b/src/store/middleware/view.tsx
@@ -12,7 +12,7 @@ export const passViewMiddleware = (stateAPI: MiddlewareAPI<Dispatch, Application
   if (action.type === Action.InitApplication) {
     API.getServerInfo()
       .then((r) => {
-        dispatch(Actions.setServerInfo(r.authProvider || [], new Date(r.serverTime), r.feedbackEnabled));
+        dispatch(Actions.setServerInfo(r.authProvider || [], new Date(r.serverTime).getTime(), r.feedbackEnabled));
       })
       .catch(() => {
         i18n.on("loaded", () => {
diff --git a/src/store/reducer/view.ts b/src/store/reducer/view.ts
index e59e992e..a93b2fb8 100644
--- a/src/store/reducer/view.ts
+++ b/src/store/reducer/view.ts
@@ -42,7 +42,7 @@ export const viewReducer = (state: ViewState = INITIAL_VIEW_STATE, action: Redux
       return {
         ...state,
         enabledAuthProvider: action.enabledAuthProvider,
-        serverTimeOffset: new Date().getTime() - action.serverTime.getTime(),
+        serverTimeOffset: new Date().getTime() - action.serverTime,
         feedbackEnabled: action.feedbackEnabled,
       };
     }


Test patch:
diff --git a/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap b/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
index e4c3d4b6..8f6de3c2 100644
--- a/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
+++ b/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
@@ -96,8 +96,10 @@ exports[`Column should have correct style show column with correct style 1`] = `
             style="opacity: 1;"
             tabindex="-1"
           >
-            <button
+            <div
               class="note note--stackBetween"
+              role="button"
+              tabindex="0"
             >
               <header
                 class="note__header"
@@ -647,7 +649,7 @@ exports[`Column should have correct style show column with correct style 1`] = `
                   </div>
                 </div>
               </footer>
-            </button>
+            </div>
           </div>
         </li>
         <li>
@@ -660,8 +662,10 @@ exports[`Column should have correct style show column with correct style 1`] = `
             style="opacity: 1;"
             tabindex="-1"
           >
-            <button
+            <div
               class="note note--stackBetween"
+              role="button"
+              tabindex="0"
             >
               <header
                 class="note__header"
@@ -1178,7 +1182,7 @@ exports[`Column should have correct style show column with correct style 1`] = `
                   </div>
                 </div>
               </footer>
-            </button>
+            </div>
           </div>
         </li>
       </ul>