docs(bindings): explain React hooks and renderer ownership
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
|
||||
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
|
||||
* 重导出不自动挂 Provider、attach view 或创建 engine;ReactFlowProjection* 只是结构类型,不运行数据转换。
|
||||
*/
|
||||
"use client";
|
||||
/** Explicit framework boundary; consumers install the declared optional peers. */
|
||||
|
||||
@@ -15,14 +15,17 @@ export type ReactRenderers={readonly catalog:RendererCatalog;readonly definition
|
||||
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
||||
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
|
||||
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
|
||||
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
|
||||
/** $$typeof 为 react.memo 则原样返回;其它受支持组件按原引用弱缓存 React.memo 包装。
|
||||
* 不调用 render、不解析 lazy 内容,也不缓存组件状态;包装身份稳定不保证所有 props 变化都免渲染。 */
|
||||
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
|
||||
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
|
||||
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
|
||||
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
|
||||
}
|
||||
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
|
||||
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
|
||||
* 同一 structure 内 rendererId 可别名共用,但普通/预览组件引用必须一致;node/edge 类型表彼此独立。
|
||||
* 标记对象只做结构识别,不证明组件安全、纯净或必能成功渲染;安装来源仍须受信。
|
||||
* 每次调用新建目录/表/外壳,只有组件 memo 包装共享;冻结注册表不冻结函数闭包或 React 内部状态。 */
|
||||
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
||||
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
|
||||
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
|
||||
@@ -31,11 +34,13 @@ export function createReactRenderers(definitions:readonly ReactRendererDefinitio
|
||||
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
|
||||
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
||||
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
|
||||
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。
|
||||
// 可选 registry 只核 componentId/dataVersion/structure 精确存在,不执行某个元素的数据 schema/迁移。
|
||||
// 未提供 registry 也可装配纯显示目录,真正投影元素时仍需模型解释与 fallback 判断。
|
||||
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
||||
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
|
||||
// 定义保留原 render/preview 引用,类型表存编译后的 memo 引用;不混淆这两层身份。
|
||||
// 缺 preview 则不创建对应 previewTypes 项,工厂不自动用正式组件充当预览;宿主决定降级路径。
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
|
||||
}
|
||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
||||
|
||||
@@ -22,12 +22,15 @@ export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
|
||||
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
|
||||
const CanvasContext=createContext<CanvasBinding|null>(null);
|
||||
/** Borrows the engine and an already attached view. Unmount never closes the project. */
|
||||
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
|
||||
/** value 只按 engine/viewId 引用 memo 并冻结外壳,children 使用 React Context;不是项目实例拥有者。
|
||||
* 不 attach/detach view、不创建/销毁 engine;若使用交互 view,应由外层宿主先装配,卸载 Provider 也不关闭项目。 */
|
||||
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
|
||||
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
|
||||
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
|
||||
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
|
||||
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
|
||||
* selector 或等价函数换引用时重建派生 store,但可按 committed.current 复用等价结果,避免无意义选中值换身份。
|
||||
* effect 才推进已提交基线,不在 render 中认领提交;source/selector/isEqual 须遵守同步不可变读取合同。
|
||||
* getServerSnapshot 也使用同一 store.getSnapshot,不创建独立服务端模型,宿主须提供可稳定读取的绑定。 */
|
||||
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
|
||||
const committed=useRef<{value:T}|null>(null);
|
||||
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
|
||||
@@ -47,7 +50,9 @@ function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(
|
||||
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
|
||||
export function useElement(ref:Id|Target):ElementView;
|
||||
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
||||
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
|
||||
/** 字符串 ID 跟随当前生命周期;Target 仅在 present 且 incarnation 不同返回 stale,absent/unloaded 原样保留。
|
||||
* 每个 engine 共用按 ID 源;hook 再派生代次/selector 结果,默认完整视图 equal,自定义选择默认 Object.is。
|
||||
* stale 带当前元素供展示,但不自动把旧 Target 升级成新代次的写入权限。 */
|
||||
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
|
||||
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
|
||||
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
|
||||
@@ -55,7 +60,9 @@ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEq
|
||||
}
|
||||
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
|
||||
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
||||
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
|
||||
/** 按 engine/id/incarnation memo 动作外壳并捕获冻结 Target,之后改传入 target 对象不影响旧闭包。
|
||||
* rename/setData/resize/remove/moveTo 都只是普通 command;resize 接 Partial<Geometry>,不只限 width/height。
|
||||
* 调用时 editor 再核代次/策略/事务,不乐观修改 React 局部文档,也不在动作闭包里排网络请求。 */
|
||||
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
||||
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
|
||||
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
|
||||
@@ -69,16 +76,20 @@ const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSo
|
||||
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
|
||||
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
|
||||
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
|
||||
* 它不是跨两源的独立事务,合法性由使用方的目标代次校验保证。 */
|
||||
* 顺序读两源,不是跨源事务或原子快照锁;目标合法性仍由 hooks/命令校验。
|
||||
* 正常退订依次 a()/b(),没有 finally 隔离,宿主取消端口须不抛错;右侧注册失败时尽量撤回左侧。 */
|
||||
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
|
||||
let previous:readonly[A,B]|undefined;
|
||||
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
|
||||
}
|
||||
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
|
||||
/** 要求 Provider 有非空 viewId;这里只补绑定形状,不在此 attach 或验证该视图此刻仍注册。
|
||||
* session 操作入口继续核视图/手势生命周期,不能凭这个返回对象绕过所有权检查。 */
|
||||
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
|
||||
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
|
||||
/** 将 Error 风格异常的 code/targets 转为 Availability,缺 code 用 binding_failed。
|
||||
* 不归一化任意 thrown 值;null/undefined 不符合这里直接读属性的错误合同。 */
|
||||
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
|
||||
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
|
||||
/** 成对订阅元素查询和 view/element 模式;缺元素或旧代次读成 null,不把旧元素的编辑模式套给新元素。
|
||||
* setMode 调用时再核当前 incarnation,然后由 sessions.mode 检查视图;返回可用性,不直接编辑内容/history。 */
|
||||
export function useMode(target:Target){
|
||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
|
||||
@@ -87,7 +98,9 @@ export function useMode(target:Target){
|
||||
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
|
||||
}
|
||||
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
|
||||
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
|
||||
* controls 内 lease 仅是本 hook 捕获的数字 token,不是另一份手势事实;真实 active/preview 在 sessions。
|
||||
* effect 清理先核 token/view/active,仅取消自己取得的手势;旧组件卸载不能取消后来另一视图的新手势。
|
||||
* begin/update/commit 沿用 session 的结果或异常;拥有手势后进入 commit 的 try/finally 才保证清 lease,未拥有则直接拒绝。 */
|
||||
export function useGestureBindings(target:Target){
|
||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
|
||||
@@ -106,7 +119,8 @@ export function useGestureBindings(target:Target){
|
||||
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
|
||||
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
|
||||
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
|
||||
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */
|
||||
/** 每 engine/id memo 派生源,useSyncExternalStore 读取逻辑操作和关联请求;缺 ID 返回 undefined。
|
||||
* 这是诊断订阅,不轮询服务、不授予直接修改 phase/request 的接口。 */
|
||||
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
|
||||
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
|
||||
type Durability=ReturnType<ProjectEngine['getDurability']>;
|
||||
@@ -117,7 +131,8 @@ const inactiveOwner=(value:Durability)=>value.closing||value.closed;
|
||||
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
|
||||
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
|
||||
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
|
||||
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
|
||||
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。
|
||||
* 计数与 durability 来自两条订阅,不建立新的事务根;status 是显示优先级归纳,synced 不证明远端生成已结束。 */
|
||||
export function useSyncStatus(canvasId?:Id){
|
||||
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
|
||||
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||
@@ -125,7 +140,10 @@ export function useSyncStatus(canvasId?:Id){
|
||||
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
|
||||
}
|
||||
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
|
||||
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
|
||||
* 异步返回核 abort,渲染再核 state.engine/id/key;metadata 与句柄分别来自根和 effect,不另存原资源字节。
|
||||
* key 只含上传 operationId/purge、binding.authority/digest,普通元信息变化不全都重开显示请求。
|
||||
* attempt 只触发 effect 重试,不在返回 state 的身份键中;同来源 retry 的 loading 状态由 effect 启动时设置。
|
||||
* ready 表示 URL 租约已取得,不等于图片 DOM 已解码/绘制或上传/同步完成。 */
|
||||
export function useAsset(id:Id){
|
||||
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
|
||||
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||
@@ -134,7 +152,7 @@ export function useAsset(id:Id){
|
||||
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
|
||||
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
|
||||
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
|
||||
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
|
||||
/** retry 仅增本 hook 的尝试序号,effect 清旧租约再取新显示句柄;不调用 engine.retry 或重新上传。 */
|
||||
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
|
||||
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user