From 94fdb4ac2fe06fc3cc587ace7ff91d09c570cbae Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 13 May 2026 15:42:52 -0700 Subject: [PATCH] webapp pagescene: per-token scene cache with original snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks scene-cache-store from a single playground-scoped string to a Record keyed by sceneToken (with a PLAYGROUND_KEY constant for the no-token slot). Visiting /edit-3d/A, editing, jumping to /edit-3d/B, then coming back to /edit-3d/A restores A's in-progress state — the cache hit short-circuits the server fetch inside Editor.initialize. The adapter's loadSceneViaApi seeds `original` on first server fetch so the lib's "Reset to original" menu item has the snapshot it needs. resetToOriginal pulls that snapshot, hands it to editor.applyJson (which replaces the scene and clears undo history), and mirrors `current = original` so a pre-serialization reload can't drag the discarded edits back. Co-Authored-By: Claude Opus 4.7 --- .../src/pages/pagescene/pagescene.tsx | 22 ++-- .../src/pages/pagescene/scene-cache-store.ts | 100 +++++++++++++++--- .../src/pages/pagescene/web-adapter.tsx | 32 +++++- 3 files changed, 129 insertions(+), 25 deletions(-) diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx index 8629c354c6..8dd17223e8 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx @@ -108,15 +108,19 @@ function PageSceneEditor() { // onSceneSerialized callback on unmount. Mirrors how Tauri stashes // per-tab scene JSON via useTabStore. // - // Scoped to the no-token playground: when sceneToken is in the URL - // the server-loaded scene is the source of truth, so we neither - // restore from cache (would override the shared scene) nor write the - // session back into it. - const setCacheJsonString = useSceneCacheStore((s) => s.setCacheJsonString); - const cacheJsonString = sceneToken - ? undefined - : useSceneCacheStore.getState().cacheJsonString; - const onSceneSerialized = sceneToken ? undefined : setCacheJsonString; + // Keyed by sceneToken so each scene (and the playground) gets its own + // slot — visiting /edit-3d/A, /edit-3d/B, /edit-3d/A round-trips back + // to A's in-progress state rather than B's or the server's. Cache + // hits short-circuit the lib's loadScene path (Editor.initialize + // prefers cacheJsonString over the sceneToken). + const setCurrent = useSceneCacheStore((s) => s.setCurrent); + const cacheJsonString = useSceneCacheStore + .getState() + .getEntry(sceneToken)?.current; + const onSceneSerialized = useCallback( + (json: string) => setCurrent(sceneToken, json), + [sceneToken, setCurrent], + ); return (
void; - clear: () => void; +// Per-token scratch cache for the 3D editor. +// +// The lib gives the host two hooks: a `cacheJsonString` it'll restore +// from on mount (priority over re-fetching a sceneToken), and an +// `onSceneSerialized` callback it fires whenever the editor has fresh +// JSON to stash. We pair those with sessionStorage so SPA navigation, +// tab reloads, and Cmd+R survive — but a fresh browser session starts +// clean (no cross-user bleed). +// +// Each cache entry holds both the snapshot the host loaded from the +// server (`original`) and the user's latest in-progress state +// (`current`). The destructive "Reset to original" menu item is +// available iff `original` is defined for the active scene, and the +// reset flow just reads `original` and re-applies it via +// `editor.applyJson`. +// +// Playground sessions (no sceneToken in the URL) key on PLAYGROUND_KEY +// and don't track an `original` — there's no "server version" to revert +// to. + +export const PLAYGROUND_KEY = "__playground__"; + +export interface SceneCacheEntry { + original?: string; + current: string; } +interface SceneCacheStore { + entries: Record; + getEntry: (token: string | undefined) => SceneCacheEntry | undefined; + setOriginal: (token: string, original: string) => void; + setCurrent: (token: string | undefined, current: string) => void; + resetCurrentToOriginal: (token: string) => void; + clearToken: (token: string | undefined) => void; +} + +const keyOf = (token: string | undefined) => token ?? PLAYGROUND_KEY; + export const useSceneCacheStore = create()( persist( - (set) => ({ - cacheJsonString: undefined, - setCacheJsonString: (cacheJsonString) => set({ cacheJsonString }), - clear: () => set({ cacheJsonString: undefined }), + (set, get) => ({ + entries: {}, + getEntry: (token) => get().entries[keyOf(token)], + setOriginal: (token, original) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + return { + entries: { + ...state.entries, + [key]: { + ...existing, + original, + current: existing?.current ?? original, + }, + }, + }; + }), + setCurrent: (token, current) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + return { + entries: { + ...state.entries, + [key]: { ...existing, current }, + }, + }; + }), + resetCurrentToOriginal: (token) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + if (!existing?.original) return state; + return { + entries: { + ...state.entries, + [key]: { ...existing, current: existing.original }, + }, + }; + }), + clearToken: (token) => + set((state) => { + const key = keyOf(token); + if (!(key in state.entries)) return state; + const next = { ...state.entries }; + delete next[key]; + return { entries: next }; + }), }), { name: "artcraft-webapp-pagescene-cache", diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx index 0ca2dbb7de..1d517e9c06 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx @@ -19,7 +19,7 @@ import { UsersApi, } from "@storyteller/api"; import type { PageSceneAdapter } from "@storyteller/ui-pagescene"; -import { ToastTypes } from "@storyteller/ui-pagescene"; +import { getActiveEditor, ToastTypes } from "@storyteller/ui-pagescene"; import { UploadModal3D, UploadModalImage, @@ -28,6 +28,7 @@ import { } from "@storyteller/ui-upload-modal"; import { showToast } from "../../components/toast/toast"; import { invalidateSession } from "../../lib/session"; +import { useSceneCacheStore } from "./scene-cache-store"; const apiHost = () => StorytellerApiHostStore.getInstance().getApiSchemeAndHost(); @@ -90,6 +91,16 @@ const loadSceneViaApi = async (token: string): Promise => { const fileResp = await fetch(cdnUrl); if (!fileResp.ok) throw new Error(`Scene fetch HTTP ${fileResp.status}`); const text = await fileResp.text(); + + // Seed the scene cache's `original` snapshot on first fetch so the + // destructive "Reset to original" menu item has something to revert + // to without going back over the network. Cache-hit reloads skip + // this path entirely (Editor.initialize uses cacheJsonString). + const cache = useSceneCacheStore.getState(); + if (!cache.getEntry(token)?.original) { + cache.setOriginal(token, text); + } + return JSON.parse(text); }; @@ -269,6 +280,25 @@ export const useWebAppPageSceneAdapter = ( promptSignup, + // Powers the lib's "Reset to original" menu item. The cache has + // the server's snapshot we seeded on first load; pipe it back + // through `Editor.applyJson` (which clears undo history and + // re-renders) and mirror `current = original` in the cache so a + // pre-serialization reload doesn't drag the edits back. + resetToOriginal: async () => { + if (!initialSceneToken) return; + const editor = getActiveEditor(); + if (!editor) return; + const entry = useSceneCacheStore + .getState() + .getEntry(initialSceneToken); + if (!entry?.original) return; + await editor.applyJson(entry.original); + useSceneCacheStore + .getState() + .resetCurrentToOriginal(initialSceneToken); + }, + performLogout: async () => { try { await new UsersApi().Logout();