Commmerce Editor
Publish
One toolbar button — Publish — locks the current draft, marks every block version as live, rebuilds the GCS bundle, and immediately auto-forks a fresh draft so editing never stops. Pending changes are auto-saved first so the user never has to remember.
Overview
Publishing flips the current app_version (Dev Portal) or store_theme_version (Store Editor) from 'draft' to 'publish'. Once published, the version becomes immutable — every block_version it owns is locked, the GCS bundle is rebuilt against the currently-active blocks, and the editor automatically forks a new 'draft' version cloned from this one so the user can keep iterating without ever editing a live row.
In Dev Portal the button lives next to Save on the right of the second toolbar and reads Publish Theme / Publish Page / Publish Component depending on app_type. On a version that is already published, the button is replaced with a green <Tag color="success">Published</Tag>. In the Store Editor the PublishButton component currently renders as an early-return empty fragment — publishing is performed by elevating a draft theme to live via the Set live action in the theme list rather than from the editor toolbar.
How it works
handlePublish is defined inside dev/editor/DevEditor.tsx (Dev Portal) and theme-editor/editor/StoreEditor.tsx (Store Editor) and runs the same three-step sequence:
- Auto-save pending changes — reads the editor state directly via
useCommmerceEditor.getState(); ifhasUnsavedChangesis true, builds the same payloadSaveBlockwould build and sends it through the save flow. If the save fails, the publish is aborted and the error is surfaced viauseMessageStore. - Publish the version — invokes the publish flow for the active project (Dev Portal or Store Editor). The backend transitions the version row to
'publish', promotes oneblock_versionperblock_idto'active', marks the rest as'published', and rebuildsbundle_datain GCS. - Auto-fork a new draft — Dev Portal calls
createNewAppVersion, Store Editor'spublishStoreThemeclones in the same SQL transaction. Either way the response carries the newapp_version_code/store_theme_version_code, the firstblock_id, and itsblock_version_code. The editor then navigates to the new URL — Dev Portal usesnavigate(..., { replace: true })(soft), Store Editor useswindow.location.href(hard reload) — so the editor remounts on a fresh draft.
Server-side, the Dev Portal flow in backend/src/controllers/api/dev-portal/publish.ts handles three distinct controllers: publishAppVersion (flip + bundle rebuild), createNewAppVersion (clone the published version into a new draft and its blocks into fresh block_version rows with new codes and remapped parent IDs), and activateBlockVersion (manually pick which version of a block is the live one inside a published app_version).
The bundle rebuild step in publishAppVersion walks the app_block_mapping_tb rows, picks the 'active' version for each block_id, and writes an optimized IOptimizedBundleData blob to ${app_slug}/v${version_code}/bundle_data. The URL is then stored on the app_version_tb row. This is the JSON the SSR layer reads to assemble live HTML — public preview would otherwise render stale content if the user had manually swapped active block versions before publishing.
Auto-version-on-open handles the case where the user navigates directly to a published version's URL: DevEditor's mount effect detects app_version_status === "publish", fires createNewAppVersion silently, and redirects to the new draft via navigate(..., { replace: true }) — the user never sees a read-only published editor.
Key features
- Auto-save before publish — the publish button never needs to be paired with a manual Save.
handlePublishreadshasUnsavedChangesfrom the store and runs an inline save against the existing block-save endpoint. USE CASE: a content writer makes a typo fix and clicks Publish without thinking about Ctrl+S — the change still ships. - Atomic version flip —
publishAppVersionWithActiveFallbackensures everyblock_idin thisapp_versionhas exactly one'active'sibling before theapp_versionitself transitions to'publish'. If the dev never manually picked an active version, the highest-code draft wins by fallback. USE CASE: prevents publishing a version with two competing "active" Home blocks because the dev forgot to pick. - Bundle rebuild — every publish regenerates
bundle_datain GCS from the resolved active mappings. USE CASE: making sure SSR reads from the same version the dev chose after a manual "Make live" swap, not the stale bundle baked at clone time. - Auto-fork to next draft — Dev Portal's
createNewAppVersionand Store Editor'spublishAndCloneStoreThemeVersionboth run in one server roundtrip: clone the active block versions into newblock_versionrows withMAX(code) + 1, remap parent references via anoldToNewBlockVersionIdMap, transformbundle_datastructure, upload to GCS, and return the navigation coordinates. USE CASE: after Publish, the editor lands on v4 — the dev can immediately keep editing without manually creating a draft. - Idempotent re-publish (Store Editor) — calling
publishStoreThemewhen there is no draft falls back to the latest version regardless of status, allowing a no-op re-publish to refresh the bundle. USE CASE: a script-driven CI / migration that re-runs publish to regenerate stale bundles. - Already-published guard (Dev Portal) —
publishAppVersionhard-refuses a409if the version is already'publish'("This version is already published"). The UI prevents this by showing thePublishedtag instead of the button. USE CASE: protects against a double-click that would otherwise create two forks back-to-back. - Read-only fallback — if the auto-fork after publish fails,
DevEditorsurfaces the error, leaves the editor insetReadOnly(true)withapp_version_status = "publish", and the user can manually trigger the fork via the publish button's fallback path. USE CASE: GCS upload flake during fork doesn't leave the dev stuck — they get a button to retry. - Preview opens the published URL — the toolbar's Preview button (next to Publish) opens
${api_base_url}/preview/${app_slug}/v/${version_code}/${block_id}/${block_version_code}for Dev Portal blocks, giving a deterministic view of exactly what the published bundle renders. USE CASE: pre-publish sanity check that the SSR output matches the canvas. - Active vs published differentiation — only the picked
'active'sibling perblock_idfeeds the SSR bundle. Historical'published'siblings stay as immutable archival rows in case the dev wants to revert. USE CASE: rolling back to last week's Home version with one click in the version picker.
3-step state-store flow
handlePublish orchestrates these three reads/writes against the editor's Zustand stores. Each step is fault-isolated: a failure in step 1 aborts step 2, and a failure in step 3 leaves the user with a published-but-no-draft fallback.
1. Auto-saveuseCommmerceEditor.getState().hasUnsavedChanges, children, jsCode, block_*setHasUnsavedChanges(false), setInitializing("Saving changes...")Persists pending edits so publish never ships stale data. USE CASE: handles the user who never pressed Ctrl+S.2. Publishapp_id, app_version_code (or storeThemeId)setInitializing("Publishing..."), setPublishing(true)Server-side: status flip + bundle rebuild. Frontend just waits. USE CASE: typically < 2 s for a small theme, longer for large bundles.3. Auto-forkpublish response data (new_version_code, block_id, block_version_code)navigate(...) / window.location.hrefLands the editor on the new draft so the next edit doesn't try to mutate the locked version. USE CASE: maintains "always edit a draft" invariant.Workflows
1. Standard publish (Dev Portal)
- User clicks Publish Theme in the toolbar.
setPublishing(true)flips the button into its loading state. handlePublishreadsuseCommmerceEditor.getState().hasUnsavedChanges. If true, builds a save payload from the live store state and runs the inline save.- If the save fails, the publish aborts and the server message is shown verbatim.
- The Dev Portal publish flow runs. Server promotes one
block_versionperblock_idto'active'viapublishAppVersionWithActiveFallback, then transitions theapp_versionrow to'publish', then rebuildsbundle_data. - On success,
createNewAppVersionruns to fork a new draft. - The editor navigates via
navigate(.../<new_code>/<new_block_id>/<new_block_version_code>, { replace: true })— same component instance, new URL, fresh draft scope.
2. Open a published version directly
- User pastes a URL pointing at
app_version_status === "publish". DevEditor's mount effect fetches block data and detects the publish status.- Without showing the editor, it fires
createNewAppVersionsilently. navigate(...new URL, { replace: true })redirects to the new draft so the back button skips the published URL.- If the fork fails: the editor renders with
setReadOnly(true)and the user can retry via the publish button.
3. Promote a specific block version
- Inside the dev's routing UI, the user picks which
block_versionshould be active for a givenblock_id. - The block-activate flow is invoked with the chosen
block_version_code. activateBlockVersiondemotes any previously-active sibling to'published'(if the parentapp_versionis published) or'draft'(otherwise), then marks the new one as'active'.- The next publish will pick up this selection and bake it into
bundle_data.
4. Store theme publish
- Store Editor's
handlePublishauto-saves the current block (same path as Dev Portal). - The Store Editor publish flow is invoked for the current theme.
- Server calls
publishAndCloneStoreThemeVersion— locks the latest draft, clones into a new draft in one transaction, returns the new theme version code plus the first block coords. - Editor does
window.location.href = .../<theme_id>/<new_version_code>/...— full reload so all editor state resets cleanly. - To actually serve the new theme to shoppers, the user separately uses the Set live action from the theme list, which routes through the dedicated set-live flow.
5. Roll back to a previous block version
- A regression is reported in Home v4. The dev wants to flip live traffic back to v3 while they investigate.
- In the version-management UI, pick v3 of
block_id = 108. - The block-activate flow runs for the chosen version.
- Server marks v4 as
'published'(demoted) and v3 as'active'. - Trigger an idempotent re-publish (or the next publish) to rebuild the GCS bundle so SSR picks up v3 immediately.
6. Publish + Set live (Store Editor)
- Merchant finishes editing their draft theme in the Store Editor.
- They navigate back to the theme list (the toolbar's PublishButton is currently empty in Store Editor).
- From the theme list they pick Publish — server publishes + clones to new draft.
- From the same list they pick Set live on the now-published version —
setLiveThemeatomically demotes the previous live theme and promotes this one. - The storefront begins serving the new theme on the next SSR cycle.
Keyboard shortcuts
Ctrl+S / Cmd+SSave before publishOptional — handlePublish auto-saves anyway. Useful if you want to verify the save succeeds first.Tips & gotchas
- Publishing is one-way for the source version. Once flipped to
'publish', thatapp_versionis immutable. Edits land on the auto-forked next draft — there is no "un-publish". - The auto-fork is silent. If
createNewAppVersionfails (network, GCS upload error), the editor surfaces an error toast and leaves the user on the now-published, read-only version. They must retry to create the next draft. - Bundle rebuild only fires when there are active mappings. If a version somehow has zero blocks, the bundle write is skipped — the SSR layer will fall back to whatever was there previously. This shouldn't happen in practice because forks always carry over the active block selection.
- Store Editor publish does not flip the live theme. Publishing a store theme version creates the next draft but doesn't promote it to the storefront's live theme. Use the separate Set live action for that, which atomically demotes the previous live theme.
- The Store Editor's
PublishButtoncurrently returns an empty fragment via an earlyreturn <></>. Publishing happens off-canvas through the theme management UI. Dev Portal is the active publish surface inside the editor toolbar. - Versioning is per-block, not just per-app. Each
block_idtracks its ownblock_version_codesequence;MAX + 1for that block determines the next fork's code. This is whycreateNewAppVersioncarries anoldToNewBlockVersionIdMap— to remap parent references when cloning a nested block tree. - Active vs published vs draft — these three statuses on
block_version_tbare different. Only one sibling perblock_idcan be'active'inside a givenapp_version; the rest of the published siblings stay as historical'published'rows. The bundle always reads from the'active'one. - Pre-publish save aborts the whole flow. If the auto-save fails (e.g. the version was marked published in another tab between the save call and the publish call), the publish aborts cleanly — no partial state is left on the server.
- Bundle URL is updated last. The controller writes the new GCS URL into
app_version_tb.bundle_dataonly after the upload returns a non-null URL — partial uploads don't poison the version row. - Re-publish in Store Editor is idempotent. If there is no current draft, the controller publishes the latest version regardless of status — useful for regenerating the bundle after a schema change or manual data fix.