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_version in place. Allowed only when the version's status is 'draft' (Dev Portal) or 'draft'/'active' (Store Editor). Bound to Ctrl+S / Cmd+S.
  • Save as... (new version) — clones the block into a new block_version_code = MAX + 1 row, uploads a fresh JSON blob to GCS, then redirects the editor to the new URL via window.location.href (full reload so history/clipboard reset). Bound to Ctrl+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() on createHistorySlice; only the diff between snapshots is stored. History caps at maxHistorySize = 50. Save does not consume history — it just flips hasUnsavedChanges. USE CASE: undoing the last 50 edits after a Save still works because Save doesn't clear the history stack.
  • Per-tab IndexedDB scopederiveEditorScope in common/components/commmerce-editor/store/storage/deriveScope.ts builds 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 via useCommmerceEditor.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 editedBlocks and the key pushed onto dirtyBlockKeys. Save iterates over dirtyBlockKeys after the main save and writes each as mode: "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 uploadAppDataAsJson in backend/src/packages/GoogleStorage.ts; only the resulting URL is stored in block_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 publishhandlePublish in DevEditor / StoreEditor checks hasUnsavedChanges and runs an inline save against the same saveBlock / saveStoreBlock endpoint 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 (keys breakpoints-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 syncextractAllChildBlockRefs scans the new block_data for nested block references and the controller adds/removes app_block_mapping_tb rows 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.

Slice / methodTypeDefault / whenPurpose / use case
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()() => booleanReturns true if there is history OR a pending uncommitted snapshot. USE CASE: the Undo button reads this to decide enabled/disabled state.
createHistorySlice.canRedo()() => booleanReturns 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

  1. User makes edits — the store flips hasUnsavedChanges = true.
  2. User presses Ctrl+S or clicks the Save primary button.
  3. handleSaveBlock("overwrite") builds the payload from useCommmerceEditor store state.
  4. The Dev Portal save flow / Store Editor save flow sends the payload to the backend.
  5. Server UPDATEs the existing block_version_tb row and overwrites the GCS blob at the same URL.
  6. Any dirty global blocks (dirtyBlockKeys) are saved in sequence as separate overwrites.
  7. Editor shows a success message and clears hasUnsavedChanges.

2. Save as new version (fork)

  1. User opens the dropdown menu and picks Save as... (Ctrl+Shift+S).
  2. handleSaveBlock("new_version") sends the same payload with mode: "new_version".
  3. Server computes nextCode = MAX(block_version_code) + 1 for this block_id.
  4. JSON is uploaded to a fresh GCS path keyed by the new code; a new block_version_tb row is INSERTed as 'draft'.
  5. A new app_block_mapping_tb entry replaces the previous mapping inside the current app_version.
  6. If the previous version was the only draft, the controller auto-promotes it to 'active' so the live storefront keeps rendering the proven version.
  7. 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

  1. User opens a version where block_version_status === 'active' or 'published'.
  2. Primary Save button stays disabled (overwriteDisallowed is true); the menu shows only Save as....
  3. If the user still presses Ctrl+S, handleSaveBlock coerces the mode to "new_version" before sending — server never sees an invalid overwrite request.
  4. The edit lands on a brand-new fork so the live block keeps serving traffic untouched.

4. Restore unsaved work after refresh

  1. User edits the canvas — every store mutation triggers Zustand's persist middleware.
  2. State is serialized to IndexedDB under a key built by deriveEditorScope.
  3. User refreshes the tab.
  4. On mount, StoreEditor / DevEditor calls useCommmerceEditor.persist.rehydrate() before the API fetch — unsaved work reappears immediately.
  5. 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

  1. While editing the Home page, the user opens the embedded Header (a global block) inline.
  2. Each Header edit calls setEditedBlock(tabKey, payload) on the store, which pushes the key onto dirtyBlockKeys.
  3. User presses Ctrl+S. The primary save runs first against the Home page block.
  4. On success, the SaveBlock component iterates dirtyBlockKeys and POSTs an additional overwrite save per dirty block.
  5. If all global saves succeed: "Page and 2 global block(s) saved successfully". Mixed results: "Page saved. 2 global block(s) saved, 1 failed.".
  6. clearEditedBlocks() empties the dirty map for the next round of edits.

6. Save block-level settings (SEO)

  1. User opens the Page settings dialog from the toolbar 3-dot.
  2. Edits the SEO meta title and description in the dialog's form.
  3. Clicking the dialog's Save calls saveBlockSettings rather than saveBlock — only the block_version_settings column is updated.
  4. The settings JSON is uploaded to a dedicated GCS path ${app_slug}/v${ver}/blocks/block_${id}_v${code}_settings and the URL is stored on block_version_tb.

Keyboard shortcuts

ShortcutActionNotes
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 useSaveSettings hook. 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 in overwriteDisallowed.
  • 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_data URL 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_slug is included in the payload, the controller does an extra UPDATE on app_page_routes — so the route change goes live the next time the bundle is rebuilt, not retroactively against existing visits.