feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
+133 -30
View File
@@ -1,5 +1,9 @@
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
buildSidebarDataSyncKey,
getSidebarDataFreshness,
} from "@/components/sidebar/sidebar-sync";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
@@ -11,6 +15,24 @@ export interface SidebarDataResult {
source: "convex-live" | "http-fallback" | "initial";
}
const STABLE_SIDEBAR_CACHE_LIMIT = 12;
const stableSidebarDataCache = new Map<string, SidebarInitialData>();
function getStableSidebarData(syncKey: string, data: SidebarInitialData): SidebarInitialData {
const cached = stableSidebarDataCache.get(syncKey);
if (cached) {
return cached;
}
stableSidebarDataCache.set(syncKey, data);
if (stableSidebarDataCache.size > STABLE_SIDEBAR_CACHE_LIMIT) {
const oldestKey = stableSidebarDataCache.keys().next().value;
if (typeof oldestKey === "string") {
stableSidebarDataCache.delete(oldestKey);
}
}
return data;
}
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
method: "GET",
@@ -31,6 +53,13 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
const convexSidebar = useConvexSidebarData(workspaceId);
const shouldUseHttpFallback =
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
const [manualSnapshotState, setManualSnapshotState] = useState<{
workspaceId: string;
data: SidebarInitialData | null;
}>({
workspaceId,
data: null,
});
const httpQuery = useQuery({
queryKey: ["sidebar", workspaceId],
@@ -40,41 +69,115 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
enabled: shouldUseHttpFallback,
});
return useMemo<SidebarDataResult>(() => {
const liveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const isLoading =
convexSidebar.hasLiveSubscription
? convexSidebar.isLoading
: shouldUseHttpFallback
? httpQuery.isLoading
: false;
const source: SidebarDataResult["source"] = convexSidebar.data
? "convex-live"
: shouldUseHttpFallback && httpQuery.data
? "http-fallback"
: "initial";
const refetch = async () => {
if (convexSidebar.hasLiveSubscription) {
await convexSidebar.refetch();
return liveData;
}
if (shouldUseHttpFallback) {
return httpQuery.refetch();
}
return liveData;
};
const manualSnapshot =
manualSnapshotState.workspaceId === workspaceId
? manualSnapshotState.data
: null;
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const baseLiveDataSyncKey = useMemo(
() => buildSidebarDataSyncKey(baseLiveData),
[baseLiveData],
);
const baseLiveDataFreshness = useMemo(
() => getSidebarDataFreshness(baseLiveData),
[baseLiveData],
);
const manualSnapshotSyncKey = useMemo(
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
[manualSnapshot],
);
const manualSnapshotFreshness = useMemo(
() => (manualSnapshot ? getSidebarDataFreshness(manualSnapshot) : Number.NEGATIVE_INFINITY),
[manualSnapshot],
);
const liveData = useMemo(() => {
if (!manualSnapshot) {
return baseLiveData;
}
if (manualSnapshotSyncKey === baseLiveDataSyncKey) {
return baseLiveData;
}
return manualSnapshotFreshness >= baseLiveDataFreshness
? manualSnapshot
: baseLiveData;
}, [
baseLiveData,
baseLiveDataFreshness,
baseLiveDataSyncKey,
manualSnapshot,
manualSnapshotFreshness,
manualSnapshotSyncKey,
]);
const isLoading =
convexSidebar.hasLiveSubscription
? convexSidebar.isLoading
: shouldUseHttpFallback
? httpQuery.isLoading
: false;
const source: SidebarDataResult["source"] = convexSidebar.data
? "convex-live"
: shouldUseHttpFallback && httpQuery.data
? "http-fallback"
: "initial";
const error = convexSidebar.error ?? httpQuery.error ?? null;
const liveDataRef = useRef(liveData);
const convexRefetchRef = useRef(convexSidebar.refetch);
const httpRefetchRef = useRef(httpQuery.refetch);
const liveDataSyncKey = useMemo(() => buildSidebarDataSyncKey(liveData), [liveData]);
useEffect(() => {
liveDataRef.current = liveData;
}, [liveData]);
useEffect(() => {
convexRefetchRef.current = convexSidebar.refetch;
}, [convexSidebar]);
useEffect(() => {
httpRefetchRef.current = httpQuery.refetch;
}, [httpQuery]);
const refetch = useCallback(async () => {
if (workspaceId) {
try {
const refreshedSnapshot = await requestSidebarData(workspaceId);
setManualSnapshotState({
workspaceId,
data: refreshedSnapshot,
});
return refreshedSnapshot;
} catch {
}
}
if (convexSidebar.hasLiveSubscription) {
await convexRefetchRef.current();
return liveDataRef.current;
}
if (shouldUseHttpFallback) {
return httpRefetchRef.current();
}
return liveDataRef.current;
}, [
convexSidebar.hasLiveSubscription,
shouldUseHttpFallback,
workspaceId,
]);
const stableLiveData = getStableSidebarData(liveDataSyncKey, liveData);
return useMemo<SidebarDataResult>(() => {
return {
data: liveData,
data: stableLiveData,
isLoading,
error: convexSidebar.error ?? httpQuery.error ?? null,
error,
refetch,
source,
};
}, [
convexSidebar,
httpQuery,
initialData,
shouldUseHttpFallback,
error,
isLoading,
refetch,
stableLiveData,
source,
]);
}