perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -21,10 +21,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
@@ -61,7 +63,7 @@ type Props = {
|
||||
setEditingId: (id: string | null) => void;
|
||||
editTitle: string;
|
||||
setEditTitle: (value: string) => void;
|
||||
handleSaveEdit: () => void;
|
||||
handleSaveEdit: (titleOverride?: string) => void;
|
||||
handleCancelEdit: () => void;
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
@@ -83,133 +85,58 @@ type Props = {
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean }) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
|
||||
renderSessionNode: (
|
||||
node: SessionNode,
|
||||
depth?: number,
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
secondaryMeta?: SecondaryMeta | null,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
};
|
||||
|
||||
const getNodeChildSignature = (node: SessionNode): string => {
|
||||
if (node.children.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return node.children
|
||||
.map((child) => `${child.session.id}:${child.children.length}`)
|
||||
.join('|');
|
||||
};
|
||||
|
||||
const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.session.id === sessionId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
if (treeContainsSessionId(child, sessionId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const treeContainsMenuKey = (
|
||||
node: SessionNode,
|
||||
menuKey: string | null,
|
||||
renderContext: 'project' | 'recent',
|
||||
archivedBucket: boolean,
|
||||
): boolean => {
|
||||
if (!menuKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`;
|
||||
if (nodeMenuKey === menuKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const areEqual = (prev: Props, next: Props): boolean => {
|
||||
const prevSession = prev.node.session;
|
||||
const nextSession = next.node.session;
|
||||
const prevSessionId = prevSession.id;
|
||||
const nextSessionId = nextSession.id;
|
||||
|
||||
if (prevSessionId !== nextSessionId) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||||
if (prev.currentSessionId !== next.currentSessionId) {
|
||||
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
|
||||
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
|
||||
if (prevActiveInTree || nextActiveInTree) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||||
// Expansion is keyed per render context, so compare the composite key
|
||||
// matching the one isExpanded reads from in render. If a session appears
|
||||
// in two contexts (project + recent), they have independent state.
|
||||
{
|
||||
const prevRenderContext = prev.renderContext ?? 'project';
|
||||
const nextRenderContext = next.renderContext ?? 'project';
|
||||
const prevArchived = prev.archivedBucket ?? false;
|
||||
const nextArchived = next.archivedBucket ?? false;
|
||||
const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`;
|
||||
const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`;
|
||||
if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false;
|
||||
}
|
||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||
if (prev.editingId !== next.editingId) {
|
||||
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||||
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||||
if (prevEditingInTree || nextEditingInTree) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (prev.editTitle !== next.editTitle) {
|
||||
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||||
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||||
if (prevEditingInTree || nextEditingInTree) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
||||
|
||||
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
|
||||
const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
|
||||
if (prevMenuInTree !== nextMenuInTree) return false;
|
||||
|
||||
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(prev.groupDirectory ?? null);
|
||||
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(next.groupDirectory ?? null);
|
||||
if (prevDirectory !== nextDirectory) return false;
|
||||
|
||||
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
|
||||
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
||||
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||||
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
|
||||
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||||
if (prev.renamingFolderId !== next.renamingFolderId) return false;
|
||||
|
||||
return true;
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the current
|
||||
* active session. Computed once per SessionGroupSection render (when
|
||||
* currentSessionId changes) instead of being recomputed in every row's
|
||||
* React.memo comparator.
|
||||
*/
|
||||
subtreeContainsActive: Set<string>;
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the session
|
||||
* currently being edited. Same rationale as subtreeContainsActive.
|
||||
*/
|
||||
subtreeContainsEditing: Set<string>;
|
||||
/**
|
||||
* Precomputed session ID of the row whose sidebar menu is open, or null
|
||||
* if no menu is open. Only one row can have its menu open at a time.
|
||||
*/
|
||||
menuOpenSessionId: string | null;
|
||||
/**
|
||||
* Precomputed structural key for this node. Encodes the IDs and child
|
||||
* counts of all descendants so a reference-only change to `node` (e.g.
|
||||
* a fresh tree rebuild) can be detected with a single string compare
|
||||
* instead of a recursive walk per row.
|
||||
*/
|
||||
nodeStructureKey: string;
|
||||
/**
|
||||
* Resolves the per-row render extras for each child node. SessionGroupSection
|
||||
* walks the whole tree once to precompute the structure key for every
|
||||
* descendant; SessionNodeItem's recursive child render uses this lookup
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
/**
|
||||
* Batched index of live session objects keyed by id. The previous
|
||||
* implementation called `useSession(session.id)` per row, which used
|
||||
* `findLiveSession` to iterate every child-store on every SSE event.
|
||||
* With M visible rows that's M×child-stores per event; the batched
|
||||
* map turns it into a single Map.get per row. The parent falls back
|
||||
* to `useSession` only when this map returns undefined.
|
||||
*/
|
||||
liveSessionById: Map<string, Session>;
|
||||
};
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
@@ -255,6 +182,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
liveSessionById,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
@@ -302,11 +234,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
||||
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
||||
handleSaveEditRef.current = handleSaveEdit;
|
||||
const [renameDraft, setRenameDraft] = React.useState(editTitle);
|
||||
const renameDraftRef = React.useRef(renameDraft);
|
||||
renameDraftRef.current = renameDraft;
|
||||
const renameTargetRef = React.useRef<string | null>(null);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const session = node.session;
|
||||
const liveSession = useSession(session.id);
|
||||
const resolvedSession = liveSession ?? session;
|
||||
// Batched live-session lookup. `liveSessionById` is built once per
|
||||
// Sidebar render from the same `useAllLiveSessions` selector that
|
||||
// `useSession` would have iterated per child-store, so a Map.get
|
||||
// here is equivalent in observed state but O(1) per row instead of
|
||||
// O(child-stores). Falls back to the row session when the live map
|
||||
// hasn't seen this id yet (sub-render latency between when a session
|
||||
// is created and when the SSE-driven aggregate picks it up).
|
||||
const resolvedSession = liveSessionById.get(session.id) ?? session;
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
@@ -477,13 +419,25 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
if (editingId !== session.id) return;
|
||||
const handleDocMouseDown = (e: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||
handleSaveEditRef.current();
|
||||
handleSaveEditRef.current(renameDraftRef.current);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleDocMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleDocMouseDown);
|
||||
}, [editingId, session.id]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (editingId !== session.id) {
|
||||
if (renameTargetRef.current === session.id) {
|
||||
renameTargetRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (renameTargetRef.current === session.id) return;
|
||||
renameTargetRef.current = session.id;
|
||||
setRenameDraft(editTitle);
|
||||
}, [editingId, editTitle, session.id]);
|
||||
|
||||
if (editingId === session.id) {
|
||||
return (
|
||||
<div
|
||||
@@ -496,24 +450,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
className="flex w-full items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveEdit();
|
||||
handleSaveEdit(renameDraft);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder={t('sessions.sidebar.session.menu.rename')}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
handleCancelEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
@@ -1030,18 +981,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handleRowPointerDown}
|
||||
onPointerUp={handleRowPointerEnd}
|
||||
onPointerCancel={handleRowPointerEnd}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={(event) => handleRowSelect(event)}
|
||||
onPointerDown={handleRowPointerDown}
|
||||
onPointerUp={handleRowPointerEnd}
|
||||
onPointerCancel={handleRowPointerEnd}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={(event) => handleRowSelect(event)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick(session.id, sessionTitle);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
isTouchPressed && 'bg-interactive-hover/70',
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
isTouchPressed && 'bg-interactive-hover/70',
|
||||
alwaysShowActions
|
||||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||||
: revealPaddingClass
|
||||
@@ -1159,7 +1110,26 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
</ContextMenu.Root>
|
||||
</DraggableSessionRow>
|
||||
{hasChildren && isExpanded
|
||||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
||||
? node.children.map((child): React.ReactNode => {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
};
|
||||
return renderSessionNode(
|
||||
child,
|
||||
depth + 1,
|
||||
sessionDirectory ?? groupDirectory,
|
||||
projectId,
|
||||
archivedBucket,
|
||||
undefined,
|
||||
renderContext,
|
||||
childRenderExtras,
|
||||
);
|
||||
})
|
||||
: null}
|
||||
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||
@@ -1213,4 +1183,194 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
|
||||
const getNodeSessionDirectory = (node: SessionNode): string | null => {
|
||||
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
|
||||
};
|
||||
|
||||
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
|
||||
return (prev?.projectLabel ?? null) === (next?.projectLabel ?? null)
|
||||
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
|
||||
};
|
||||
|
||||
const getMenuSessionIdFromKey = (props: Props): string | null => {
|
||||
if (!props.openSidebarMenuKey) return null;
|
||||
const bucketTag = props.archivedBucket ? 'archived' : 'active';
|
||||
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
|
||||
return props.openSidebarMenuKey.startsWith(prefix)
|
||||
? props.openSidebarMenuKey.slice(prefix.length)
|
||||
: null;
|
||||
};
|
||||
|
||||
const getRelevantMenuSessionId = (props: Props): string | null => {
|
||||
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
|
||||
};
|
||||
|
||||
const subtreeContainsSession = (
|
||||
props: Props,
|
||||
sessionId: string | null,
|
||||
precomputed: Set<string>,
|
||||
): boolean => {
|
||||
if (!sessionId) return false;
|
||||
if (precomputed.has(props.node.session.id)) return true;
|
||||
return nodeContainsSessionId(props.node, sessionId);
|
||||
};
|
||||
|
||||
const hasSetMembershipChangeInNode = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevSet: Set<string>,
|
||||
nextSet: Set<string>,
|
||||
getKey: (node: SessionNode) => string,
|
||||
): boolean => {
|
||||
if (prevNode.session.id !== nextNode.session.id) return true;
|
||||
const key = getKey(prevNode);
|
||||
if (prevSet.has(key) !== nextSet.has(key)) return true;
|
||||
if (prevNode.children.length !== nextNode.children.length) return true;
|
||||
for (let i = 0; i < prevNode.children.length; i += 1) {
|
||||
if (hasSetMembershipChangeInNode(prevNode.children[i], nextNode.children[i], prevSet, nextSet, getKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasResolvedSessionChangeInNode = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevLiveSessionById: Map<string, Session>,
|
||||
nextLiveSessionById: Map<string, Session>,
|
||||
): boolean => {
|
||||
if (prevNode.session.id !== nextNode.session.id) return true;
|
||||
const sessionId = prevNode.session.id;
|
||||
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
|
||||
return true;
|
||||
}
|
||||
if (prevNode.children.length !== nextNode.children.length) return true;
|
||||
for (let i = 0; i < prevNode.children.length; i += 1) {
|
||||
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
|
||||
return hasSetMembershipChangeInNode(
|
||||
prev.node,
|
||||
next.node,
|
||||
prev.expandedParents,
|
||||
next.expandedParents,
|
||||
(node) => `${prev.renderContext ?? 'project'}:${prevBucketTag}:${node.session.id}`,
|
||||
) || hasSetMembershipChangeInNode(
|
||||
prev.node,
|
||||
next.node,
|
||||
prev.expandedParents,
|
||||
next.expandedParents,
|
||||
(node) => `${next.renderContext ?? 'project'}:${nextBucketTag}:${node.session.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||||
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||||
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||||
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
|
||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
|
||||
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
|
||||
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|
||||
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|
||||
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editTitle !== next.editTitle
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|
||||
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.copiedSessionId !== next.copiedSessionId
|
||||
&& (
|
||||
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|
||||
|| nodeContainsSessionId(next.node, next.copiedSessionId)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
|
||||
const prevMenuSessionId = getRelevantMenuSessionId(prev);
|
||||
const nextMenuSessionId = getRelevantMenuSessionId(next);
|
||||
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (prev.renamingFolderId !== next.renamingFolderId) {
|
||||
const prevMenuSessionId = getRelevantMenuSessionId(prev);
|
||||
const nextMenuSessionId = getRelevantMenuSessionId(next);
|
||||
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return prev.setEditingId === next.setEditingId
|
||||
&& prev.setEditTitle === next.setEditTitle
|
||||
&& prev.handleSaveEdit === next.handleSaveEdit
|
||||
&& prev.handleCancelEdit === next.handleCancelEdit
|
||||
&& prev.toggleParent === next.toggleParent
|
||||
&& prev.handleSessionSelect === next.handleSessionSelect
|
||||
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
|
||||
&& prev.togglePinnedSession === next.togglePinnedSession
|
||||
&& prev.handleShareSession === next.handleShareSession
|
||||
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
|
||||
&& prev.handleUnshareSession === next.handleUnshareSession
|
||||
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
||||
&& prev.getFoldersForScope === next.getFoldersForScope
|
||||
&& prev.getSessionFolderId === next.getSessionFolderId
|
||||
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
|
||||
&& prev.addSessionToFolder === next.addSessionToFolder
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
};
|
||||
|
||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
|
||||
|
||||
Reference in New Issue
Block a user