refactor(react): use memoization where appropriate

This commit is contained in:
Elian Doran 2025-08-10 17:19:39 +03:00
parent a6e56be55a
commit 3caefa5409
No known key found for this signature in database
4 changed files with 117 additions and 69 deletions

View File

@ -1,4 +1,5 @@
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { memo } from "preact/compat";
import AbstractBulkAction from "./abstract_bulk_action"; import AbstractBulkAction from "./abstract_bulk_action";
interface BulkActionProps { interface BulkActionProps {
@ -8,12 +9,17 @@ interface BulkActionProps {
bulkAction: AbstractBulkAction; bulkAction: AbstractBulkAction;
} }
export default function BulkAction({ label, children, helpText, bulkAction }: BulkActionProps) { // Define styles as constants to prevent recreation
const flexContainerStyle = { display: "flex", alignItems: "center" } as const;
const labelStyle = { marginRight: "10px" } as const;
const textStyle = { marginRight: "10px", marginLeft: "10px" } as const;
const BulkAction = memo(({ label, children, helpText, bulkAction }: BulkActionProps) => {
return ( return (
<tr> <tr>
<td colSpan={2}> <td colSpan={2}>
<div style={{ display: "flex", alignItems: "center" }}> <div style={flexContainerStyle}>
<div style={{ marginRight: "10px" }} className="text-nowrap">{label}</div> <div style={labelStyle} className="text-nowrap">{label}</div>
{children} {children}
</div> </div>
@ -33,14 +39,16 @@ export default function BulkAction({ label, children, helpText, bulkAction }: Bu
</td> </td>
</tr> </tr>
); );
} });
export function BulkActionText({ text }: { text: string }) { export default BulkAction;
export const BulkActionText = memo(({ text }: { text: string }) => {
return ( return (
<div <div
style={{ marginRight: "10px", marginLeft: "10px" }} style={textStyle}
className="text-nowrap"> className="text-nowrap">
{text} {text}
</div> </div>
); );
} });

View File

@ -1,6 +1,7 @@
import type { RefObject } from "preact"; import type { RefObject } from "preact";
import type { CSSProperties } from "preact/compat"; import type { CSSProperties } from "preact/compat";
import { useRef } from "preact/hooks"; import { useRef, useMemo } from "preact/hooks";
import { memo } from "preact/compat";
interface ButtonProps { interface ButtonProps {
/** Reference to the button element. Mostly useful for requesting focus. */ /** Reference to the button element. Mostly useful for requesting focus. */
@ -17,26 +18,41 @@ interface ButtonProps {
style?: CSSProperties; style?: CSSProperties;
} }
export default function Button({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) { const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) => {
const classes: string[] = ["btn"]; // Memoize classes array to prevent recreation
const classes = useMemo(() => {
const classList: string[] = ["btn"];
if (primary) { if (primary) {
classes.push("btn-primary"); classList.push("btn-primary");
} else { } else {
classes.push("btn-secondary"); classList.push("btn-secondary");
} }
if (className) { if (className) {
classes.push(className); classList.push(className);
} }
if (small) { if (small) {
classes.push("btn-sm"); classList.push("btn-sm");
} }
return classList.join(" ");
}, [primary, className, small]);
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null); const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
const splitShortcut = (keyboardShortcut ?? "").split("+");
// Memoize keyboard shortcut rendering
const shortcutElements = useMemo(() => {
if (!keyboardShortcut) return null;
const splitShortcut = keyboardShortcut.split("+");
return splitShortcut.map((key, index) => (
<>
<kbd key={index}>{key.toUpperCase()}</kbd>
{index < splitShortcut.length - 1 ? "+" : ""}
</>
));
}, [keyboardShortcut]);
return ( return (
<button <button
className={classes.join(" ")} className={classes}
type={onClick ? "button" : "submit"} type={onClick ? "button" : "submit"}
onClick={onClick} onClick={onClick}
ref={buttonRef} ref={buttonRef}
@ -44,13 +60,9 @@ export default function Button({ buttonRef: _buttonRef, className, text, onClick
style={style} style={style}
> >
{icon && <span className={`bx ${icon}`}></span>} {icon && <span className={`bx ${icon}`}></span>}
{text} {keyboardShortcut && ( {text} {shortcutElements}
splitShortcut.map((key, index) => (
<>
<kbd key={index}>{key.toUpperCase()}</kbd>{ index < splitShortcut.length - 1 ? "+" : "" }
</>
))
)}
</button> </button>
); );
} });
export default Button;

View File

@ -1,7 +1,8 @@
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
import { escapeQuotes } from "../../services/utils"; import { escapeQuotes } from "../../services/utils";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { memo } from "preact/compat";
interface FormCheckboxProps { interface FormCheckboxProps {
name: string; name: string;
@ -15,28 +16,41 @@ interface FormCheckboxProps {
onChange(newValue: boolean): void; onChange(newValue: boolean): void;
} }
export default function FormCheckbox({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) { const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => {
const labelRef = useRef<HTMLLabelElement>(null); const labelRef = useRef<HTMLLabelElement>(null);
if (hint) { // Fix: Move useEffect outside conditional
useEffect(() => { useEffect(() => {
let tooltipInstance: Tooltip | null = null; if (!hint || !labelRef.current) return;
if (labelRef.current) {
tooltipInstance = Tooltip.getOrCreateInstance(labelRef.current, { const tooltipInstance = Tooltip.getOrCreateInstance(labelRef.current, {
html: true, html: true,
template: '<div class="tooltip tooltip-top" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>' template: '<div class="tooltip tooltip-top" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>'
}); });
}
return () => tooltipInstance?.dispose(); return () => tooltipInstance?.dispose();
}, [labelRef.current]); }, [hint]); // Proper dependency
}
// Memoize style object
const labelStyle = useMemo(() =>
hint ? { textDecoration: "underline dotted var(--main-text-color)" } : undefined,
[hint]
);
// Memoize onChange handler
const handleChange = useCallback((e: Event) => {
onChange((e.target as HTMLInputElement).checked);
}, [onChange]);
// Memoize title attribute
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
return ( return (
<div className="form-checkbox"> <div className="form-checkbox">
<label <label
className="form-check-label tn-checkbox" className="form-check-label tn-checkbox"
style={hint && { textDecoration: "underline dotted var(--main-text-color)" }} style={labelStyle}
title={hint && escapeQuotes(hint)} title={titleText}
ref={labelRef} ref={labelRef}
> >
<input <input
@ -46,9 +60,11 @@ export default function FormCheckbox({ name, disabled, label, currentValue, onCh
checked={currentValue || false} checked={currentValue || false}
value="1" value="1"
disabled={disabled} disabled={disabled}
onChange={e => onChange((e.target as HTMLInputElement).checked)} /> onChange={handleChange} />
{label} {label}
</label> </label>
</div> </div>
); );
} });
export default FormCheckbox;

View File

@ -1,10 +1,11 @@
import { useContext, useEffect, useRef } from "preact/hooks"; import { useContext, useEffect, useRef, useMemo, useCallback } from "preact/hooks";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import type { CSSProperties, RefObject } from "preact/compat"; import type { CSSProperties, RefObject } from "preact/compat";
import { openDialog } from "../../services/dialog"; import { openDialog } from "../../services/dialog";
import { ParentComponent } from "./ReactBasicWidget"; import { ParentComponent } from "./ReactBasicWidget";
import { Modal as BootstrapModal } from "bootstrap"; import { Modal as BootstrapModal } from "bootstrap";
import { memo } from "preact/compat";
interface ModalProps { interface ModalProps {
className: string; className: string;
@ -101,18 +102,25 @@ export default function Modal({ children, className, size, title, header, footer
} }
}, [ show ]); }, [ show ]);
const dialogStyle: CSSProperties = {}; // Memoize styles to prevent recreation on every render
const dialogStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = {};
if (zIndex) { if (zIndex) {
dialogStyle.zIndex = zIndex; style.zIndex = zIndex;
} }
return style;
}, [zIndex]);
const documentStyle: CSSProperties = {}; const documentStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = {};
if (maxWidth) { if (maxWidth) {
documentStyle.maxWidth = `${maxWidth}px`; style.maxWidth = `${maxWidth}px`;
} }
if (minWidth) { if (minWidth) {
documentStyle.minWidth = minWidth; style.minWidth = minWidth;
} }
return style;
}, [maxWidth, minWidth]);
return ( return (
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}> <div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}>
@ -132,10 +140,10 @@ export default function Modal({ children, className, size, title, header, footer
</div> </div>
{onSubmit ? ( {onSubmit ? (
<form ref={formRef} onSubmit={(e) => { <form ref={formRef} onSubmit={useCallback((e) => {
e.preventDefault(); e.preventDefault();
onSubmit(); onSubmit();
}}> }, [onSubmit])}>
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner> <ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
</form> </form>
) : ( ) : (
@ -149,11 +157,15 @@ export default function Modal({ children, className, size, title, header, footer
); );
} }
function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) { const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) => {
const footerStyle: CSSProperties = _footerStyle ?? {}; // Memoize footer style
const footerStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = _footerStyle ?? {};
if (footerAlignment === "between") { if (footerAlignment === "between") {
footerStyle.justifyContent = "space-between"; style.justifyContent = "space-between";
} }
return style;
}, [_footerStyle, footerAlignment]);
return ( return (
<> <>
@ -168,4 +180,4 @@ function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle:
)} )}
</> </>
); );
} });