fix(web): keep user-opened scene when startup template is still loading
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

Cancel the automatic startup General project as soon as a file is selected
or the lesson studio is requested, and re-check after the template fetch.
Prevents a slow remote first load from overwriting the user's scene.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-12 22:58:04 +08:00
parent c92cd933c5
commit a27aa46d7c
2 changed files with 16 additions and 6 deletions

View File

@@ -3939,6 +3939,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
},[]);
const desktopHandoffLoaded=useRef(false);
const wasmStartupLoaded=useRef(false);
const startupTemplateCancelled=useRef(false);
const textDraftDirty=useRef(false);
const [projectActionLocked,setProjectActionLocked]=useState(false);
const textDraftAreas=useRef(new Set<string>());
@@ -5432,6 +5433,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
const openTutorialScene = async (url: string, fileName: string): Promise<boolean> => {
try {
startupTemplateCancelled.current = true;
const response = await fetch(url, { cache: "no-cache" });
if (!response.ok) return false;
const file = new File([await response.arrayBuffer()], fileName, { type: "application/x-blender" });
@@ -7193,7 +7195,8 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
window.addEventListener('message',onNativeFullscreenRequest);
return()=>window.removeEventListener('message',onNativeFullscreenRequest);
},[toggleBrowserFullscreen]);
const newProject=async()=>{if(headerPending)return;
const newProject=async(startup=false)=>{if(headerPending)return;
if(startup&&startupTemplateCancelled.current)return;
/* A user open in flight supersedes the startup template, but a brief autosave
* checkpoint must not suppress project creation entirely: wait for the lock,
* then skip only if another open already replaced the project meanwhile. */
@@ -7202,6 +7205,9 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
if(projectIdRef.current!==startProject)return;
setHeaderPending(true);try{
const response=await fetch('/templates/general.blend');if(!response.ok)throw new Error('NEW_PROJECT_TEMPLATE_MISSING');
// The template fetch can outlive a user open started after the lock check.
if(startup&&startupTemplateCancelled.current)return;
if(projectIdRef.current!==startProject)return;
if(!await openBlendFile(new File([await response.arrayBuffer()],'Untitled.blend')))return;
const client=webClientRef.current;if(!client)throw new Error('ENGINE_NOT_READY');
const current=await client.snapshot();if(!current.snapshot.nodes.some(node=>node.name==='Cube'))throw new Error('NEW_PROJECT_OPEN_FAILED');
@@ -7830,7 +7836,7 @@ else:
},[wasmStatus]);
useEffect(()=>{
if(wasmStartupLoaded.current||new URLSearchParams(location.search).has('desktopHandoff')||!wasmStatus.startsWith('WASM ABI: ready'))return;
wasmStartupLoaded.current=true;void newProject();
wasmStartupLoaded.current=true;void newProject(true);
},[wasmStatus,localOnly]);
const applyNativeTimelineEdit = async (edit: NativeTimelineEdit, areaId?: string): Promise<boolean> => {
@@ -8078,7 +8084,7 @@ else:
<DesktopMenu className="workspace-menu-control" label="工作区列表" items={localOnly&&editorWorkflow?editorWorkflow.workspaces.map(item=>({label:item.name,checked:item.id===nativeWorkspace?.id,disabled:!item.writable,action:()=>void selectNativeWorkspace(item.id)})):BLENDER_WORKSPACE_IDS.map(id=>({label:id,checked:id===workspace,action:()=>dispatchUI({type:'switchWorkspace',workspaceId:id})}))}/>
</nav>
<div className="topbar-actions"><label title="恢复自动保存时允许执行 Blender 文件脚本"><input type="checkbox" aria-label="允许恢复文件脚本" checked={nativeRecoveryUseScripts} onChange={event=>setNativeRecoveryUseScripts(event.target.checked)}/>允许恢复脚本</label><button type="button" className="extension-robot" aria-label="六轴机器人工具" title="Web 扩展:六轴机器人" onClick={()=>setRobotToolOpen(true)}>Robot</button>{(snapshot?.scenes?.length??0)>1?<select className="topbar-context" aria-label="当前场景" title="当前场景" value={snapshot?.sceneId??''} onChange={(event)=>{const sceneId=event.target.value;if(sceneId)void applyEditCommand({type:"setScene",sceneId});}}>{(snapshot?.scenes??[]).map((scene)=><option key={scene.id} value={scene.id}>{scene.name}</option>)}</select>:<span className="topbar-context" title="当前场景">{activeSceneFromSnapshot(snapshot)?.name??"Scene"}</span>}<span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName, 'open'); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存项目" onClick={() => void saveBlend()}>▣</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} />
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { startupTemplateCancelled.current = true; const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} />
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING" || projectActionLocked}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}