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