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 });