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 && ( {resolvedActivePageId && (
<> <>
<div className="flex justify-end mb-2 h-4">
{(isSaving || updateInitiativeMutation.isPending) && ( {(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Saving... Saving...
</span> </span>
</div>
)} )}
</div>
{activePageQuery.isSuccess && ( {activePageQuery.isSuccess && (
<input <input
value={pageTitle} 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 { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
@@ -36,9 +36,9 @@ export function TiptapEditor({
const onPageLinkDeletedRef = useRef(onPageLinkDeleted); const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted; onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef); const extensions = useMemo(() => {
const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [ const base = [
StarterKit, StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }), Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow, TableRow,
@@ -59,10 +59,10 @@ export function TiptapEditor({
SlashCommands, SlashCommands,
BlockSelectionExtension, BlockSelectionExtension,
]; ];
return enablePageLinks
const extensions = enablePageLinks ? [...base, PageLinkExtension, detector]
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] : base;
: baseExtensions; }, [enablePageLinks]);
const editor = useEditor( 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"], revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
// --- Pages --- // --- 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"], createPage: ["listPages", "getRootPage"],
deletePage: ["listPages", "getRootPage"], deletePage: ["listPages", "getRootPage"],

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
@@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab";
import { ReviewTab } from "@/components/review"; import { ReviewTab } from "@/components/review";
import { PipelineTab } from "@/components/pipeline"; import { PipelineTab } from "@/components/pipeline";
import { useLiveUpdates } from "@/hooks"; import { useLiveUpdates } from "@/hooks";
import type { LiveUpdateRule } from "@/hooks/useLiveUpdates";
type Tab = "content" | "plan" | "execution" | "review"; type Tab = "content" | "plan" | "execution" | "review";
const TABS: Tab[] = ["content", "plan", "execution", "review"]; const TABS: Tab[] = ["content", "plan", "execution", "review"];
@@ -27,15 +29,16 @@ function InitiativeDetailPage() {
const { tab: activeTab } = Route.useSearch(); const { tab: activeTab } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
// Single SSE stream for all live updates // Single SSE stream for all live updates — memoized to avoid re-subscribe on render
useLiveUpdates([ const liveUpdateRules = useMemo<LiveUpdateRule[]>(() => [
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
]); ], []);
useLiveUpdates(liveUpdateRules);
// tRPC queries // tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id }); const initiativeQuery = trpc.getInitiative.useQuery({ id });