From 81767187837a9557b4441c89611eb1084007e2c1 Mon Sep 17 00:00:00 2001 From: Dexploarer <211557447+Dexploarer@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:07:53 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20optimize=20LogsView=20performance?= =?UTF-8?q?=20with=20React.memo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts `LogEntryItem` from `LogsView` and wraps it in `React.memo` to prevent unnecessary re-renders of log entries when filtering or when parent state changes. This is a standard optimization for list views. - Extracted `LogEntryItem` component. - Memoized `LogEntryItem` using `React.memo`. - Added regression test `apps/app/test/components/LogsView.test.tsx` to ensure functionality is preserved. --- apps/app/src/components/LogsView.tsx | 112 +++++++++++---------- apps/app/test/components/LogsView.test.tsx | 85 ++++++++++++++++ 2 files changed, 146 insertions(+), 51 deletions(-) create mode 100644 apps/app/test/components/LogsView.test.tsx diff --git a/apps/app/src/components/LogsView.tsx b/apps/app/src/components/LogsView.tsx index dcc3899fc..80c3dd047 100644 --- a/apps/app/src/components/LogsView.tsx +++ b/apps/app/src/components/LogsView.tsx @@ -2,7 +2,7 @@ * Logs view component — logs viewer with filtering. */ -import { useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { useApp } from "../AppContext"; import type { LogEntry } from "../api-client"; import { formatTime } from "./shared/format"; @@ -18,6 +18,63 @@ const TAG_COLORS: Record = { websocket: { bg: "rgba(20, 184, 166, 0.15)", fg: "rgb(20, 184, 166)" }, }; +const LogEntryItem = memo(({ entry }: { entry: LogEntry }) => { + return ( +
+ {/* Timestamp */} + + {formatTime(entry.timestamp, { fallback: "—" })} + + + {/* Level */} + + {entry.level} + + + {/* Source */} + + [{entry.source}] + + + {/* Tag badges */} + + {(entry.tags ?? []).map((t: string) => { + const c = TAG_COLORS[t]; + return ( + + {t} + + ); + })} + + + {/* Message */} + {entry.message} +
+ ); +}); + +LogEntryItem.displayName = "LogEntryItem"; + export function LogsView() { const [searchQuery, setSearchQuery] = useState(""); @@ -163,57 +220,10 @@ export function LogsView() { ) : ( filteredLogs.map((entry: LogEntry) => ( -
- {/* Timestamp */} - - {formatTime(entry.timestamp, { fallback: "—" })} - - - {/* Level */} - - {entry.level} - - - {/* Source */} - - [{entry.source}] - - - {/* Tag badges */} - - {(entry.tags ?? []).map((t: string) => { - const c = TAG_COLORS[t]; - return ( - - {t} - - ); - })} - - - {/* Message */} - {entry.message} -
+ entry={entry} + /> )) )} diff --git a/apps/app/test/components/LogsView.test.tsx b/apps/app/test/components/LogsView.test.tsx new file mode 100644 index 000000000..ad672b415 --- /dev/null +++ b/apps/app/test/components/LogsView.test.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import * as AppContext from "../../src/AppContext"; +import { LogsView } from "../../src/components/LogsView"; + +// Mock the AppContext +vi.mock("../../src/AppContext", () => ({ + useApp: vi.fn(), +})); + +describe("LogsView", () => { + it("renders logs correctly", async () => { + const mockUseApp = { + logs: [ + { + timestamp: 1234567890000, + source: "test-source", + level: "info", + message: "Test log message", + tags: ["test-tag"], + }, + ], + logSources: ["test-source"], + logTags: ["test-tag"], + logTagFilter: "", + logLevelFilter: "", + logSourceFilter: "", + loadLogs: vi.fn(), + setState: vi.fn(), + }; + + // @ts-expect-error - partial mock + vi.spyOn(AppContext, "useApp").mockReturnValue(mockUseApp); + + let testRenderer: ReactTestRenderer | undefined; + await act(async () => { + testRenderer = create(); + }); + + if (!testRenderer) throw new Error("Renderer not initialized"); + + const root = testRenderer.root; + // Check if log entry exists + const logEntries = root.findAllByProps({ "data-testid": "log-entry" }); + expect(logEntries.length).toBe(1); + + // Check content + // Check for text content in the rendered tree + const treeJson = JSON.stringify(testRenderer.toJSON()); + expect(treeJson).toContain("Test log message"); + expect(treeJson).toContain("info"); + expect(treeJson).toContain("test-source"); + }); + + it("renders 'No log entries' when empty", async () => { + const mockUseApp = { + logs: [], + logSources: [], + logTags: [], + logTagFilter: "", + logLevelFilter: "", + logSourceFilter: "", + loadLogs: vi.fn(), + setState: vi.fn(), + }; + + // @ts-expect-error - partial mock + vi.spyOn(AppContext, "useApp").mockReturnValue(mockUseApp); + + let testRenderer: ReactTestRenderer | undefined; + await act(async () => { + testRenderer = create(); + }); + + if (!testRenderer) throw new Error("Renderer not initialized"); + + const root = testRenderer.root; + const logEntries = root.findAllByProps({ "data-testid": "log-entry" }); + expect(logEntries.length).toBe(0); + + const treeJson = JSON.stringify(testRenderer.toJSON()); + expect(treeJson).toContain("No log entries"); + }); +});