Expose React canvas hooks with scoped selectors and resource lifecycles

This commit is contained in:
2026-11-09 17:34:56 +08:00
parent d7b3321df1
commit 575b5a9256
18 changed files with 259 additions and 25 deletions
+11 -7
View File
@@ -75,6 +75,9 @@ export async function openProjectEngine(options: EngineOptions) {
const digestLifecycle=new AbortController();
const sourceDigest=(bytes:Blob,signal?:AbortSignal)=>{const combined=signal?AbortSignal.any([signal,digestLifecycle.signal]):digestLifecycle.signal;return options.digests?options.digests.digest(bytes,combined):importSourceDigest(bytes,combined);};
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } };
const durabilityListeners=new Set<()=>void>();let durabilityError:string|undefined,durabilityCache:Readonly<{rootRevision:number;durableRootRevision:number;closing:boolean;closed:boolean;error?:string}>|undefined;
function getDurability(){const rootRevision=editor.getSnapshot().rootRevision;if(!durabilityCache||durabilityCache.rootRevision!==rootRevision||durabilityCache.durableRootRevision!==durableRootRevision||durabilityCache.closing!==closing||durabilityCache.closed!==closed||durabilityCache.error!==durabilityError)durabilityCache=Object.freeze({rootRevision,durableRootRevision,closing,closed,...(durabilityError?{error:durabilityError}:{})});return durabilityCache;}
function notifyDurability(){for(const listener of [...durabilityListeners])try{listener();}catch(error){report(error);}}
const createId = options.createId ?? (() => crypto.randomUUID());
const network = createNetworkGate(2);
const depot=options.assetDepot??(options.storage.assetDepot?createAssetDepot({scope:options.scope,port:options.storage.assetDepot,current:()=>!closing&&!closed&&(!options.isCurrentIdentity||options.isCurrentIdentity()),digest:sourceDigest}):undefined);
@@ -195,15 +198,15 @@ export async function openProjectEngine(options: EngineOptions) {
const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined;
const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles,...(depot?{depot}:{}) });
if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt');
durableRootRevision = revision; pending.delete(revision);
durableRootRevision = revision; pending.delete(revision);notifyDurability();
for(const id of deleteFiles){durablyPurged.add(id);collectionReservations.delete(id);}
for (const file of files) fileWrites.delete(file.id);
for(const [id,entry]of adoptedFiles)if(entry.rootRevision<=snapshot.rootRevision&&depot?.writes.some(write=>write.ref.asset.id===id&&write.bytes===entry.bytes))adoptedFiles.delete(id);
if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation));
}
persistenceFailed = false;
persistenceFailed = false;durabilityError=undefined;notifyDurability();
})();
writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} });
writing = task.catch(error => { persistenceFailed = true;const failure=error&&typeof error==='object'?error as {code?:unknown;message?:unknown}:{};durabilityError=typeof failure.code==='string'&&failure.code?failure.code:typeof failure.message==='string'&&failure.message?failure.message:'persistence_failed';notifyDurability(); throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} });
return writing;
}
function collectResources(settings:{retentionMs:number;maxFiles?:number}) {
@@ -518,7 +521,7 @@ export async function openProjectEngine(options: EngineOptions) {
const sessions = createSessions(editor);
const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); });
const unsubscribe = editor.subscribe((commit, snapshot) => {
pending.set(snapshot.rootRevision, snapshot);
pending.set(snapshot.rootRevision, snapshot);notifyDurability();
const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id);
if(addedRuns.length)queueMicrotask(()=>runTracker.notify(addedRuns));
if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); }
@@ -980,17 +983,18 @@ export async function openProjectEngine(options: EngineOptions) {
},
getSnapshot: editor.getSnapshot, persist,
cancelRecovery:async()=>{await recovery?.cancel();},
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
getDurability,
subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};},
retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); },
async settle() { await Promise.resolve();await Promise.allSettled([...adoptionTasks.values()]);await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist();await scheduler.settle();await persist(); },
async close() {
if (closed) return;
closing = true;loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask;
recovery?.controller.abort();recovery?.release();
for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close();
// Failure keeps the leader/recovery material available for an explicit retry of close.
await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;
await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear();
},
});
} catch (error) { await leader.release(); throw error; }