fix: 修复 ChatPanel 无限重渲染 — useLogger 新增 bindings 参数保证引用稳定
This commit is contained in:
@@ -73,12 +73,12 @@ export interface Logger {
|
|||||||
|
|
||||||
### 实现
|
### 实现
|
||||||
|
|
||||||
| 实现 | 工厂函数 | 用途 |
|
| 实现 | 工厂函数 | 用途 |
|
||||||
| ----------------------- | --------------------------------------- | ------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| `DefaultLogger` + Sinks | `useLogger()` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流 |
|
| `DefaultLogger` + Sinks | `useLogger(bindings?)` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流;传入 bindings 自动创建带作用域的子 Logger |
|
||||||
| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink |
|
| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink |
|
||||||
| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 |
|
| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 |
|
||||||
| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 |
|
| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 |
|
||||||
|
|
||||||
### 使用方式
|
### 使用方式
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ export interface Logger {
|
|||||||
import { useLogger } from "../hooks/use-logger";
|
import { useLogger } from "../hooks/use-logger";
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const logger = useLogger();
|
const logger = useLogger({ component: "MyComponent" });
|
||||||
logger.info("数据加载完成", { count: 42 });
|
logger.info("数据加载完成", { count: 42 });
|
||||||
logger.warn("即将超时");
|
logger.warn("即将超时");
|
||||||
logger.error("操作失败", { error: new Error("...") });
|
logger.error("操作失败", { error: new Error("...") });
|
||||||
@@ -106,6 +106,15 @@ logger.debug("调试信息");
|
|||||||
|
|
||||||
**作用域绑定:**
|
**作用域绑定:**
|
||||||
|
|
||||||
|
组件内直接通过 `useLogger` 的 `bindings` 参数传入,hook 内部保证引用稳定(值不变时多次渲染返回同一 Logger):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
|
||||||
|
logger.info("页面加载"); // [Alfred:INFO] 页面加载 [component=ChatPanel][page=workbench]
|
||||||
|
```
|
||||||
|
|
||||||
|
非组件场景仍可使用 `logger.child()`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const pageLogger = logger.child({ page: "projects" });
|
const pageLogger = logger.child({ page: "projects" });
|
||||||
pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects]
|
pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface ChatPanelProps {
|
|||||||
|
|
||||||
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
|
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const logger = useLogger().child({ component: "ChatPanel", page: "workbench" });
|
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
|
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { App } from "antd";
|
import { App } from "antd";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Logger } from "../utils/logger";
|
import type { Logger } from "../utils/logger";
|
||||||
|
|
||||||
import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger";
|
import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger";
|
||||||
|
|
||||||
export function useLogger(): Logger {
|
export function useLogger(bindings?: Record<string, unknown>): Logger {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const [stableJson, setStableJson] = useState(() => JSON.stringify(bindings ?? {}));
|
||||||
|
const [stableBindings, setStableBindings] = useState(() => bindings);
|
||||||
|
const currentJson = JSON.stringify(bindings ?? {});
|
||||||
|
|
||||||
|
if (currentJson !== stableJson) {
|
||||||
|
setStableJson(currentJson);
|
||||||
|
setStableBindings(bindings);
|
||||||
|
}
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const isProduction = !!import.meta.env["PROD"];
|
const isProduction = !!import.meta.env["PROD"];
|
||||||
return createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
|
const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
|
||||||
}, [message]);
|
if (!stableBindings || Object.keys(stableBindings).length === 0) return base;
|
||||||
|
return base.child(stableBindings);
|
||||||
|
}, [message, stableBindings]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
import { createElement } from "react";
|
import { act, createElement, useState } from "react";
|
||||||
|
|
||||||
import type { Logger } from "../../../src/web/utils/logger";
|
import type { Logger } from "../../../src/web/utils/logger";
|
||||||
|
|
||||||
import { useLogger } from "../../../src/web/hooks/use-logger";
|
import { useLogger } from "../../../src/web/hooks/use-logger";
|
||||||
import { renderWithProviders } from "../test-utils";
|
import { renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
|
function BindingsHookTester({
|
||||||
|
bindings,
|
||||||
|
onMount,
|
||||||
|
}: {
|
||||||
|
bindings?: Record<string, unknown>;
|
||||||
|
onMount: (logger: Logger) => void;
|
||||||
|
}) {
|
||||||
|
const logger = useLogger(bindings);
|
||||||
|
onMount(logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function HookTester({ onMount }: { onMount: (logger: Logger) => void }) {
|
function HookTester({ onMount }: { onMount: (logger: Logger) => void }) {
|
||||||
const logger = useLogger();
|
const logger = useLogger();
|
||||||
onMount(logger);
|
onMount(logger);
|
||||||
@@ -70,3 +82,109 @@ describe("useLogger", () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useLogger bindings 参数", () => {
|
||||||
|
test("传入 bindings 返回带上下文前缀的 Logger", () => {
|
||||||
|
const spy = mock((..._args: unknown[]) => {});
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = spy;
|
||||||
|
|
||||||
|
let logger: Logger | undefined;
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(BindingsHookTester, {
|
||||||
|
bindings: { component: "TestComp" },
|
||||||
|
onMount: (l: Logger) => {
|
||||||
|
logger = l;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger!.warn("绑定测试");
|
||||||
|
|
||||||
|
console.warn = origWarn;
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy.mock.calls[0]![0]).toMatch(/\[component=TestComp\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无参调用与空 bindings 行为一致", () => {
|
||||||
|
let noBindingsLogger: Logger | undefined;
|
||||||
|
let emptyBindingsLogger: Logger | undefined;
|
||||||
|
|
||||||
|
const spy = mock((..._args: unknown[]) => {});
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = spy;
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(HookTester, {
|
||||||
|
onMount: (l: Logger) => {
|
||||||
|
noBindingsLogger = l;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
createElement(BindingsHookTester, {
|
||||||
|
bindings: {},
|
||||||
|
onMount: (l: Logger) => {
|
||||||
|
emptyBindingsLogger = l;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
noBindingsLogger!.warn("a");
|
||||||
|
emptyBindingsLogger!.warn("b");
|
||||||
|
|
||||||
|
console.warn = origWarn;
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(spy.mock.calls[0]![0]).not.toMatch(/\[component=/);
|
||||||
|
expect(spy.mock.calls[1]![0]).not.toMatch(/\[component=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("相同 bindings 值多次渲染返回同一 Logger 引用", () => {
|
||||||
|
const refs: Logger[] = [];
|
||||||
|
|
||||||
|
function StableTester() {
|
||||||
|
const logger = useLogger({ component: "Stable" });
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
refs.push(logger);
|
||||||
|
return createElement("button", {
|
||||||
|
onClick: () => setTick((t) => t + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = renderWithProviders(createElement(StableTester));
|
||||||
|
const initialCount = refs.length;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.getByRole("button").click();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.getByRole("button").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refs.length).toBeGreaterThan(initialCount);
|
||||||
|
for (let i = 1; i < refs.length; i++) {
|
||||||
|
expect(refs[i]).toBe(refs[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bindings 值变化时返回新 Logger 引用", () => {
|
||||||
|
const refs: Logger[] = [];
|
||||||
|
let currentBindings: Record<string, unknown> = { component: "A" };
|
||||||
|
|
||||||
|
function DynamicTester() {
|
||||||
|
const logger = useLogger(currentBindings);
|
||||||
|
refs.push(logger);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = renderWithProviders(createElement(DynamicTester));
|
||||||
|
const countBefore = refs.length;
|
||||||
|
|
||||||
|
currentBindings = { component: "B" };
|
||||||
|
rerender(createElement(DynamicTester));
|
||||||
|
|
||||||
|
expect(refs.length).toBe(countBefore + 1);
|
||||||
|
expect(refs[refs.length - 1]).not.toBe(refs[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user