From 65c54e7b9e5327616b8a55e3b1e45b1713e9d2ed Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:14:21 +0800 Subject: [PATCH] docs(workspace): explain component state and async ownership --- src/components/workspace/account-panel.tsx | 3 +++ src/components/workspace/active-board.tsx | 1 + src/components/workspace/board-directory.tsx | 2 ++ src/components/workspace/canvas-runtime-host.tsx | 2 ++ src/components/workspace/editable-title.tsx | 3 +++ src/components/workspace/graph-page.tsx | 5 +++++ src/components/workspace/user-avatar.tsx | 4 +++- 7 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/workspace/account-panel.tsx b/src/components/workspace/account-panel.tsx index 185b087..2abd95b 100644 --- a/src/components/workspace/account-panel.tsx +++ b/src/components/workspace/account-panel.tsx @@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp * HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */ async function submit(event: FormEvent) { event.preventDefault(); + // busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。 if (busy) return; const data = new FormData(event.currentTarget); setBusy(true); @@ -38,6 +39,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp if (settings) { const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() }; const updated = demo ? { ...profile, ...input } : await api("profile", { method: "PATCH", body: JSON.stringify(input) }); + // 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。 onProfile(updated); } else { const name = String(data.get("name")).trim(); @@ -49,6 +51,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp finally { setBusy(false); } } + // 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。 // 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。 return ( { if (!open && !busy) onClose(); }}> diff --git a/src/components/workspace/active-board.tsx b/src/components/workspace/active-board.tsx index fe8cb90..bf5c605 100644 --- a/src/components/workspace/active-board.tsx +++ b/src/components/workspace/active-board.tsx @@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{ /** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */ export function ActiveBoardProvider({ children }: { children: ReactNode }) { + // Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。 const [board, setBoard] = useState(null); const value = useMemo(() => ({ board, setBoard }), [board]); return {children}; diff --git a/src/components/workspace/board-directory.tsx b/src/components/workspace/board-directory.tsx index 0c896d1..3072c20 100644 --- a/src/components/workspace/board-directory.tsx +++ b/src/components/workspace/board-directory.tsx @@ -13,6 +13,7 @@ import styles from "./workspace.module.css"; /** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */ export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) { const [closed, setClosed] = useState(new Set()); + // memo 按 boards 引用重算;宿主更新条目须提供新数组,不能只原地改标题/父链再期待重新派生。 const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]); /** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */ useEffect(() => { @@ -22,6 +23,7 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa /** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */ function render(items: BoardBranch[], depth = 0) { return
    {items.map(({ board, children }) => { + // 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。 const expanded = !!query.trim() || !closed.has(board.id); return
  • {children.length ? : } diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx index 46e233e..2a0d96f 100644 --- a/src/components/workspace/canvas-runtime-host.tsx +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers); export function useCanvasRenderers(){return useContext(Renderers);} /** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) { + // 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。 const [{host,renderers}] = useState(() => { // 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。 const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry); @@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN * 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */ useEffect(() => { const generation = ++lifetime.current; + // cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。 // Strict Mode replays effects synchronously; only a real detach retires the host. return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); }; }, [host]); diff --git a/src/components/workspace/editable-title.tsx b/src/components/workspace/editable-title.tsx index b9e088d..83a1c68 100644 --- a/src/components/workspace/editable-title.tsx +++ b/src/components/workspace/editable-title.tsx @@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { cancelled.current = true; setError(""); setMode("selected"); + // 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。 requestAnimationFrame(() => button.current?.focus()); } /** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。 @@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { setMode("saving"); setError(""); try { + // onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。 await onSave(name); cancelled.current = true; setMode("idle"); @@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { setMode("editing"); } finally { pending.current = false; } } + // title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。 // saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。 return (
    diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index 9fd23d2..cea6999 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin // latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。 let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType|undefined; /** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */ + // 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。 const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));}; /** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */ const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);}; @@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin function pump(){ if(pumping)return pumping; const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})(); + // finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。 pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping; } @@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin catch(error){reportBackground(error);} } // 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。 + // 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。 while(pumping)await pumping; if(!active||!currentIdentity?.())return; const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken}); @@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin return () => { active = false;latest=undefined; controller.abort();completeInBackground(); }; }, [workspaceId, boardId, route, demo, retry, router, runtimeHost]); // 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。 + // route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。 const current = loaded?.route === route ? loaded : null; const currentPreview=preview?.route===route?preview:null; const board = current?.board??currentPreview?.board; /** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */ useEffect(() => { if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title }); + // 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。 return () => setActiveBoard(null); }, [board, workspaceId, setActiveBoard]); // 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。 diff --git a/src/components/workspace/user-avatar.tsx b/src/components/workspace/user-avatar.tsx index 361542f..0682dad 100644 --- a/src/components/workspace/user-avatar.tsx +++ b/src/components/workspace/user-avatar.tsx @@ -7,13 +7,15 @@ import { useState } from "react"; import styles from "./workspace.module.css"; -/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */ +/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 + * 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */ export function UserAvatar({ name, url, large = false }: { name: string; url?: string | null; large?: boolean; }) { const [failedUrl, setFailedUrl] = useState(null); + // fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。 const showImage = Boolean(url && url !== failedUrl); return (