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:
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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"],
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user