fix: Eliminate content page flickering from layout shifts and double invalidation

- 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
This commit is contained in:
Lukas May
2026-03-06 12:46:39 +01:00
parent 08c1bed465
commit 243f24a397
4 changed files with 43 additions and 36 deletions

View File

@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{resolvedActivePageId && (
<>
{(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<div className="flex justify-end mb-2 h-4">
{(isSaving || updateInitiativeMutation.isPending) && (
<span className="text-xs text-muted-foreground">
Saving...
</span>
</div>
)}
)}
</div>
{activePageQuery.isSuccess && (
<input
value={pageTitle}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -36,33 +36,33 @@ export function TiptapEditor({
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [
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,
];
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(
{

View File

@@ -71,7 +71,11 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
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"],

View File

@@ -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<LiveUpdateRule[]>(() => [
{ 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 });