refactor(react): fix a few rules of hooks violations

This commit is contained in:
Elian Doran
2025-08-25 18:00:10 +03:00
parent e386b03b90
commit 733ec2c145
9 changed files with 125 additions and 162 deletions

View File

@@ -6,11 +6,6 @@ import { t } from "../../services/i18n";
export default function CallToActionDialog() { export default function CallToActionDialog() {
const activeCallToActions = useMemo(() => getCallToActions(), []); const activeCallToActions = useMemo(() => getCallToActions(), []);
if (!activeCallToActions.length) {
return <></>;
}
const [ activeIndex, setActiveIndex ] = useState(0); const [ activeIndex, setActiveIndex ] = useState(0);
const [ shown, setShown ] = useState(true); const [ shown, setShown ] = useState(true);
const activeItem = activeCallToActions[activeIndex]; const activeItem = activeCallToActions[activeIndex];
@@ -23,7 +18,7 @@ export default function CallToActionDialog() {
} }
} }
return ( return (activeCallToActions.length &&
<Modal <Modal
className="call-to-action" className="call-to-action"
size="md" size="md"

View File

@@ -36,25 +36,23 @@ export default function NoteTypeChooserDialogComponent() {
setShown(true); setShown(true);
}); });
if (!noteTypes.length) { useEffect(() => {
useEffect(() => { note_types.getNoteTypeItems().then(noteTypes => {
note_types.getNoteTypeItems().then(noteTypes => { let index = -1;
let index = -1;
setNoteTypes((noteTypes ?? []).map((item) => { setNoteTypes((noteTypes ?? []).map((item) => {
if (item.title === "----") { if (item.title === "----") {
index++; index++;
return { return {
title: SEPARATOR_TITLE_REPLACEMENTS[index], title: SEPARATOR_TITLE_REPLACEMENTS[index],
enabled: false enabled: false
}
} }
}
return item; return item;
})); }));
});
}); });
} }, []);
function onNoteTypeSelected(value: string) { function onNoteTypeSelected(value: string) {
const [ noteType, templateNoteId ] = value.split(","); const [ noteType, templateNoteId ] = value.split(",");

View File

@@ -18,34 +18,27 @@ import { useTriliumEvent } from "../react/hooks";
export default function RecentChangesDialog() { export default function RecentChangesDialog() {
const [ ancestorNoteId, setAncestorNoteId ] = useState<string>(); const [ ancestorNoteId, setAncestorNoteId ] = useState<string>();
const [ groupedByDate, setGroupedByDate ] = useState<Map<string, RecentChangeRow[]>>(); const [ groupedByDate, setGroupedByDate ] = useState<Map<string, RecentChangeRow[]>>();
const [ needsRefresh, setNeedsRefresh ] = useState(false); const [ refreshCounter, setRefreshCounter ] = useState(0);
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => { useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => {
setNeedsRefresh(true);
setAncestorNoteId(ancestorNoteId ?? hoisted_note.getHoistedNoteId()); setAncestorNoteId(ancestorNoteId ?? hoisted_note.getHoistedNoteId());
setShown(true); setShown(true);
}); });
if (!groupedByDate || needsRefresh) { useEffect(() => {
useEffect(() => { server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
if (needsRefresh) { .then(async (recentChanges) => {
setNeedsRefresh(false); // preload all notes into cache
} await froca.getNotes(
recentChanges.map((r) => r.noteId),
true
);
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`) const groupedByDate = groupByDate(recentChanges);
.then(async (recentChanges) => { setGroupedByDate(groupedByDate);
// preload all notes into cache });
await froca.getNotes( }, [ shown, refreshCounter ])
recentChanges.map((r) => r.noteId),
true
);
const groupedByDate = groupByDate(recentChanges);
setGroupedByDate(groupedByDate);
});
})
}
return ( return (
<Modal <Modal
@@ -60,7 +53,7 @@ export default function RecentChangesDialog() {
style={{ padding: "0 10px" }} style={{ padding: "0 10px" }}
onClick={() => { onClick={() => {
server.post("notes/erase-deleted-notes-now").then(() => { server.post("notes/erase-deleted-notes-now").then(() => {
setNeedsRefresh(true); setRefreshCounter(refreshCounter + 1);
toast.showMessage(t("recent_changes.deleted_notes_message")); toast.showMessage(t("recent_changes.deleted_notes_message"));
}); });
}} }}
@@ -113,10 +106,6 @@ function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map
} }
function NoteLink({ notePath, title }: { notePath: string, title: string }) { function NoteLink({ notePath, title }: { notePath: string, title: string }) {
if (!notePath || !title) {
return null;
}
const [ noteLink, setNoteLink ] = useState<JQuery<HTMLElement> | null>(null); const [ noteLink, setNoteLink ] = useState<JQuery<HTMLElement> | null>(null);
useEffect(() => { useEffect(() => {
link.createLink(notePath, { link.createLink(notePath, {

View File

@@ -201,17 +201,9 @@ function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: Revisi
return <></>; return <></>;
} }
switch (revisionItem.type) { switch (revisionItem.type) {
case "text": { case "text":
const contentRef = useRef<HTMLDivElement>(null); return <RevisionContentText content={content} />
useEffect(() => {
if (contentRef.current?.querySelector("span.math-tex")) {
renderMathInElement(contentRef.current, { trust: true });
}
});
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
}
case "code": case "code":
return <pre style={CODE_STYLE}>{content}</pre>; return <pre style={CODE_STYLE}>{content}</pre>;
case "image": case "image":
@@ -263,6 +255,16 @@ function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: Revisi
} }
} }
function RevisionContentText({ content }: { content: string | Buffer<ArrayBufferLike> | undefined }) {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current?.querySelector("span.math-tex")) {
renderMathInElement(contentRef.current, { trust: true });
}
}, [content]);
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
}
function RevisionFooter({ note }: { note?: FNote }) { function RevisionFooter({ note }: { note?: FNote }) {
if (!note) { if (!note) {
return <></>; return <></>;

View File

@@ -23,12 +23,12 @@ export default function UploadAttachmentsDialog() {
setShown(true); setShown(true);
}); });
if (parentNoteId) { useEffect(() => {
useEffect(() => { if (!parentNoteId) return;
tree.getNoteTitle(parentNoteId).then((noteTitle) =>
setDescription(t("upload_attachments.files_will_be_uploaded", { noteTitle }))); tree.getNoteTitle(parentNoteId).then((noteTitle) =>
}, [parentNoteId]); setDescription(t("upload_attachments.files_will_be_uploaded", { noteTitle })));
} }, [parentNoteId]);
return ( return (
<Modal <Modal

View File

@@ -71,29 +71,27 @@ export default function Modal({ children, className, size, title, header, footer
const parentWidget = useContext(ParentComponent); const parentWidget = useContext(ParentComponent);
const elementToFocus = useRef<Element | null>(); const elementToFocus = useRef<Element | null>();
if (onShown || onHidden) { useEffect(() => {
useEffect(() => { const modalElement = modalRef.current;
const modalElement = modalRef.current; if (!modalElement) {
if (!modalElement) { return;
return; }
if (onShown) {
modalElement.addEventListener("shown.bs.modal", onShown);
}
modalElement.addEventListener("hidden.bs.modal", () => {
onHidden();
if (elementToFocus.current && "focus" in elementToFocus.current) {
(elementToFocus.current as HTMLElement).focus();
} }
});
return () => {
if (onShown) { if (onShown) {
modalElement.addEventListener("shown.bs.modal", onShown); modalElement.removeEventListener("shown.bs.modal", onShown);
} }
modalElement.addEventListener("hidden.bs.modal", () => { modalElement.removeEventListener("hidden.bs.modal", onHidden);
onHidden(); };
if (elementToFocus.current && "focus" in elementToFocus.current) { }, [ onShown, onHidden ]);
(elementToFocus.current as HTMLElement).focus();
}
});
return () => {
if (onShown) {
modalElement.removeEventListener("shown.bs.modal", onShown);
}
modalElement.removeEventListener("hidden.bs.modal", onHidden);
};
}, [ ]);
}
useEffect(() => { useEffect(() => {
if (!parentWidget) { if (!parentWidget) {

View File

@@ -216,15 +216,13 @@ export function useNoteContext() {
setNote(noteContext?.note); setNote(noteContext?.note);
}); });
useLegacyImperativeHandlers({ const parentComponent = useContext(ParentComponent) as ReactWrappedWidget;
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) { (parentComponent as ReactWrappedWidget & { setNoteContextEvent: (data: EventData<"setNoteContext">) => void }).setNoteContextEvent = ({ noteContext }: EventData<"setNoteContext">) => {
setNoteContext(noteContext); setNoteContext(noteContext);
} }
}, true);
useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`); useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`);
const parentComponent = useContext(ParentComponent) as ReactWrappedWidget;
return { return {
note: note, note: note,
@@ -249,25 +247,21 @@ export function useNoteContext() {
* @returns the value of the requested property. * @returns the value of the requested property.
*/ */
export function useNoteProperty<T extends keyof FNote>(note: FNote | null | undefined, property: T, componentId?: string) { export function useNoteProperty<T extends keyof FNote>(note: FNote | null | undefined, property: T, componentId?: string) {
if (!note) { const [, setValue ] = useState<FNote[T] | undefined>(note?.[property]);
return null; const refreshValue = () => setValue(note?.[property]);
}
const [, setValue ] = useState<FNote[T]>(note[property]);
const refreshValue = () => setValue(note[property]);
// Watch for note changes. // Watch for note changes.
useEffect(() => refreshValue(), [ note, note[property] ]); useEffect(() => refreshValue(), [ note, note?.[property] ]);
// Watch for external changes. // Watch for external changes.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => { useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.isNoteReloaded(note.noteId, componentId)) { if (loadResults.isNoteReloaded(note?.noteId, componentId)) {
refreshValue(); refreshValue();
} }
}); });
useDebugValue(property); useDebugValue(property);
return note[property]; return note?.[property];
} }
export function useNoteRelation(note: FNote | undefined | null, relationName: string): [string | null | undefined, (newValue: string) => void] { export function useNoteRelation(note: FNote | undefined | null, relationName: string): [string | null | undefined, (newValue: string) => void] {
@@ -362,10 +356,6 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s
} }
export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] { export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] {
if (!note) {
return [ undefined ];
}
const [ blob, setBlob ] = useState<FBlob | null>(); const [ blob, setBlob ] = useState<FBlob | null>();
function refresh() { function refresh() {
@@ -379,7 +369,7 @@ export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | un
} }
}); });
useDebugValue(note.noteId); useDebugValue(note?.noteId);
return [ blob ] as const; return [ blob ] as const;
} }
@@ -514,13 +504,9 @@ export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Toolti
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function useLegacyImperativeHandlers(handlers: Record<string, Function>, force?: boolean) { export function useLegacyImperativeHandlers(handlers: Record<string, Function>) {
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
if (!force) { useEffect(() => {
useEffect(() => {
Object.assign(parentComponent as never, handlers);
}, [ handlers ])
} else {
Object.assign(parentComponent as never, handlers); Object.assign(parentComponent as never, handlers);
} }, [ handlers ]);
} }

View File

@@ -20,16 +20,16 @@ interface NoteActionsProps {
noteContext?: NoteContext; noteContext?: NoteContext;
} }
export default function NoteActions(props: NoteActionsProps) { export default function NoteActions({ note, noteContext }: NoteActionsProps) {
return ( return (
<> <>
<RevisionsButton {...props} /> {note && <RevisionsButton note={note} />}
<NoteContextMenu {...props} /> {note && note.type !== "launcher" && <NoteContextMenu note={note as FNote} noteContext={noteContext}/>}
</> </>
); );
} }
function RevisionsButton({ note }: NoteActionsProps) { function RevisionsButton({ note }: { note: FNote }) {
const isEnabled = !["launcher", "doc"].includes(note?.type ?? ""); const isEnabled = !["launcher", "doc"].includes(note?.type ?? "");
return (isEnabled && return (isEnabled &&
@@ -42,12 +42,7 @@ function RevisionsButton({ note }: NoteActionsProps) {
); );
} }
function NoteContextMenu(props: NoteActionsProps) { function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
const { note, noteContext } = props;
if (!note || note.type === "launcher") {
return <></>;
}
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment(); const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(note.type); const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(note.type);
@@ -65,7 +60,7 @@ function NoteContextMenu(props: NoteActionsProps) {
hideToggleArrow hideToggleArrow
noSelectButtonStyle noSelectButtonStyle
> >
{canBeConvertedToAttachment && <ConvertToAttachment {...props} /> } {canBeConvertedToAttachment && <ConvertToAttachment note={note} /> }
{note.type === "render" && <CommandItem command="renderActiveNote" icon="bx bx-extension" text={t("note_actions.re_render_note")} />} {note.type === "render" && <CommandItem command="renderActiveNote" icon="bx bx-extension" text={t("note_actions.re_render_note")} />}
<CommandItem command="findInText" icon="bx bx-search" disabled={!isSearchable} text={t("note_actions.search_in_note")} /> <CommandItem command="findInText" icon="bx bx-search" disabled={!isSearchable} text={t("note_actions.search_in_note")} />
<CommandItem command="printActiveNote" icon="bx bx-printer" disabled={!isPrintable} text={t("note_actions.print_note")} /> <CommandItem command="printActiveNote" icon="bx bx-printer" disabled={!isPrintable} text={t("note_actions.print_note")} />
@@ -110,7 +105,7 @@ function CommandItem({ icon, text, title, command, disabled }: { icon: string, t
>{text}</FormListItem> >{text}</FormListItem>
} }
function ConvertToAttachment({ note }: NoteActionsProps) { function ConvertToAttachment({ note }: { note: FNote }) {
return ( return (
<FormListItem <FormListItem
icon="bx bx-paperclip" icon="bx bx-paperclip"

View File

@@ -77,10 +77,6 @@ export default function EtapiSettings() {
} }
function TokenList({ tokens }: { tokens: EtapiToken[] }) { function TokenList({ tokens }: { tokens: EtapiToken[] }) {
if (!tokens.length) {
return <div>{t("etapi.no_tokens_yet")}</div>;
}
const renameCallback = useCallback<RenameTokenCallback>(async (tokenId: string, oldName: string) => { const renameCallback = useCallback<RenameTokenCallback>(async (tokenId: string, oldName: string) => {
const tokenName = await dialog.prompt({ const tokenName = await dialog.prompt({
title: t("etapi.rename_token_title"), title: t("etapi.rename_token_title"),
@@ -104,41 +100,45 @@ function TokenList({ tokens }: { tokens: EtapiToken[] }) {
}, []); }, []);
return ( return (
<div style={{ overflow: "auto", height: "500px"}}> tokens.length ? (
<table className="table table-stripped"> <div style={{ overflow: "auto", height: "500px"}}>
<thead> <table className="table table-stripped">
<tr> <thead>
<th>{t("etapi.token_name")}</th>
<th>{t("etapi.created")}</th>
<th>{t("etapi.actions")}</th>
</tr>
</thead>
<tbody>
{tokens.map(({ etapiTokenId, name, utcDateCreated}) => (
<tr> <tr>
<td>{name}</td> <th>{t("etapi.token_name")}</th>
<td>{formatDateTime(utcDateCreated)}</td> <th>{t("etapi.created")}</th>
<td> <th>{t("etapi.actions")}</th>
{etapiTokenId && (
<>
<ActionButton
icon="bx bx-edit-alt"
text={t("etapi.rename_token")}
onClick={() => renameCallback(etapiTokenId, name)}
/>
<ActionButton
icon="bx bx-trash"
text={t("etapi.delete_token")}
onClick={() => deleteCallback(etapiTokenId, name)}
/>
</>
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tokens.map(({ etapiTokenId, name, utcDateCreated}) => (
</div> <tr>
) <td>{name}</td>
<td>{formatDateTime(utcDateCreated)}</td>
<td>
{etapiTokenId && (
<>
<ActionButton
icon="bx bx-edit-alt"
text={t("etapi.rename_token")}
onClick={() => renameCallback(etapiTokenId, name)}
/>
<ActionButton
icon="bx bx-trash"
text={t("etapi.delete_token")}
onClick={() => deleteCallback(etapiTokenId, name)}
/>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div>{t("etapi.no_tokens_yet")}</div>
)
);
} }