Commmerce Editor
Save and Save as
The editor toolbar exposes a single Save dropdown that commits the current block to MySQL plus Google Cloud Storage, or forks a brand-new block_version via Save as.... Drafts overwrite cleanly; published versions are immutable, so the editor silently coerces a Ctrl+S into a fork to keep your work safe.
Overview
Save persists the current editor canvas — sections, elements, attributes, styles, inline jsCode, and block metadata — to the backend. There are two modes, both surfaced by the same Dropdown.Button on the right side of the toolbar.
- Save (overwrite) — updates the current draft
block_versionin place. Allowed only when the version's status is'draft'(Dev Portal) or'draft'/'active'(Store Editor). Bound toCtrl+S/Cmd+S. - Save as... (new version) — clones the block into a new
block_version_code = MAX + 1row, uploads a fresh JSON blob to GCS, then redirects the editor to the new URL viawindow.location.href(full reload so history/clipboard reset). Bound toCtrl+Shift+S/Cmd+Shift+S.
Independent of either, the in-memory canvas state is mirrored to IndexedDB on every store mutation (keyed per dev-app / store-theme / block via deriveEditorScope) so a tab refresh restores unsaved work without a server round-trip. The history slice tracks every change as a delta — Save itself does not consume history, it only flips hasUnsavedChanges back to false so the green dot on the toolbar disappears.
How it works
The dropdown lives in theme-editor/editor/StoreEditor.tsx (Store Editor) and dev/editor/DevEditor.tsx (Dev Portal). Both pass a <SaveBlock /> component into CommmerceEditor via the customUIElements prop, which the editor's toolbar (common/components/commmerce-editor/control/index.tsx) renders on the right side of the second bar next to Publish.
When the user clicks Save or presses Ctrl+S, handleSaveBlock("overwrite") runs. It safety-coerces the mode to "new_version" if the current block_version_status is not a draft (Dev Portal blocks active/published versions; Store Editor blocks only published), then builds the payload, calls the backend, and clears the hasUnsavedChanges flag on the createHistorySlice store.
The save payload always carries the full children tree (sections and nested elements as BaseNode[]), inline jsCode, and optional block metadata (block_name, block_slug, block_version_slug, block_image, block_version_settings). Global blocks referenced inside the tree are stripped to reference stubs by stripGlobalBlockData in backend/src/controllers/api/dev-portal/blocks.ts — the parent only stores wrapper metadata while each global block lives at its own block_id route.
Server-side, the controller validates the URL params and the block-mapping row, then either UPDATEs block_version_tb + overwrites the GCS blob (overwrite mode) or INSERTs a new row, uploads to a new path ${app_slug}/v${app_version_code}/blocks/block_${block_id}_v${next_code}, and refreshes the app_block_mapping_tb entry (new_version mode). Any dirty global blocks tracked in editedBlocks / dirtyBlockKeys on the store are saved in sequence after the primary save.
On success, the editor shows a green message ("Block saved successfully") via useMessageStore, flips setHasUnsavedChanges(false), and clears the dirty global-block map. On failure, it surfaces the server-side error message verbatim — typically "Cannot overwrite ... status is 'active'" when the version is locked.
Key features
- Two-mode dropdown — single button with primary action (overwrite) and split-menu fork (new_version). The menu auto-disables "Save (Ctrl+S)" when the version is non-draft so the only safe path is a fork. USE CASE: a designer iterating on a draft never needs to think about modes; a developer revisiting a published v2 sees Save disabled and is funneled into v3.
- Status-aware coercion — Ctrl+S on a published/active version silently rewrites the request to
mode: "new_version"so the user never hits the backend validation error. USE CASE: muscle-memory Ctrl+S on a live page still saves work, just as a brand-new v3 draft instead of mutating production. - Delta-based history — every store mutation is wrapped in
saveToHistory()oncreateHistorySlice; only the diff between snapshots is stored. History caps atmaxHistorySize = 50. Save does not consume history — it just flipshasUnsavedChanges. USE CASE: undoing the last 50 edits after a Save still works because Save doesn't clear the history stack. - Per-tab IndexedDB scope —
deriveEditorScopeincommon/components/commmerce-editor/store/storage/deriveScope.tsbuilds a unique key per(app_id, app_version_code, block_id, block_version_code)tuple. Multiple tabs editing different blocks never clobber each other. Restore happens automatically viauseCommmerceEditor.persist.rehydrate()on mount. USE CASE: an author keeps Home, Product Page, and Footer open in three tabs — each tab restores its own unsaved work on refresh without leaking. - Global block tracking — when the user edits a referenced global block in-place, its full tree is captured in
editedBlocksand the key pushed ontodirtyBlockKeys. Save iterates overdirtyBlockKeysafter the main save and writes each asmode: "overwrite". USE CASE: tweaking the nav bar inside the Home page editor and pressing Save persists both the Home page AND the global Header in one click. - GCS round-trip — block JSON is uploaded by
uploadAppDataAsJsoninbackend/src/packages/GoogleStorage.ts; only the resulting URL is stored inblock_version_tb.block_data. USE CASE: a 500 KB section tree never bloats MySQL; the database stores one URL, the JSON lives on GCS where the SSR layer can stream it. - Settings persistence is separate — Global Settings (typography, colors, breakpoints, SEO, layout) go through their own dedicated save flow and are no longer bundled into the block-save payload. USE CASE: changing a brand color saves only the settings row, not the entire 500 KB page tree.
- Auto-save before publish —
handlePublishinDevEditor/StoreEditorcheckshasUnsavedChangesand runs an inline save against the samesaveBlock/saveStoreBlockendpoint before invoking the publish call. USE CASE: clicking Publish on dirty edits ships the in-memory tree, not the last saved one — the author never has to remember to Save first. - Locked breakpoints / settings cache — the toolbar additionally mirrors the breakpoint config and editor settings to
localStorage(keysbreakpoints-theme-*,editor-settings-*) so a hard reload on a slow connection still shows the last-known good values while the API call completes. USE CASE: on a flaky network the editor doesn't flash default breakpoints for 2 seconds before the server response arrives. - Server-side child mapping sync —
extractAllChildBlockRefsscans the new block_data for nested block references and the controller adds/removesapp_block_mapping_tbrows so the bundle resolver sees the right children. USE CASE: dragging a global Footer into the page and pressing Save automatically registers the parent-child mapping needed for SSR composition.
State store methods
The editor's save flow touches two slices of the main useCommmerceEditor store. Both expose actions the toolbar wires up directly.
createHistorySlice.saveToHistory()() => voidbefore every mutationComputes deltas between the previous snapshot and current state, pushes a HistoryEntry, flips hasUnsavedChanges to true. USE CASE: every drag / type / style change wraps in this so the diff is captured.createHistorySlice.undo()() => voidCtrl+ZApplies the entry's reverseDeltas to roll state back. USE CASE: undoing an accidental delete.createHistorySlice.redo()() => voidCtrl+Y / Ctrl+Shift+ZRe-applies the next entry's forward deltas. USE CASE: restoring an undone change after second-guessing.createHistorySlice.canUndo()() => boolean—Returns true if there is history OR a pending uncommitted snapshot. USE CASE: the Undo button reads this to decide enabled/disabled state.createHistorySlice.canRedo()() => boolean—Returns true if there is a future entry. USE CASE: gating the Redo button.createHistorySlice.clearHistory()() => voidpage swapWipes the stack but keeps the current state as the pending snapshot. USE CASE: switching between pages without leaking undo into the next page.createHistorySlice.setHasUnsavedChanges(b)(boolean) => voidfalse on successToggles the unsaved indicator. USE CASE: Save calls setHasUnsavedChanges(false) on success; logo-back navigation calls it to bypass the leave-confirm.createHistorySlice.maxHistorySizenumber = 5050Cap on history length; older entries are shift()ed off. USE CASE: prevents long sessions from ballooning memory.createResetSlice.setPageId(id)(string) => voidon page loadSets the page identifier the editor is working on. USE CASE: switching pages without remounting the whole editor.createResetSlice.createPage(...)(id, name, slug, image, type) => voidon createSeeds an empty page with one default child. USE CASE: New Page action in the page selector.createResetSlice.emptyPage()() => voidnarrow resetClears block + page properties only (children, history, selection). USE CASE: deleting a page via the page selector.createResetSlice.resetEditorState()() => voidscope swapHard reset — wipes block + page + selection + clipboard + history + dirtyBlockKeys. USE CASE: navigating between blocks within the same tab so the new IDB key's rehydrate cannot inherit stale state.createResetSlice.clearPersistedState()() => Promise<void>manualCalls del('commmerce-editor') against IndexedDB. USE CASE: a "Reset editor" debug action that fully wipes the persisted cache.Workflows
1. Quick save on a draft
- User makes edits — the store flips
hasUnsavedChanges = true. - User presses
Ctrl+Sor clicks the Save primary button. handleSaveBlock("overwrite")builds the payload fromuseCommmerceEditorstore state.- The Dev Portal save flow / Store Editor save flow sends the payload to the backend.
- Server UPDATEs the existing
block_version_tbrow and overwrites the GCS blob at the same URL. - Any dirty global blocks (
dirtyBlockKeys) are saved in sequence as separate overwrites. - Editor shows a success message and clears
hasUnsavedChanges.
2. Save as new version (fork)
- User opens the dropdown menu and picks Save as... (Ctrl+Shift+S).
handleSaveBlock("new_version")sends the same payload withmode: "new_version".- Server computes
nextCode = MAX(block_version_code) + 1for thisblock_id. - JSON is uploaded to a fresh GCS path keyed by the new code; a new
block_version_tbrow is INSERTed as'draft'. - A new
app_block_mapping_tbentry replaces the previous mapping inside the currentapp_version. - If the previous version was the only draft, the controller auto-promotes it to
'active'so the live storefront keeps rendering the proven version. - Editor does
window.location.href = .../<new_block_version_code>— full reload so the store, history, clipboard, and per-tab IndexedDB scope all reset to the new fork.
3. Auto-save coercion on a non-draft
- User opens a version where
block_version_status === 'active'or'published'. - Primary Save button stays disabled (
overwriteDisallowedis true); the menu shows only Save as.... - If the user still presses
Ctrl+S,handleSaveBlockcoerces the mode to"new_version"before sending — server never sees an invalid overwrite request. - The edit lands on a brand-new fork so the live block keeps serving traffic untouched.
4. Restore unsaved work after refresh
- User edits the canvas — every store mutation triggers Zustand's persist middleware.
- State is serialized to IndexedDB under a key built by
deriveEditorScope. - User refreshes the tab.
- On mount,
StoreEditor/DevEditorcallsuseCommmerceEditor.persist.rehydrate()before the API fetch — unsaved work reappears immediately. - The API fetch runs in parallel; if the server has newer content for the same scope, it overwrites the rehydrated state.
5. Save with dirty global blocks
- While editing the Home page, the user opens the embedded Header (a global block) inline.
- Each Header edit calls
setEditedBlock(tabKey, payload)on the store, which pushes the key ontodirtyBlockKeys. - User presses Ctrl+S. The primary save runs first against the Home page block.
- On success, the SaveBlock component iterates
dirtyBlockKeysand POSTs an additionaloverwritesave per dirty block. - If all global saves succeed:
"Page and 2 global block(s) saved successfully". Mixed results:"Page saved. 2 global block(s) saved, 1 failed.". clearEditedBlocks()empties the dirty map for the next round of edits.
6. Save block-level settings (SEO)
- User opens the Page settings dialog from the toolbar 3-dot.
- Edits the SEO meta title and description in the dialog's form.
- Clicking the dialog's Save calls
saveBlockSettingsrather thansaveBlock— only theblock_version_settingscolumn is updated. - The settings JSON is uploaded to a dedicated GCS path
${app_slug}/v${ver}/blocks/block_${id}_v${code}_settingsand the URL is stored onblock_version_tb.
Keyboard shortcuts
Ctrl+S / Cmd+SSave (overwrite)Coerced to new_version on non-draft.Ctrl+Shift+S / Cmd+Shift+SSave as new versionForks regardless of current status.Ctrl+Z / Cmd+ZUndoWalks the delta history backward.Ctrl+Y / Cmd+Shift+ZRedoWalks forward through deltas.All shortcut handlers live in control/index.tsx (undo/redo) and inside each project's SaveBlock component (save). They no-op when saving === true or when there are no unsaved changes.
Tips & gotchas
- The primary Save button is gated on
hasUnsavedChanges. The dropdown trigger is always interactive, but the primary click is disabled when there's nothing to save. This is why landing on a freshly-loaded editor shows a greyed-out button. - Save as... always navigates away. Because the backend mints a new
block_version_code, the editor does a hard reload to the new URL. Any open dialogs, expanded sidebar panels, and the undo history all reset on the new fork. - Global Settings have their own save path through the Settings dialog and the
useSaveSettingshook. Editing typography and pressing Ctrl+S in the canvas does not save those — open the Settings dialog and click its Save button, which uses the dedicated settings save flow and clears the localStorage draft cache. - Server overwrites are status-strict. Dev Portal will refuse to overwrite anything that is not
'draft'; Store Editor refuses only'published'— Store's'active'blocks are the editing surface. The frontend mirrors this inoverwriteDisallowed. - Dirty global blocks are saved sequentially, not atomically. A successful primary save can be followed by a partial global-block save failure ("Page saved. 2 global block(s) saved, 1 failed."). Check the warning message after a multi-block save.
- Per-tab IndexedDB is keyed by URL. Hot-swapping between blocks within the same tab triggers
resetEditorState()followed by a re-hydrate against the new key — this is intentional and prevents one block's unsaved tree from leaking into another. - Hardware reload during an in-flight save can leave the canvas behind. The IndexedDB write happens on every mutation, so the canvas state survives. But if the server save failed mid-flight, you'll see your edits restored locally and a stale
block_dataURL on the server until you press Save again. - New-version mode auto-promotes the previous draft to active if no version of this block was already active in the current app_version. This guarantees the storefront keeps rendering a known-good version while the user iterates on the new draft.
- Slug changes UPDATE the routing table separately. When
block_slugis included in the payload, the controller does an extra UPDATE onapp_page_routes— so the route change goes live the next time the bundle is rebuilt, not retroactively against existing visits.