Optimize Message rendering
This commit is contained in:
parent
2d6f13a8c5
commit
5a98fc2f4c
10 changed files with 242 additions and 133 deletions
55
ts/components/PureComponentProfiler.tsx
Normal file
55
ts/components/PureComponentProfiler.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export abstract class PureComponentProfiler<
|
||||
Props extends Record<string, unknown>,
|
||||
State extends Record<string, unknown>
|
||||
> extends React.Component<Props, State> {
|
||||
public override shouldComponentUpdate(
|
||||
nextProps: Props,
|
||||
nextState: State
|
||||
): boolean {
|
||||
console.group(`PureComponentProfiler(${this.props.id})`);
|
||||
|
||||
const propKeys = new Set([
|
||||
...Object.keys(nextProps),
|
||||
...Object.keys(this.props),
|
||||
]);
|
||||
|
||||
const stateKeys = new Set([
|
||||
...Object.keys(nextState ?? {}),
|
||||
...Object.keys(this.state ?? {}),
|
||||
]);
|
||||
|
||||
let result = false;
|
||||
for (const key of propKeys) {
|
||||
if (nextProps[key] !== this.props[key]) {
|
||||
console.error(
|
||||
`propUpdated(${key})`,
|
||||
this.props[key],
|
||||
'=>',
|
||||
nextProps[key]
|
||||
);
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
for (const key of stateKeys) {
|
||||
if (nextState[key] !== this.state[key]) {
|
||||
console.error(
|
||||
`stateUpdated(${key}):`,
|
||||
this.state[key],
|
||||
'=>',
|
||||
nextState[key]
|
||||
);
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -584,7 +584,7 @@ function ReplyOrReactionMessage({
|
|||
conversationType="group"
|
||||
direction="incoming"
|
||||
deletedForEveryone={reply.deletedForEveryone}
|
||||
menu={undefined}
|
||||
renderMenu={undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -92,7 +92,7 @@ import { Emojify } from './Emojify';
|
|||
import { getPaymentEventDescription } from '../../messages/helpers';
|
||||
import { PanelType } from '../../types/Panels';
|
||||
|
||||
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
|
||||
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
|
||||
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
||||
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
|
||||
delivered: 24,
|
||||
|
@ -274,8 +274,10 @@ export type PropsData = {
|
|||
isMessageRequestAccepted: boolean;
|
||||
bodyRanges?: HydratedBodyRangesType;
|
||||
|
||||
menu: JSX.Element | undefined;
|
||||
renderMenu?: () => JSX.Element | undefined;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
item?: never;
|
||||
};
|
||||
|
||||
export type PropsHousekeeping = {
|
||||
|
@ -2531,7 +2533,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
isSticker,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
menu,
|
||||
renderMenu,
|
||||
onKeyDown,
|
||||
} = this.props;
|
||||
const { expired, expiring, isSelected, imageBroken } = this.state;
|
||||
|
@ -2565,7 +2567,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{this.renderError()}
|
||||
{this.renderAvatar()}
|
||||
{this.renderContainer()}
|
||||
{menu}
|
||||
{renderMenu?.()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ const defaultMessage: MessageDataPropsType = {
|
|||
direction: 'incoming',
|
||||
id: 'my-message',
|
||||
renderingContext: 'storybook',
|
||||
menu: undefined,
|
||||
renderMenu: undefined,
|
||||
isBlocked: false,
|
||||
isMessageRequestAccepted: true,
|
||||
previews: [],
|
||||
|
|
|
@ -324,7 +324,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
contactNameColor={contactNameColor}
|
||||
containerElementRef={this.messageContainerRef}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||
menu={undefined}
|
||||
renderMenu={undefined}
|
||||
disableScroll
|
||||
displayLimit={Number.MAX_SAFE_INTEGER}
|
||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||
|
|
|
@ -214,6 +214,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
shouldRenderDateHeader,
|
||||
startCallingLobby,
|
||||
theme,
|
||||
...reducedProps
|
||||
} = this.props;
|
||||
|
||||
if (!item) {
|
||||
|
@ -230,7 +231,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
if (item.type === 'message') {
|
||||
itemContents = (
|
||||
<TimelineMessage
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
isSelected={isSelected}
|
||||
shouldCollapseAbove={shouldCollapseAbove}
|
||||
|
@ -247,7 +248,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
notification = (
|
||||
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = (
|
||||
|
@ -262,26 +263,26 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification {...this.props} i18n={i18n} />
|
||||
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'deliveryIssue') {
|
||||
notification = (
|
||||
<DeliveryIssueNotification
|
||||
{...item.data}
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'universalTimerNotification') {
|
||||
notification = renderUniversalTimerNotification();
|
||||
} else if (item.type === 'changeNumberNotification') {
|
||||
notification = (
|
||||
<ChangeNumberNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
@ -289,7 +290,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
@ -297,27 +298,33 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV2Change') {
|
||||
notification = (
|
||||
<GroupV2Change {...this.props} {...item.data} i18n={i18n} />
|
||||
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV1Migration') {
|
||||
notification = (
|
||||
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
|
||||
<GroupV1Migration
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'conversationMerge') {
|
||||
notification = (
|
||||
<ConversationMergeNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
@ -325,17 +332,19 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
} else if (item.type === 'phoneNumberDiscovery') {
|
||||
notification = (
|
||||
<PhoneNumberDiscoveryNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = <ResetSessionNotification {...this.props} i18n={i18n} />;
|
||||
notification = (
|
||||
<ResetSessionNotification {...reducedProps} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'profileChange') {
|
||||
notification = (
|
||||
<ProfileChangeNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
@ -343,7 +352,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
} else if (item.type === 'paymentEvent') {
|
||||
notification = (
|
||||
<PaymentEventNotification
|
||||
{...this.props}
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Ref } from 'react';
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||
import ReactDOM, { createPortal } from 'react-dom';
|
||||
|
@ -106,6 +106,8 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
showMessageDetail,
|
||||
text,
|
||||
timestamp,
|
||||
kickOffAttachmentDownload,
|
||||
saveAttachment,
|
||||
} = props;
|
||||
|
||||
const [reactionPickerRoot, setReactionPickerRoot] = useState<
|
||||
|
@ -116,27 +118,28 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
const isWindowWidthNotNarrow =
|
||||
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
||||
|
||||
function popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
|
||||
return {
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
altAxis: true,
|
||||
boundary: containerElementRef.current || undefined,
|
||||
padding: {
|
||||
bottom: 16,
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 16,
|
||||
const popperPreventOverflowModifier =
|
||||
useCallback((): Partial<PreventOverflowModifier> => {
|
||||
return {
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
altAxis: true,
|
||||
boundary: containerElementRef.current || undefined,
|
||||
padding: {
|
||||
bottom: 16,
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
}, [containerElementRef]);
|
||||
|
||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||
// It needs to be unique.
|
||||
const triggerId = String(id || `${author.id}-${timestamp}`);
|
||||
|
||||
const toggleReactionPicker = React.useCallback(
|
||||
const toggleReactionPicker = useCallback(
|
||||
(onlyRemove = false): void => {
|
||||
if (reactionPickerRoot) {
|
||||
document.body.removeChild(reactionPickerRoot);
|
||||
|
@ -173,42 +176,46 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
};
|
||||
});
|
||||
|
||||
const openGenericAttachment = (event?: React.MouseEvent): void => {
|
||||
const { kickOffAttachmentDownload, saveAttachment } = props;
|
||||
const openGenericAttachment = useCallback(
|
||||
(event?: React.MouseEvent): void => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!attachments || attachments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const attachment = attachments[0];
|
||||
if (!isDownloaded(attachment)) {
|
||||
kickOffAttachmentDownload({
|
||||
attachment,
|
||||
messageId: id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
if (!isDownloaded(attachment)) {
|
||||
kickOffAttachmentDownload({
|
||||
attachment,
|
||||
messageId: id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveAttachment(attachment, timestamp);
|
||||
},
|
||||
[kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
|
||||
);
|
||||
|
||||
saveAttachment(attachment, timestamp);
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
return;
|
||||
}
|
||||
if (menuTriggerRef.current) {
|
||||
menuTriggerRef.current.handleContextClick(event);
|
||||
}
|
||||
};
|
||||
const handleContextMenu = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
return;
|
||||
}
|
||||
if (menuTriggerRef.current) {
|
||||
menuTriggerRef.current.handleContextClick(event);
|
||||
}
|
||||
},
|
||||
[menuTriggerRef]
|
||||
);
|
||||
|
||||
const canForward =
|
||||
!isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment;
|
||||
|
@ -229,11 +236,18 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
? openGenericAttachment
|
||||
: undefined;
|
||||
|
||||
const handleReplyToMessage = canReply
|
||||
? () => setQuoteByMessageId(conversationId, id)
|
||||
: undefined;
|
||||
const handleReplyToMessage = useCallback(() => {
|
||||
if (!canReply) {
|
||||
return;
|
||||
}
|
||||
setQuoteByMessageId(conversationId, id);
|
||||
}, [canReply, conversationId, id, setQuoteByMessageId]);
|
||||
|
||||
const handleReact = canReact ? () => toggleReactionPicker() : undefined;
|
||||
const handleReact = useCallback(() => {
|
||||
if (canReact) {
|
||||
toggleReactionPicker();
|
||||
}
|
||||
}, [canReact, toggleReactionPicker]);
|
||||
|
||||
const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false);
|
||||
const [hasDeleteConfirmation, setHasDeleteConfirmation] = useState(false);
|
||||
|
@ -252,6 +266,71 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
};
|
||||
}, [isSelected, toggleReactionPickerKeyboard]);
|
||||
|
||||
const renderMenu = useCallback(() => {
|
||||
return (
|
||||
<Manager>
|
||||
<MessageMenu
|
||||
i18n={i18n}
|
||||
triggerId={triggerId}
|
||||
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
|
||||
direction={direction}
|
||||
menuTriggerRef={menuTriggerRef}
|
||||
showMenu={handleContextMenu}
|
||||
onDownload={handleDownload}
|
||||
onReplyToMessage={handleReplyToMessage}
|
||||
onReact={handleReact}
|
||||
/>
|
||||
{reactionPickerRoot &&
|
||||
createPortal(
|
||||
<Popper
|
||||
placement="top"
|
||||
modifiers={[
|
||||
offsetDistanceModifier(4),
|
||||
popperPreventOverflowModifier(),
|
||||
]}
|
||||
>
|
||||
{({ ref, style }) =>
|
||||
renderReactionPicker({
|
||||
ref,
|
||||
style,
|
||||
selected: selectedReaction,
|
||||
onClose: toggleReactionPicker,
|
||||
onPick: emoji => {
|
||||
toggleReactionPicker(true);
|
||||
reactToMessage(id, {
|
||||
emoji,
|
||||
remove: emoji === selectedReaction,
|
||||
});
|
||||
},
|
||||
renderEmojiPicker,
|
||||
})
|
||||
}
|
||||
</Popper>,
|
||||
reactionPickerRoot
|
||||
)}
|
||||
</Manager>
|
||||
);
|
||||
}, [
|
||||
i18n,
|
||||
triggerId,
|
||||
isWindowWidthNotNarrow,
|
||||
direction,
|
||||
menuTriggerRef,
|
||||
handleContextMenu,
|
||||
handleDownload,
|
||||
|
||||
handleReplyToMessage,
|
||||
handleReact,
|
||||
reactionPickerRoot,
|
||||
popperPreventOverflowModifier,
|
||||
renderReactionPicker,
|
||||
selectedReaction,
|
||||
reactToMessage,
|
||||
renderEmojiPicker,
|
||||
toggleReactionPicker,
|
||||
id,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDOEConfirmation && canDeleteForEveryone && (
|
||||
|
@ -305,49 +384,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||
{...props}
|
||||
renderingContext="conversation/TimelineItem"
|
||||
onContextMenu={handleContextMenu}
|
||||
menu={
|
||||
<Manager>
|
||||
<MessageMenu
|
||||
i18n={i18n}
|
||||
triggerId={triggerId}
|
||||
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
|
||||
direction={direction}
|
||||
menuTriggerRef={menuTriggerRef}
|
||||
showMenu={handleContextMenu}
|
||||
onDownload={handleDownload}
|
||||
onReplyToMessage={handleReplyToMessage}
|
||||
onReact={handleReact}
|
||||
/>
|
||||
{reactionPickerRoot &&
|
||||
createPortal(
|
||||
<Popper
|
||||
placement="top"
|
||||
modifiers={[
|
||||
offsetDistanceModifier(4),
|
||||
popperPreventOverflowModifier(),
|
||||
]}
|
||||
>
|
||||
{({ ref, style }) =>
|
||||
renderReactionPicker({
|
||||
ref,
|
||||
style,
|
||||
selected: selectedReaction,
|
||||
onClose: toggleReactionPicker,
|
||||
onPick: emoji => {
|
||||
toggleReactionPicker(true);
|
||||
reactToMessage(id, {
|
||||
emoji,
|
||||
remove: emoji === selectedReaction,
|
||||
});
|
||||
},
|
||||
renderEmojiPicker,
|
||||
})
|
||||
}
|
||||
</Popper>,
|
||||
reactionPickerRoot
|
||||
)}
|
||||
</Manager>
|
||||
}
|
||||
renderMenu={renderMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -4101,6 +4101,10 @@ export class ConversationModel extends window.Backbone
|
|||
// Perform asynchronous tasks before entering the batching mode
|
||||
await this.beforeAddSingleMessage(model);
|
||||
|
||||
if (sticker) {
|
||||
await addStickerPackReference(model.id, sticker.packId);
|
||||
}
|
||||
|
||||
this.isInReduxBatch = true;
|
||||
batchDispatch(() => {
|
||||
try {
|
||||
|
@ -4146,10 +4150,6 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
});
|
||||
|
||||
if (sticker) {
|
||||
await addStickerPackReference(model.id, sticker.packId);
|
||||
}
|
||||
|
||||
const renderDuration = Date.now() - renderStart;
|
||||
|
||||
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
|
||||
|
|
|
@ -226,20 +226,22 @@ function sendMultiMediaMessage(
|
|||
toastType,
|
||||
},
|
||||
});
|
||||
dispatch(setComposerDisabledState(false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!message.length &&
|
||||
!hasDraftAttachments(conversation.attributes.draftAttachments, {
|
||||
includePending: false,
|
||||
}) &&
|
||||
!voiceNoteAttachment
|
||||
) {
|
||||
dispatch(setComposerDisabledState(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
!message.length &&
|
||||
!hasDraftAttachments(conversation.attributes.draftAttachments, {
|
||||
includePending: false,
|
||||
}) &&
|
||||
!voiceNoteAttachment
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let attachments: Array<AttachmentType> = [];
|
||||
if (voiceNoteAttachment) {
|
||||
attachments = [voiceNoteAttachment];
|
||||
|
@ -285,6 +287,7 @@ function sendMultiMediaMessage(
|
|||
undefined
|
||||
);
|
||||
dispatch(resetComposer());
|
||||
dispatch(setComposerDisabledState(false));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -293,7 +296,6 @@ function sendMultiMediaMessage(
|
|||
'Error pulling attached files before send',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
} finally {
|
||||
dispatch(setComposerDisabledState(false));
|
||||
}
|
||||
};
|
||||
|
|
|
@ -291,6 +291,10 @@ export function reducer(
|
|||
if (action.type === 'items/PUT_EXTERNAL') {
|
||||
const { payload } = action;
|
||||
|
||||
if (state[payload.key] === payload.value) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[payload.key]: payload.value,
|
||||
|
|
Loading…
Add table
Reference in a new issue