"use client"; import {StrictMode,memo,useEffect,useLayoutEffect} from 'react'; import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom'; import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus,type ElementView} from '@/lib/canvas-bindings/react'; import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index'; import {openProjectEngine} from '@/lib/canvas-runtime/engine'; import {equal} from '@/lib/canvas-core/values'; const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);}; const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');} async function fixture(partial=false){ const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise|undefined; const files=new Map(),receipts=new Map(),errors:unknown[]=[];let reads=0; const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}}; const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical}; const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)}; const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle(); return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise){readGate=value;},detach(){detachA();detachB();}}; } async function run(){ const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any; const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;}); const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;}); const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;}; const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;}); const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;}); const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;}); const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;}); const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;}); const Status=memo(function Status(){status=useSyncStatus();return null;}); const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;}); const render=async(children:any,owner=engine)=>{flushSync(()=>root.render({children}));await tick();}; try{ await render(<><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections; engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector'); const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields'); const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation'); const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode'); assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view'); const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture'); engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced'); let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads; await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount'); let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin'); await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations'); await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project'); const other=await fixture();try{ let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle(); const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine'); }finally{await other.engine.close();} const partial=await fixture(true);try{ let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;} await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element'); }finally{await partial.engine.close();} return reports; }finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();} } export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;}