docs(workspace): explain component state and async ownership
This commit is contained in:
@@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
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>("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 (
|
||||
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
||||
|
||||
@@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{
|
||||
|
||||
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||
// Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。
|
||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||
|
||||
@@ -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<string>());
|
||||
// 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 <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
||||
// 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。
|
||||
const expanded = !!query.trim() || !closed.has(board.id);
|
||||
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
|
||||
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 (
|
||||
<div ref={shell} className={`${styles.shell} ${className}`}>
|
||||
|
||||
@@ -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<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|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的活动状态不能覆盖当前名称。
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
// fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。
|
||||
const showImage = Boolean(url && url !== failedUrl);
|
||||
return (
|
||||
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
|
||||
|
||||
Reference in New Issue
Block a user