From 243f24a39789614e78ab12fe470d1a55bc3aea1e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:46:39 +0100 Subject: [PATCH] fix: Eliminate content page flickering from layout shifts and double invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve fixed height for "Saving..." indicator instead of conditionally rendering it, preventing layout shift on every auto-save cycle - Remove getPage from updatePage mutation cache invalidation — useAutoSave already handles optimistic updates, and SSE events cover external changes. This eliminates double-invalidation (mutation + SSE) refetch storms. - Memoize TiptapEditor extensions array to avoid recreating extensions and pageLinkDeletionDetector on every render - Memoize useLiveUpdates rules array in initiative detail page --- apps/web/src/components/editor/ContentTab.tsx | 8 +-- .../src/components/editor/TiptapEditor.tsx | 56 +++++++++---------- apps/web/src/lib/invalidation.ts | 6 +- apps/web/src/routes/initiatives/$id.tsx | 9 ++- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/editor/ContentTab.tsx b/apps/web/src/components/editor/ContentTab.tsx index dcb8ff5..71de63b 100644 --- a/apps/web/src/components/editor/ContentTab.tsx +++ b/apps/web/src/components/editor/ContentTab.tsx @@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {resolvedActivePageId && ( <> - {(isSaving || updateInitiativeMutation.isPending) && ( -
+
+ {(isSaving || updateInitiativeMutation.isPending) && ( Saving... -
- )} + )} +
{activePageQuery.isSuccess && ( { - if (node.type.name === 'heading') { - return `Heading ${node.attrs.level}`; - } - return "Type '/' for commands..."; - }, - }), - Link.configure({ - openOnClick: false, - }), - SlashCommands, - BlockSelectionExtension, - ]; - - const extensions = enablePageLinks - ? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] - : baseExtensions; + const extensions = useMemo(() => { + const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef); + const base = [ + StarterKit, + Table.configure({ resizable: true, cellMinWidth: 50 }), + TableRow, + TableCell, + TableHeader, + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Type '/' for commands..."; + }, + }), + Link.configure({ + openOnClick: false, + }), + SlashCommands, + BlockSelectionExtension, + ]; + return enablePageLinks + ? [...base, PageLinkExtension, detector] + : base; + }, [enablePageLinks]); const editor = useEditor( { diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index 3cd6e1f..eb5b517 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -71,7 +71,11 @@ const INVALIDATION_MAP: Partial> = { revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"], // --- Pages --- - updatePage: ["listPages", "getPage", "getRootPage"], + // NOTE: getPage omitted — useAutoSave handles optimistic updates for the + // active page, and SSE `page:updated` events cover external changes. + // Including getPage here caused double-invalidation (mutation + SSE) and + // refetch storms that flickered the editor. + updatePage: ["listPages", "getRootPage"], createPage: ["listPages", "getRootPage"], deletePage: ["listPages", "getRootPage"], diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 678100c..f56dbed 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle } from "lucide-react"; @@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab"; import { ReviewTab } from "@/components/review"; import { PipelineTab } from "@/components/pipeline"; import { useLiveUpdates } from "@/hooks"; +import type { LiveUpdateRule } from "@/hooks/useLiveUpdates"; type Tab = "content" | "plan" | "execution" | "review"; const TABS: Tab[] = ["content", "plan", "execution", "review"]; @@ -27,15 +29,16 @@ function InitiativeDetailPage() { const { tab: activeTab } = Route.useSearch(); const navigate = useNavigate(); - // Single SSE stream for all live updates - useLiveUpdates([ + // Single SSE stream for all live updates — memoized to avoid re-subscribe on render + const liveUpdateRules = useMemo(() => [ { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, - ]); + ], []); + useLiveUpdates(liveUpdateRules); // tRPC queries const initiativeQuery = trpc.getInitiative.useQuery({ id });