From be598973c06381ed7c0d8ad0b047a04968338629 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 2 Sep 2026 17:05:05 -0400 Subject: [PATCH] editor: a failed scene load shows an error with retry instead of an empty scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host `onLoad` that rejects (failed fetch, timeout) used to fall through to `applySceneGraphToEditor(null)`, i.e. the default site/building/level scaffold. The autosave loop re-baselined on that cleared store, the next store touch armed a save, and the scaffold was written over the real project. Prod audit 2026-09-02: 10–100 such wipes per day; 11–13 % of forks and template starts opened over two weeks lost their copied scene on the first save. Now a failed load keeps the store unloaded and the autosave loop in its loading state (nothing can be written), and renders `SceneLoadFailed` with a retry that re-runs the load effect. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DLB54VYmGTzvyHNFb3wWxE --- .../editor/src/components/editor/index.tsx | 47 ++++++++++++++----- .../editor/src/components/ui/scene-loader.tsx | 35 ++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 65ec105aa2..aafb678a0d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -64,7 +64,7 @@ import { PanelManager } from '../ui/panels/panel-manager' import { ErrorBoundary } from '../ui/primitives/error-boundary' import { useSidebarStore } from '../ui/primitives/sidebar' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' -import { SceneLoader } from '../ui/scene-loader' +import { SceneLoader, SceneLoadFailed } from '../ui/scene-loader' import { AppSidebar } from '../ui/sidebar/app-sidebar' import type { ExtraPanel } from '../ui/sidebar/icon-rail' import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' @@ -1255,6 +1255,11 @@ function EditorContent({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) + // A failed `onLoad` is shown as an error with a retry, never as an empty + // scene: an editor that renders the default scaffold after a failed load + // autosaves that scaffold over the real project. + const [sceneLoadError, setSceneLoadError] = useState(null) + const [sceneLoadAttempt, setSceneLoadAttempt] = useState(0) const [sceneReadyKey, setSceneReadyKey] = useState(0) const [isViewerSceneReady, setIsViewerSceneReady] = useState(false) const [previewStageMode, setPreviewStageMode] = useState('3d') @@ -1296,6 +1301,7 @@ function EditorContent({ async function load() { isLoadingSceneRef.current = true + setSceneLoadError(null) setHasLoadedInitialScene(false) setIsViewerSceneReady(false) setIsSceneLoading(true) @@ -1304,6 +1310,7 @@ function EditorContent({ // Session groups are not scene-graph state — clear on every load/switch. useSessionGroups.getState().clearGroups() + let failed = false try { const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() if (!cancelled) { @@ -1311,19 +1318,23 @@ function EditorContent({ setIsViewerSceneReady(false) setSceneReadyKey((key) => key + 1) } - } catch { + } catch (error) { + // Leave the store unloaded and the autosave loop in its loading + // state: nothing may be written until a load actually succeeds. + failed = true if (!cancelled) { - applySceneGraphToEditor(null) - setIsViewerSceneReady(false) - setSceneReadyKey((key) => key + 1) + console.error('[editor] scene load failed', error) + setSceneLoadError(error ?? new Error('Scene load failed')) } } finally { if (!cancelled) { setIsSceneLoading(false) - setHasLoadedInitialScene(true) - requestAnimationFrame(() => { - isLoadingSceneRef.current = false - }) + if (!failed) { + setHasLoadedInitialScene(true) + requestAnimationFrame(() => { + isLoadingSceneRef.current = false + }) + } } } } @@ -1333,7 +1344,11 @@ function EditorContent({ return () => { cancelled = true } - }, [onLoad, isLoadingSceneRef]) + }, [onLoad, isLoadingSceneRef, sceneLoadAttempt]) + + const retrySceneLoad = useCallback(() => { + setSceneLoadAttempt((attempt) => attempt + 1) + }, []) // Apply preview scene when version preview mode changes useEffect(() => { @@ -1522,7 +1537,11 @@ function EditorContent({ {visibleLoader && (
- + {sceneLoadError ? ( + + ) : ( + + )}
)} @@ -1598,7 +1617,11 @@ function EditorContent({ {visibleLoader && (
- + {sceneLoadError ? ( + + ) : ( + + )}
)} diff --git a/packages/editor/src/components/ui/scene-loader.tsx b/packages/editor/src/components/ui/scene-loader.tsx index db141df915..73bc4d8c5c 100644 --- a/packages/editor/src/components/ui/scene-loader.tsx +++ b/packages/editor/src/components/ui/scene-loader.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { cn } from '../../lib/utils' +import { Button } from './primitives/button' const LOADERS = [ 'pascal-loader-1', @@ -36,3 +37,37 @@ export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) ) } + +interface SceneLoadFailedProps { + className?: string + onRetry: () => void +} + +/** + * Replaces the loader when the host could not deliver the scene. Rendered + * INSTEAD of falling back to an empty default scene: a session that shows + * scaffold nodes after a failed load autosaves that scaffold over the real + * project (prod scene-wipe class, 2026-09-02). + */ +export function SceneLoadFailed({ className, onRetry }: SceneLoadFailedProps) { + return ( +
+
+

This project couldn't be loaded

+

+ Nothing was changed. Check your connection and try again. +

+
+ +
+ ) +}