webapp pagescene: per-token scene cache with original snapshot

Reworks scene-cache-store from a single playground-scoped string to a
Record<string, { original?, current }> 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 <noreply@anthropic.com>
This commit is contained in:
Tanish Manku
2026-05-13 15:42:52 -07:00
parent a09f1c5cfd
commit 94fdb4ac2f
3 changed files with 129 additions and 25 deletions
@@ -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 (
<div
@@ -1,25 +1,95 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Holds the serialized 3D editor scene JSON so the user's in-progress
// editor state survives navigating away to other webapp pages (e.g.
// browsing /gallery, kicking off a /create-image generation) and
// coming back. Mirrors how Tauri's useTabStore caches per-tab JSON
// across tab switches — sessionStorage is the webapp equivalent: it
// covers SPA navigation AND tab reloads, but doesn't bleed into other
// browser sessions or tabs.
interface SceneCacheStore {
cacheJsonString: string | undefined;
setCacheJsonString: (json: string | undefined) => 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<string, SceneCacheEntry>;
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<SceneCacheStore>()(
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",
@@ -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<unknown> => {
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();