From 70014532a792c8819e286c5d602ab8c9b6143aef Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 7 Oct 2026 16:10:32 +0800 Subject: [PATCH 1/2] fix(desktop): stop per-event nested commits that crashed the Live Session View Sending a prompt crashed the window with React error #185 (maximum update depth). React 19 counts a commit as nested when updates are still pending right after it, and every projection-store event is a sync commit. Three things left work pending after each event, so dense Pi 1.0 event bursts pushed the counter past 50: - LiveSessionColumn marked the on-screen Session read from a passive effect, adding a second store commit per assistant event. The store now tracks viewed Sessions (store.view / useViewedSession) and folds the read marker into the event's own write. - ChatInlinePager set state on every parent render for same-page content; it now renders that content straight from children. - @astryxdesign/core 0.3.0 Text/Heading rebuilt their merged ref every render, so useTruncation set state during each commit. Patched to a memoized ref (upstream fixed in 0.6.x). AppFrame's expand effect is now keyed on the selected project instead of the whole projections array. The renderer's projection writes now cost one commit per event; nested counts in the repro stay at 5 or below (was up to 51). --- .../session-projections-store.test.tsx | 120 +++++++++++++++++- .../session/session-projections-store.ts | 47 ++++++- .../session/use-session-projections.tsx | 15 +++ .../src/shared/ui/astryx-text-ref.test.tsx | 54 ++++++++ .../shared/ui/chat/chat-inline-pager.test.tsx | 25 ++++ .../src/shared/ui/chat/chat-inline-pager.tsx | 6 +- .../src/widgets/app-frame/app-frame.tsx | 25 ++-- .../widgets/live-chat/live-session-column.tsx | 14 +- bun.lock | 3 + package.json | 3 + patches/@astryxdesign%2Fcore@0.3.0.patch | 66 ++++++++++ 11 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/shared/ui/astryx-text-ref.test.tsx create mode 100644 patches/@astryxdesign%2Fcore@0.3.0.patch diff --git a/apps/desktop/src/entities/session/session-projections-store.test.tsx b/apps/desktop/src/entities/session/session-projections-store.test.tsx index 1b672c3b..10d7e75c 100644 --- a/apps/desktop/src/entities/session/session-projections-store.test.tsx +++ b/apps/desktop/src/entities/session/session-projections-store.test.tsx @@ -1,4 +1,5 @@ import { act, render } from "@testing-library/react"; +import { Profiler } from "react"; import { describe, expect, it } from "vitest"; import type { AgentRuntimeEventEntry, @@ -13,11 +14,15 @@ import { createSessionProjection, type SessionProjection, } from "@/entities/session/session-projection"; -import { createSessionProjectionsStore } from "@/entities/session/session-projections-store"; +import { + createSessionProjectionsStore, + type SessionProjectionsStore, +} from "@/entities/session/session-projections-store"; import { SessionProjectionsProvider, useLiveSession, useSessionProjections, + useViewedSession, } from "@/entities/session/use-session-projections"; type Listener = (...args: T) => void; @@ -553,3 +558,116 @@ describe("Session Projections store", () => { }); }); }); + +function assistantMessage(id: string, piSessionId: string, seq: number): PiRuntimeEvent { + return { + id, + piSessionId, + kind: "message", + role: "assistant", + body: `Reply ${seq}`, + timestamp: `2026-09-24T10:00:0${seq}.000Z`, + }; +} + +describe("viewed Sessions", () => { + it("reads results as they land on a viewed Session and keeps them unread once it is released", () => { + const fake = createFakeBridge(); + const store = createStore(fake); + store.insert(createSessionProjection({ + id: "viewed", + projectId: "pig", + initialPrompt: "Prompt", + createdAt: "2026-09-24T09:00:00.000Z", + })); + store.apply("viewed", { + type: "runtime-bound", + stage: "starting runtime", + runtimeId: "runtime:viewed", + piSessionId: "pi:viewed", + occurredAt: "2026-09-24T09:00:01.000Z", + }); + + const notifies: string[] = []; + store.subscribe(() => notifies.push("notify")); + const release = store.view("viewed"); + + fake.emitLegacy(assistantMessage("m1", "pi:viewed", 1)); + + // The unread write lands inside the event's own commit, not after it. + expect(store.get("viewed")?.unreadResult).toBe(false); + expect(notifies).toHaveLength(1); + + release(); + fake.emitLegacy(assistantMessage("m2", "pi:viewed", 2)); + + expect(store.get("viewed")?.unreadResult).toBe(true); + expect(notifies).toHaveLength(2); + }); + + it("viewing an unread Session reads it once", () => { + const fake = createFakeBridge(); + const store = createStore(fake); + store.insert({ ...boundSession("unread"), unreadResult: true }); + + const notifies: string[] = []; + store.subscribe(() => notifies.push("notify")); + store.view("unread"); + + expect(store.get("unread")?.unreadResult).toBe(false); + expect(notifies).toHaveLength(1); + }); + + it("commits once per assistant event while the Session is on screen", () => { + const fake = createFakeBridge(); + let store!: SessionProjectionsStore; + let commits = 0; + + function LiveView() { + const { projection } = useLiveSession("onscreen"); + useViewedSession("onscreen"); + return {String(projection?.unreadResult)}; + } + function StoreProbe() { + store = useSessionProjections().store; + return null; + } + + render( + + + { commits += 1; }}> + + + , + ); + + act(() => { + store.insert(createSessionProjection({ + id: "onscreen", + projectId: "pig", + initialPrompt: "Prompt", + createdAt: "2026-09-24T09:00:00.000Z", + })); + store.apply("onscreen", { + type: "runtime-bound", + stage: "starting runtime", + runtimeId: "runtime:onscreen", + piSessionId: "pi:onscreen", + occurredAt: "2026-09-24T09:00:01.000Z", + }); + }); + commits = 0; + + for (let seq = 1; seq <= 5; seq += 1) { + act(() => { + fake.emitLegacy(assistantMessage(`m${seq}`, "pi:onscreen", seq)); + }); + } + + // One commit per event: the old pattern added a second commit per event + // from a passive effect writing "read" back after each render. + expect(commits).toBe(5); + expect(store.get("onscreen")?.unreadResult).toBe(false); + }); +}); diff --git a/apps/desktop/src/entities/session/session-projections-store.ts b/apps/desktop/src/entities/session/session-projections-store.ts index 8335d6c7..75a40534 100644 --- a/apps/desktop/src/entities/session/session-projections-store.ts +++ b/apps/desktop/src/entities/session/session-projections-store.ts @@ -34,6 +34,12 @@ export type SessionProjectionsStore = { remove(sessionId: string): void; rehydrate(): Promise; subscribe(listener: () => void): () => void; + /** + * Marks a Session as on screen until the returned release runs. Its results + * are read as they land, in the same store commit, so a view never writes + * "read" back after rendering (which cost one extra commit per event). + */ + view(sessionId: string): () => void; }; // A projection this process has advanced past its persisted record. Replacing @@ -63,11 +69,23 @@ export function createSessionProjectionsStore(options: { let projections: SessionProjection[] = []; const listeners = new Set<() => void>(); const subscriptions = new Map void }>(); + // Sessions currently on screen, ref-counted per `view()` caller. Their + // results are already consumed by definition, so `seen` folds the read + // marker into the same projection write instead of a second apply later. + const viewed = new Map(); const history = new Map< string, { key: string; piSessionId: string; state: Exclude } >(); + const seen = (projection: SessionProjection) => + viewed.has(projection.id) && projection.unreadResult + ? applySessionProjectionEvent(projection, { + type: "latest-message-rendered", + occurredAt: new Date().toISOString(), + }) + : projection; + const notify = () => { for (const listener of listeners) listener(); }; @@ -119,7 +137,7 @@ export function createSessionProjectionsStore(options: { // The reducer throws on events it cannot place (an unknown queued id); // that propagates before anything is stored. - const next = applySessionProjectionEvent(current, event); + const next = seen(applySessionProjectionEvent(current, event)); replace(next); if (event.type === "runtime-bound") { @@ -162,7 +180,7 @@ export function createSessionProjectionsStore(options: { } } history.set(sessionId, { key, piSessionId, state: "loaded" }); - replace(next); + replace(seen(next)); }) .catch((error: unknown) => { if (!current()) return; @@ -247,5 +265,30 @@ export function createSessionProjectionsStore(options: { listeners.delete(listener); }; }, + view(sessionId) { + viewed.set(sessionId, (viewed.get(sessionId) ?? 0) + 1); + // An already-unread Session is caught up now: one notify, only on view + // start — nothing writes "read" back while events land afterwards. + const current = get(sessionId); + if (current) { + const next = seen(current); + if (next !== current) { + replace(next); + } + } + let released = false; + return () => { + if (released) { + return; + } + released = true; + const count = (viewed.get(sessionId) ?? 0) - 1; + if (count <= 0) { + viewed.delete(sessionId); + } else { + viewed.set(sessionId, count); + } + }; + }, }; } diff --git a/apps/desktop/src/entities/session/use-session-projections.tsx b/apps/desktop/src/entities/session/use-session-projections.tsx index d8e53918..f2a4b711 100644 --- a/apps/desktop/src/entities/session/use-session-projections.tsx +++ b/apps/desktop/src/entities/session/use-session-projections.tsx @@ -304,3 +304,18 @@ export function useLiveSession(sessionId: string | null) { return { projection, history, apply, retryHistory }; } + +/** Keeps a Session read while it is on screen (SessionProjectionsStore.view). */ +export function useViewedSession(sessionId: string | null) { + const context = useContext(SessionProjectionsStoreContext); + + if (!context) { + throw new Error("useViewedSession requires SessionProjectionsProvider"); + } + + const { store } = context; + useEffect( + () => (sessionId ? store.view(sessionId) : undefined), + [sessionId, store], + ); +} diff --git a/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx b/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx new file mode 100644 index 00000000..6bd59494 --- /dev/null +++ b/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx @@ -0,0 +1,54 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Heading, Text } from "@astryxdesign/core/Text"; + +/** + * Guard for the @astryxdesign/core patch: Text/Heading merge their forwarded + * ref, useTruncation's callback ref, and an internal object ref into one + * callback built fresh on every render. A new callback identity makes React + * detach+reattach the ref on every commit — and useTruncation's attach calls + * setState inside the commit, which is what let nested updates accumulate + * into React #185 under dense Session events. + */ +describe("Astryx ref stability", () => { + it("Text keeps the forwarded callback ref attached across re-renders", () => { + const spy = vi.fn(); + function Parent({ label }: { label: string }) { + return {label}; + } + + const { rerender } = render(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith(expect.any(HTMLElement)); + + rerender(); + rerender(); + rerender(); + + // No detach (spy(null)) and no re-attach: identity must be stable. + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalledWith(null); + }); + + it("Heading keeps the forwarded callback ref attached across re-renders", () => { + const spy = vi.fn(); + function Parent({ label }: { label: string }) { + return ( + + {label} + + ); + } + + const { rerender } = render(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith(expect.any(HTMLElement)); + + rerender(); + rerender(); + rerender(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx b/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx index c9a2ea82..61415696 100644 --- a/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx @@ -1,3 +1,4 @@ +import { Profiler } from "react"; import { act, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatInlinePager } from "@/shared/ui/chat/chat-inline-pager"; @@ -104,6 +105,30 @@ describe("ChatInlinePager", () => { expect(screen.getByText("Running bash script…")).toBeInTheDocument(); }); + // A parent re-render with the same page key used to cost a second commit + // from the pager's passive effect — under dense Session events that extra + // commit per event is what let React's nested-update counter overflow (React error #185). + it("lands same-page content without an extra commit", () => { + let commits = 0; + const { rerender } = render( + { commits += 1; }}> + Running bash… + , + ); + + commits = 0; + for (const label of ["Running bash script…", "Running bash -lc…", "Running test…"]) { + rerender( + { commits += 1; }}> + {label} + , + ); + } + + expect(commits).toBe(3); + expect(screen.getByText("Running test…")).toBeInTheDocument(); + }); + it("swaps pages without motion when reduced motion is preferred", () => { vi.useFakeTimers(); useReducedMotion(); diff --git a/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx b/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx index 9147377b..4f5a28f1 100644 --- a/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx @@ -53,10 +53,10 @@ export function ChatInlinePager({ latestRef.current = { key: pageKey, node: children }; // Same page, new content (streaming args, a ticking duration): land it in - // place rather than turning the page on itself. + // place rather than turning the page on itself. Rendered straight from + // children below, so a parent re-render costs no extra commit. if (pageKey === shownRef.current.key) { shownRef.current = latestRef.current; - setShown(latestRef.current); return; } @@ -122,7 +122,7 @@ export function ChatInlinePager({ } }} > - {shown.node} + {pageKey === shown.key ? children : shown.node} diff --git a/apps/desktop/src/widgets/app-frame/app-frame.tsx b/apps/desktop/src/widgets/app-frame/app-frame.tsx index 2d79a2b4..4c00fa20 100644 --- a/apps/desktop/src/widgets/app-frame/app-frame.tsx +++ b/apps/desktop/src/widgets/app-frame/app-frame.tsx @@ -1245,30 +1245,31 @@ export function AppFrame({ }); }; - useEffect(() => { - if (!effectiveSelectedSessionId) { - return; - } - - const selectedProjection = effectiveSessionProjections.find( - (projection) => projection.id === effectiveSelectedSessionId, - ); + // The whole projections array gets a new identity on every store commit — + // keying the effect on it cost one pending update per Session event (React error #185). + // Only the selected Session's project can change what this effect does. + const selectedProjectId = effectiveSelectedSessionId + ? effectiveSessionProjections.find( + (projection) => projection.id === effectiveSelectedSessionId, + )?.projectId + : undefined; - if (!selectedProjection) { + useEffect(() => { + if (!selectedProjectId) { return; } updateExpandedProjects((currentExpandedProjects) => { - if (currentExpandedProjects[selectedProjection.projectId] === true) { + if (currentExpandedProjects[selectedProjectId] === true) { return currentExpandedProjects; } return { ...currentExpandedProjects, - [selectedProjection.projectId]: true, + [selectedProjectId]: true, }; }); - }, [effectiveSelectedSessionId, effectiveSessionProjections]); + }, [selectedProjectId]); useLayoutEffect(() => { const root = layoutRef.current; diff --git a/apps/desktop/src/widgets/live-chat/live-session-column.tsx b/apps/desktop/src/widgets/live-chat/live-session-column.tsx index 00e2257a..8f8b0f75 100644 --- a/apps/desktop/src/widgets/live-chat/live-session-column.tsx +++ b/apps/desktop/src/widgets/live-chat/live-session-column.tsx @@ -51,7 +51,10 @@ import { type SessionChangesView } from "@/entities/session/use-session-changes" import { saveLastModelSelection } from "@/entities/session/last-model-preference"; import { useVisibleModels } from "@/entities/model/visible-models"; import { usePromptCommands } from "@/entities/prompt-command"; -import { useLiveSession } from "@/entities/session/use-session-projections"; +import { + useLiveSession, + useViewedSession, +} from "@/entities/session/use-session-projections"; import { type SessionProjectionsStore } from "@/entities/session/session-projections-store"; import { isAssistantAnswerMessage, @@ -189,6 +192,9 @@ export function LiveSessionColumn({ apply, retryHistory, } = useLiveSession(showDraft ? creatingSessionId : sessionId); + // The store reads a viewed Session's results as they land (same commit), + // where a passive-effect write-back cost one extra commit per event (React error #185). + useViewedSession(showDraft ? null : sessionId); const promptCommands = usePromptCommands( liveProjection ? { sessionId: liveProjection.id } : null, ); @@ -284,12 +290,6 @@ export function LiveSessionColumn({ return () => clearTimeout(timer); }, [draftHandoff]); - useEffect(() => { - if (!showDraft && liveProjection?.unreadResult) { - apply({ type: "latest-message-rendered", occurredAt: new Date().toISOString() }); - } - }, [apply, liveProjection?.unreadResult, showDraft]); - const handleDraftChange = (prompt: string) => { setSessionDraft(saveSessionDraft(sessionDraft?.projectId ?? null, prompt)); }; diff --git a/bun.lock b/bun.lock index ef84d7c5..875433ad 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,9 @@ }, }, }, + "patchedDependencies": { + "@astryxdesign/core@0.3.0": "patches/@astryxdesign%2Fcore@0.3.0.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], diff --git a/package.json b/package.json index f9acf3be..3c9e843f 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,8 @@ "typescript-eslint": "^8.70.1", "vite": "^5.4.2", "vitest": "^4.1.9" + }, + "patchedDependencies": { + "@astryxdesign/core@0.3.0": "patches/@astryxdesign%2Fcore@0.3.0.patch" } } diff --git a/patches/@astryxdesign%2Fcore@0.3.0.patch b/patches/@astryxdesign%2Fcore@0.3.0.patch new file mode 100644 index 00000000..0b0b5fa9 --- /dev/null +++ b/patches/@astryxdesign%2Fcore@0.3.0.patch @@ -0,0 +1,66 @@ +diff --git a/dist/Heading/Heading.js b/dist/Heading/Heading.js +index b10c5c13134234791b3653078c1bd943675d45c2..832bb50cf5985935b2c61d8530f5de4d461088d1 100644 +--- a/dist/Heading/Heading.js ++++ b/dist/Heading/Heading.js +@@ -16,7 +16,7 @@ + * - /packages/cli/assets/templates/blocks/components/Heading/ (showcase blocks) + * - /packages/cli/assets/templates/blocks/components/Text/ (showcase blocks) + */ +-import { lazy, Suspense, useRef } from 'react'; ++import { lazy, Suspense, useMemo, useRef } from 'react'; + import * as stylex from '@stylexjs/stylex'; + import { colorStyles, sizeByLevelStyles, sizeByTypeStyles, defaultWeightByTypeStyles, displayStyles, justifyStyles, truncationStyles, wordBreakStyles, textWrapStyles, capsizeStyles, decorationStyles, truncationTooltipStyles } from "../Text/text.stylex.js"; + import { useTruncation } from "../Text/useTruncation.js"; +@@ -107,13 +107,18 @@ export function Heading({ + // Ref for the heading element (used as tooltip anchor) + const headingRef = useRef(null); + ++ // Pace patch: keep the merged ref stable across renders so React does not ++ // detach/reattach it (and re-run useTruncation's ref) on every commit. ++ // Upstream fixed this in 0.6.x with useMergedRefs. ++ const mergedRef = useMemo(() => mergeRefs(ref, truncation.ref, headingRef), [ref, truncation.ref]); ++ + // Build inline style for -webkit-line-clamp (dynamic value) + const inlineStyle = maxLines > 1 ? { + WebkitLineClamp: maxLines + } : undefined; + return /*#__PURE__*/_jsxs(_Fragment, { + children: [/*#__PURE__*/_jsx(Component, { +- ref: mergeRefs(ref, truncation.ref, headingRef), ++ ref: mergedRef, + ...mergeProps(themeProps('heading', { + level, + color, +diff --git a/dist/Text/Text.js b/dist/Text/Text.js +index 4f10ae885ff32b7fb79d804fd728546f4484f836..45712717d7c80e2f31377bb16e0a701e580ecf65 100644 +--- a/dist/Text/Text.js ++++ b/dist/Text/Text.js +@@ -15,7 +15,7 @@ + * - /apps/storybook/stories/Text.stories.tsx (storybook stories) + * - /packages/cli/assets/templates/blocks/components/Text/ (showcase blocks) + */ +-import { lazy, Suspense, useRef } from 'react'; ++import { lazy, Suspense, useMemo, useRef } from 'react'; + import * as stylex from '@stylexjs/stylex'; + import { colorStyles, defaultWeightByTypeStyles, sizeStyles, sizeByTypeStyles, weightStyles, displayStyles, justifyStyles, truncationStyles, wordBreakStyles, textWrapStyles, capsizeStyles, decorationStyles, tabularNumbersStyle, truncationTooltipStyles } from "./text.stylex.js"; + import { useTruncation } from "./useTruncation.js"; +@@ -134,13 +134,18 @@ export function Text({ + // Ref for the text element (used as tooltip anchor) + const textRef = useRef(null); + ++ // Pace patch: keep the merged ref stable across renders so React does not ++ // detach/reattach it (and re-run useTruncation's ref) on every commit. ++ // Upstream fixed this in 0.6.x with useMergedRefs. ++ const mergedRef = useMemo(() => mergeRefs(ref, truncation.ref, textRef), [ref, truncation.ref]); ++ + // Build inline style for -webkit-line-clamp (dynamic value) + const inlineStyle = maxLines > 1 ? { + WebkitLineClamp: maxLines + } : undefined; + return /*#__PURE__*/_jsxs(_Fragment, { + children: [/*#__PURE__*/_jsx(Component, { +- ref: mergeRefs(ref, truncation.ref, textRef), ++ ref: mergedRef, + ...mergeProps(themeProps('text', { + type, + size, From 8358f6582c15b09f0944f09229ef275a2d57fb17 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 7 Oct 2026 16:15:39 +0800 Subject: [PATCH 2/2] fix(desktop): read a viewed Session however its projection arrives insert() and rehydrate() committed projections without the read marker, so a Session that entered the store unread after its view started kept its unread badge while on screen. commit() now applies it on every write. --- .../session-projections-store.test.tsx | 24 +++++++++++++++++++ .../session/session-projections-store.ts | 6 +++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/entities/session/session-projections-store.test.tsx b/apps/desktop/src/entities/session/session-projections-store.test.tsx index 10d7e75c..82c03da2 100644 --- a/apps/desktop/src/entities/session/session-projections-store.test.tsx +++ b/apps/desktop/src/entities/session/session-projections-store.test.tsx @@ -618,6 +618,30 @@ describe("viewed Sessions", () => { expect(notifies).toHaveLength(1); }); + it("reads a Session that arrives after its view started", () => { + const store = createStore(); + const release = store.view("late"); + + // Insert bypasses apply(): the read marker has to come from commit itself. + store.insert({ ...boundSession("late"), unreadResult: true }); + + expect(store.get("late")?.unreadResult).toBe(false); + release(); + }); + + it("reads a viewed Session that rehydrate brings in unread", async () => { + const fake = createFakeBridge(); + const store = createStore(fake, async () => [ + coldSession("hydrated", { unreadResult: true }), + ]); + const release = store.view("hydrated"); + + await store.rehydrate(); + + expect(store.get("hydrated")?.unreadResult).toBe(false); + release(); + }); + it("commits once per assistant event while the Session is on screen", () => { const fake = createFakeBridge(); let store!: SessionProjectionsStore; diff --git a/apps/desktop/src/entities/session/session-projections-store.ts b/apps/desktop/src/entities/session/session-projections-store.ts index 75a40534..d774a0f3 100644 --- a/apps/desktop/src/entities/session/session-projections-store.ts +++ b/apps/desktop/src/entities/session/session-projections-store.ts @@ -90,7 +90,9 @@ export function createSessionProjectionsStore(options: { for (const listener of listeners) listener(); }; const commit = (next: SessionProjection[]) => { - projections = next; + // Every write passes here, so a viewed Session is read however its + // projection arrives (event, history read, insert, rehydrate). + projections = viewed.size > 0 ? next.map(seen) : next; notify(); }; const get = (sessionId: string) => @@ -180,7 +182,7 @@ export function createSessionProjectionsStore(options: { } } history.set(sessionId, { key, piSessionId, state: "loaded" }); - replace(seen(next)); + replace(next); }) .catch((error: unknown) => { if (!current()) return;