diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task.ts index 22b5a1ddab..b953ed4dfd 100644 --- a/packages/core/src/cloud-task/cloud-task.ts +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -16,6 +16,10 @@ import { } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { inject, injectable, optional, preDestroy } from "inversify"; +import { + capStoredEntries, + capStoredEntryPayloads, +} from "../sessions/sessionEntryCaps"; import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; import { CLOUD_TASK_AUTH, @@ -1617,7 +1621,9 @@ export class CloudTaskService extends TypedEventEmitter { return null; } - watcher.pendingLogEntries.push(event.data as StoredLogEntry); + watcher.pendingLogEntries.push( + capStoredEntryPayloads(event.data as StoredLogEntry), + ); if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { this.flushLogBatch(key); return null; @@ -2095,7 +2101,7 @@ export class CloudTaskService extends TypedEventEmitter { const raw = await authedResponse.text(); return { - entries: JSON.parse(raw) as StoredLogEntry[], + entries: capStoredEntries(JSON.parse(raw) as StoredLogEntry[]), hasMore: authedResponse.headers.get("X-Has-More") === "true", }; } catch (error) { diff --git a/packages/core/src/sessions/sessionEntryCaps.test.ts b/packages/core/src/sessions/sessionEntryCaps.test.ts new file mode 100644 index 0000000000..fa4993a4e4 --- /dev/null +++ b/packages/core/src/sessions/sessionEntryCaps.test.ts @@ -0,0 +1,220 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + capStoredEntries, + capStoredEntryPayloads, + MAX_MEDIA_DATA_CHARS, + MAX_TEXT_CHARS, +} from "./sessionEntryCaps"; + +const LONG_TEXT = "x".repeat(MAX_TEXT_CHARS + 500); +const LONG_MEDIA = "y".repeat(MAX_MEDIA_DATA_CHARS + 1); + +function sessionUpdateEntry(update: Record): StoredLogEntry { + return { + type: "notification", + timestamp: "2026-07-23T11:00:00Z", + notification: { + method: "session/update", + params: { sessionId: "s1", update }, + }, + }; +} + +function updateOf(entry: StoredLogEntry): Record { + const params = entry.notification?.params as { update: unknown }; + return params.update as Record; +} + +describe("capStoredEntryPayloads", () => { + it.each([ + [ + "agent_message_chunk", + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: LONG_TEXT }, + }, + ], + [ + "user_message_chunk", + { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: LONG_TEXT }, + }, + ], + [ + "agent_thought_chunk", + { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: LONG_TEXT }, + }, + ], + ])("truncates long %s text", (_kind, update) => { + const capped = updateOf(capStoredEntryPayloads(sessionUpdateEntry(update))); + const content = capped.content as { text: string }; + expect(content.text.length).toBeLessThan(LONG_TEXT.length); + expect(content.text).toContain("[truncated 500 chars]"); + }); + + it.each([ + ["rawInput string field", { rawInput: { content: LONG_TEXT } }], + ["rawOutput string field", { rawOutput: { stdout: LONG_TEXT } }], + ["nested rawOutput array", { rawOutput: { items: [{ text: LONG_TEXT }] } }], + ["_meta payload", { _meta: { claudeCode: { toolResponse: LONG_TEXT } } }], + ])("truncates strings inside %s while keeping the shape", (_name, fields) => { + const entry = sessionUpdateEntry({ + sessionUpdate: "tool_call_update", + toolCallId: "t1", + ...fields, + }); + const capped = updateOf(capStoredEntryPayloads(entry)); + const json = JSON.stringify(capped); + expect(json.length).toBeLessThan(JSON.stringify(updateOf(entry)).length); + expect(json).toContain("[truncated 500 chars]"); + for (const key of Object.keys(fields)) { + expect(Object.keys(capped)).toContain(key); + } + }); + + it("truncates diff oldText and newText in tool content", () => { + const entry = sessionUpdateEntry({ + sessionUpdate: "tool_call", + toolCallId: "t1", + content: [ + { type: "diff", path: "a.ts", oldText: LONG_TEXT, newText: LONG_TEXT }, + ], + }); + const capped = updateOf(capStoredEntryPayloads(entry)); + const [diff] = capped.content as { oldText: string; newText: string }[]; + expect(diff.oldText).toContain("[truncated 500 chars]"); + expect(diff.newText).toContain("[truncated 500 chars]"); + }); + + it("truncates text blocks nested in tool content", () => { + const entry = sessionUpdateEntry({ + sessionUpdate: "tool_call_update", + toolCallId: "t1", + content: [ + { type: "content", content: { type: "text", text: LONG_TEXT } }, + ], + }); + const capped = updateOf(capStoredEntryPayloads(entry)); + const [item] = capped.content as { content: { text: string } }[]; + expect(item.content.text).toContain("[truncated 500 chars]"); + }); + + it.each([ + ["image", { type: "image", data: LONG_MEDIA, mimeType: "image/png" }], + ["audio", { type: "audio", data: LONG_MEDIA, mimeType: "audio/wav" }], + ])("replaces an oversized %s block with a placeholder", (_kind, block) => { + const entry = sessionUpdateEntry({ + sessionUpdate: "user_message_chunk", + content: block, + }); + const capped = updateOf(capStoredEntryPayloads(entry)); + const content = capped.content as { type: string; text: string }; + expect(content.type).toBe("text"); + expect(content.text).toContain("omitted"); + }); + + it("keeps an image block under the media cap untouched", () => { + const block = { + type: "image", + data: "z".repeat(1000), + mimeType: "image/png", + }; + const entry = sessionUpdateEntry({ + sessionUpdate: "user_message_chunk", + content: block, + }); + expect(capStoredEntryPayloads(entry)).toBe(entry); + }); + + it("caps prompt content blocks on session/prompt requests", () => { + const entry: StoredLogEntry = { + type: "notification", + notification: { + id: 1, + method: "session/prompt", + params: { + sessionId: "s1", + prompt: [{ type: "text", text: LONG_TEXT }], + }, + }, + }; + const capped = capStoredEntryPayloads(entry); + const params = capped.notification?.params as { + prompt: { text: string }[]; + }; + expect(params.prompt[0].text).toContain("[truncated 500 chars]"); + }); + + it("replaces an oversized resource blob block with a placeholder", () => { + const entry = sessionUpdateEntry({ + sessionUpdate: "user_message_chunk", + content: { + type: "resource", + resource: { uri: "file:///a", blob: LONG_MEDIA }, + }, + }); + const capped = updateOf(capStoredEntryPayloads(entry)); + const content = capped.content as { type: string; text: string }; + expect(content.type).toBe("text"); + expect(content.text).toContain("omitted"); + }); + + it.each([ + [ + "small tool_call_update", + sessionUpdateEntry({ + sessionUpdate: "tool_call_update", + toolCallId: "t1", + rawInput: { file_path: "a.ts" }, + content: [{ type: "content", content: { type: "text", text: "ok" } }], + }), + ], + [ + "non session/update notification", + { + type: "notification", + notification: { + method: "session/request_permission", + params: { big: LONG_TEXT }, + }, + } as StoredLogEntry, + ], + [ + "entry without notification", + { type: "marker", timestamp: "2026-07-23T11:00:00Z" } as StoredLogEntry, + ], + ])("returns %s by reference unchanged", (_name, entry) => { + expect(capStoredEntryPayloads(entry)).toBe(entry); + }); +}); + +describe("capStoredEntries", () => { + it("returns the same array when nothing needs capping", () => { + const entries = [ + sessionUpdateEntry({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "short" }, + }), + ]; + expect(capStoredEntries(entries)).toBe(entries); + }); + + it("returns a new array preserving uncapped entries by reference", () => { + const small = sessionUpdateEntry({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "short" }, + }); + const big = sessionUpdateEntry({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: LONG_TEXT }, + }); + const capped = capStoredEntries([small, big]); + expect(capped).not.toBe([small, big]); + expect(capped[0]).toBe(small); + expect(capped[1]).not.toBe(big); + }); +}); diff --git a/packages/core/src/sessions/sessionEntryCaps.ts b/packages/core/src/sessions/sessionEntryCaps.ts new file mode 100644 index 0000000000..a3b8f6d65c --- /dev/null +++ b/packages/core/src/sessions/sessionEntryCaps.ts @@ -0,0 +1,180 @@ +import type { StoredLogEntry } from "@posthog/shared"; + +export const MAX_TEXT_CHARS = 100_000; +export const MAX_MEDIA_DATA_CHARS = 10_000_000; +const MAX_RAW_PAYLOAD_DEPTH = 6; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncateText(text: string): string { + if (text.length <= MAX_TEXT_CHARS) return text; + return `${text.slice(0, MAX_TEXT_CHARS)}… [truncated ${text.length - MAX_TEXT_CHARS} chars]`; +} + +function mapShared(items: T[], mapFn: (item: T) => T): T[] { + let result: T[] | undefined; + for (let index = 0; index < items.length; index += 1) { + const mapped = mapFn(items[index]); + if (!result && mapped !== items[index]) { + result = items.slice(0, index); + } + result?.push(mapped); + } + return result ?? items; +} + +// Truncates long strings anywhere inside a raw tool payload while keeping the +// object shape intact, so tool views that read structured fields +// (rawInput.content, rawOutput.stdout, ...) still render a preview instead of +// losing the field to a wholesale replacement. +function capDeepStrings(value: unknown, depth = 0): unknown { + if (typeof value === "string") return truncateText(value); + if (depth >= MAX_RAW_PAYLOAD_DEPTH || value === null) return value; + if (Array.isArray(value)) { + return mapShared(value, (item) => capDeepStrings(item, depth + 1)); + } + if (!isRecord(value)) return value; + let next: Record | undefined; + for (const [key, entryValue] of Object.entries(value)) { + const capped = capDeepStrings(entryValue, depth + 1); + if (capped !== entryValue) { + next ??= { ...value }; + next[key] = capped; + } + } + return next ?? value; +} + +// A truncated base64 payload renders as a broken image, so oversized media +// blocks are replaced with a text placeholder instead of being sliced. +function capContentBlock(block: unknown): unknown { + if (!isRecord(block)) return block; + if (block.type === "text" && typeof block.text === "string") { + const text = truncateText(block.text); + return text === block.text ? block : { ...block, text }; + } + if ( + (block.type === "image" || block.type === "audio") && + typeof block.data === "string" && + block.data.length > MAX_MEDIA_DATA_CHARS + ) { + return { + type: "text", + text: `[${block.type} omitted: ${Math.round(block.data.length / 1_000_000)}M chars exceeds transcript limit]`, + }; + } + if (block.type === "resource" && isRecord(block.resource)) { + const resource = block.resource; + if ( + typeof resource.blob === "string" && + resource.blob.length > MAX_MEDIA_DATA_CHARS + ) { + return { + type: "text", + text: `[resource omitted: ${Math.round(resource.blob.length / 1_000_000)}M chars exceeds transcript limit]`, + }; + } + if (typeof resource.text === "string") { + const text = truncateText(resource.text); + if (text !== resource.text) { + return { ...block, resource: { ...resource, text } }; + } + } + return block; + } + return block; +} + +function capToolCallContent(item: unknown): unknown { + if (!isRecord(item)) return item; + if (item.type === "content") { + const content = capContentBlock(item.content); + return content === item.content ? item : { ...item, content }; + } + if (item.type === "diff") { + const oldText = + typeof item.oldText === "string" ? truncateText(item.oldText) : undefined; + const newText = + typeof item.newText === "string" ? truncateText(item.newText) : undefined; + const oldChanged = oldText !== undefined && oldText !== item.oldText; + const newChanged = newText !== undefined && newText !== item.newText; + if (!oldChanged && !newChanged) return item; + const next: Record = { ...item }; + if (oldChanged) next.oldText = oldText; + if (newChanged) next.newText = newText; + return next; + } + return item; +} + +function capSessionUpdate(update: unknown): unknown { + if (!isRecord(update)) return update; + const kind = update.sessionUpdate; + if (kind === "tool_call" || kind === "tool_call_update") { + const rawInput = capDeepStrings(update.rawInput); + const rawOutput = capDeepStrings(update.rawOutput); + const meta = capDeepStrings(update._meta); + const content = Array.isArray(update.content) + ? mapShared(update.content, capToolCallContent) + : update.content; + const changed = + rawInput !== update.rawInput || + rawOutput !== update.rawOutput || + meta !== update._meta || + content !== update.content; + if (!changed) return update; + const next: Record = { ...update }; + if (rawInput !== update.rawInput) next.rawInput = rawInput; + if (rawOutput !== update.rawOutput) next.rawOutput = rawOutput; + if (meta !== update._meta) next._meta = meta; + if (content !== update.content) next.content = content; + return next; + } + if ( + kind === "agent_message_chunk" || + kind === "user_message_chunk" || + kind === "agent_thought_chunk" + ) { + const content = capContentBlock(update.content); + return content === update.content ? update : { ...update, content }; + } + return update; +} + +/** + * Bound the memory a single stored transcript entry can pin in the renderer. + * Applied at every entry acquisition point (local/S3 log parse, cloud + * hydration, cloud watcher pages) so entry comparisons downstream (resume + * overlap detection, hydration hashes) always see identically capped shapes. + */ +export function capStoredEntryPayloads(entry: StoredLogEntry): StoredLogEntry { + const notification = entry.notification; + if (!notification) return entry; + if (notification.method === "session/update") { + const params = notification.params; + if (!isRecord(params)) return entry; + const update = capSessionUpdate(params.update); + if (update === params.update) return entry; + return { + ...entry, + notification: { ...notification, params: { ...params, update } }, + }; + } + if (notification.method === "session/prompt") { + const params = notification.params; + if (!isRecord(params) || !Array.isArray(params.prompt)) return entry; + const prompt = mapShared(params.prompt, capContentBlock); + if (prompt === params.prompt) return entry; + return { + ...entry, + notification: { ...notification, params: { ...params, prompt } }, + }; + } + return entry; +} + +export function capStoredEntries(entries: StoredLogEntry[]): StoredLogEntry[] { + return mapShared(entries, capStoredEntryPayloads); +} diff --git a/packages/core/src/sessions/sessionLogs.ts b/packages/core/src/sessions/sessionLogs.ts index 974cd7e2e0..b3d715b8ab 100644 --- a/packages/core/src/sessions/sessionLogs.ts +++ b/packages/core/src/sessions/sessionLogs.ts @@ -1,4 +1,5 @@ import type { Adapter, StoredLogEntry } from "@posthog/shared"; +import { capStoredEntryPayloads } from "./sessionEntryCaps"; export interface ParsedSessionLogs { rawEntries: StoredLogEntry[]; @@ -21,7 +22,7 @@ export function parseSessionLogContent( for (const line of lines) { try { const stored = JSON.parse(line) as StoredLogEntry; - rawEntries.push(stored); + rawEntries.push(capStoredEntryPayloads(stored)); if ( stored.type === "notification" && diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index d7bc02d754..b463ca9bc8 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -81,6 +81,7 @@ import { type PermissionSelectionPlan, planPermissionResponse, } from "./permissionResponse"; +import { capStoredEntries } from "./sessionEntryCaps"; import { convertStoredEntriesToEvents, createUserShellExecuteEvent, @@ -550,6 +551,10 @@ function entriesScopedToTaskRun( }); } +function sumChars(values: string[]): number { + return values.reduce((total, value) => total + value.length, 0); +} + function suffixPrefixOverlap(left: string[], right: string[]): number { if (left.length === 0 || right.length === 0) return 0; @@ -1938,6 +1943,12 @@ export class SessionService { const { rawEntries, sessionId, adapter } = prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); const events = convertStoredEntriesToEvents(rawEntries); + this.d.log.info("Rebuilt local session events from transcript", { + taskId, + taskRunId, + entryCount: rawEntries.length, + eventCount: events.length, + }); const storedAdapter = this.d.adapterStore.getAdapter(taskRunId); const resolvedAdapter = adapter ?? storedAdapter; @@ -6029,7 +6040,7 @@ export class SessionService { }); return; } - rawEntries = result.entries; + rawEntries = capStoredEntries(result.entries); const markedLeafStart = rawEntries.findIndex( (entry) => getEntryTaskRunMarker(entry) === taskRunId, ); @@ -6062,8 +6073,12 @@ export class SessionService { }); return; } - const ancestorEntries: StoredLogEntry[] = ancestorResult.entries; - const currentRunEntries: StoredLogEntry[] = currentRunResult.entries; + const ancestorEntries: StoredLogEntry[] = capStoredEntries( + ancestorResult.entries, + ); + const currentRunEntries: StoredLogEntry[] = capStoredEntries( + currentRunResult.entries, + ); const ancestorKeys = ancestorEntries.map((entry) => JSON.stringify(entry), ); @@ -6083,6 +6098,17 @@ export class SessionService { (entry) => !leafKeys.has(JSON.stringify(entry)), ), ]; + this.d.log.info("Merged resume transcripts", { + taskId, + taskRunId, + ancestorEntryCount: ancestorEntries.length, + currentRunEntryCount: currentRunEntries.length, + leafLogEntryCount: leafLogs.rawEntries.length, + overlap, + mergedEntryCount: rawEntries.length, + approxChars: + sumChars(ancestorKeys) + sumChars(currentKeys.slice(overlap)), + }); resumeLeafEntryStartIndex = ancestorEntries.length; liveStreamLineCount = Math.max( leafLogs.totalLineCount, @@ -6102,7 +6128,7 @@ export class SessionService { }); return; } - rawEntries = result.entries; + rawEntries = capStoredEntries(result.entries); liveStreamLineCount = rawEntries.length; // A terminal run whose persisted chain comes back empty can still // have a complete S3 session log (persistence raced teardown); fall @@ -6148,6 +6174,14 @@ export class SessionService { ); } } + this.d.log.info("Hydrated cloud session transcript", { + taskId, + taskRunId, + isResumeRun, + isTerminalRun, + entryCount: rawEntries.length, + eventCount: events.length, + }); const hasUserPrompt = events.some( (e: AcpMessage) => isJsonRpcRequest(e.message) && e.message.method === "session/prompt", @@ -7486,6 +7520,13 @@ export class SessionService { !options.minEntryCount || localResult.totalLineCount >= options.minEntryCount ) { + this.d.log.info("Loaded session transcript", { + taskRunId, + source: "local", + contentChars: content.length, + entryCount: localResult.rawEntries.length, + totalLineCount: localResult.totalLineCount, + }); return localResult; } } @@ -7503,6 +7544,13 @@ export class SessionService { if (!content?.trim()) return localResult ?? empty; const result = this.parseLogContent(content); + this.d.log.info("Loaded session transcript", { + taskRunId, + source: "s3", + contentChars: content.length, + entryCount: result.rawEntries.length, + totalLineCount: result.totalLineCount, + }); if (taskRunId && result.rawEntries.length > 0) { this.d.trpc.logs.writeLocalLogs diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index 4963ab3970..b88ca0e51a 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -26,6 +26,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useAppVisibilityWatchdog } from "@posthog/ui/shell/useAppVisibilityWatchdog"; +import { useRendererHeapWatchdog } from "@posthog/ui/shell/useRendererHeapWatchdog"; import { RouterProvider } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { type ReactNode, useEffect, useRef, useState } from "react"; @@ -117,6 +118,7 @@ function App({ devToolbar }: AppProps) { // Mirrors the "main" branch of renderContent() below; keep the two in sync. const showingMainApp = readyForMainApp && initialRouteLoaded; useAppVisibilityWatchdog(mainRef, showingMainApp); + useRendererHeapWatchdog(); // Single gate for every state where the whole app is still loading. if ( diff --git a/packages/ui/src/shell/useRendererHeapWatchdog.test.ts b/packages/ui/src/shell/useRendererHeapWatchdog.test.ts new file mode 100644 index 0000000000..01f88f63c2 --- /dev/null +++ b/packages/ui/src/shell/useRendererHeapWatchdog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + advanceHeapBoundary, + HEAP_BOUNDARY_BYTES, +} from "./useRendererHeapWatchdog"; + +describe("advanceHeapBoundary", () => { + it.each([ + ["below the first boundary", 0, HEAP_BOUNDARY_BYTES - 1, 0, false], + ["crossing the first boundary", 0, HEAP_BOUNDARY_BYTES, 1, true], + ["staying inside a boundary", 1, HEAP_BOUNDARY_BYTES + 1, 1, false], + ["crossing two boundaries at once", 0, HEAP_BOUNDARY_BYTES * 2, 2, true], + ["dipping after a GC", 2, HEAP_BOUNDARY_BYTES - 1, 0, false], + ["re-climbing after a dip", 0, HEAP_BOUNDARY_BYTES, 1, true], + ])("%s", (_name, lastBoundary, usedBytes, boundary, crossed) => { + expect(advanceHeapBoundary(lastBoundary, usedBytes)).toEqual({ + boundary, + crossed, + }); + }); +}); diff --git a/packages/ui/src/shell/useRendererHeapWatchdog.ts b/packages/ui/src/shell/useRendererHeapWatchdog.ts new file mode 100644 index 0000000000..3e17b6f55a --- /dev/null +++ b/packages/ui/src/shell/useRendererHeapWatchdog.ts @@ -0,0 +1,53 @@ +import { logger } from "@posthog/ui/shell/logger"; +import { useEffect } from "react"; + +const log = logger.scope("heap-watchdog"); +const SAMPLE_INTERVAL_MS = 10_000; +export const HEAP_BOUNDARY_BYTES = 512 * 1024 * 1024; + +interface ChromiumHeapInfo { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; +} + +export function advanceHeapBoundary( + lastBoundary: number, + usedBytes: number, +): { boundary: number; crossed: boolean } { + const boundary = Math.floor(usedBytes / HEAP_BOUNDARY_BYTES); + return { boundary, crossed: boundary > lastBoundary }; +} + +function readHeap(): ChromiumHeapInfo | undefined { + return (performance as Performance & { memory?: ChromiumHeapInfo }).memory; +} + +const toMb = (bytes: number) => Math.round(bytes / (1024 * 1024)); + +// Logs each upward 512MB crossing of the JS heap with the active route, so a +// later renderer OOM's chromium log tail shows what was loaded and how fast +// the heap grew. Downward moves rearm the boundary so a GC dip followed by a +// re-climb logs again. +export function useRendererHeapWatchdog(): void { + useEffect(() => { + let lastBoundary = 0; + const timer = setInterval(() => { + const heap = readHeap(); + if (!heap) return; + const { boundary, crossed } = advanceHeapBoundary( + lastBoundary, + heap.usedJSHeapSize, + ); + lastBoundary = boundary; + if (!crossed) return; + log.warn("Renderer JS heap grew past boundary", { + usedMb: toMb(heap.usedJSHeapSize), + totalMb: toMb(heap.totalJSHeapSize), + limitMb: toMb(heap.jsHeapSizeLimit), + route: window.location.hash, + }); + }, SAMPLE_INTERVAL_MS); + return () => clearInterval(timer); + }, []); +}