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:

  1. Auto-save pending changes — reads the editor state directly via useCommmerceEditor.getState(); if hasUnsavedChanges is true, builds the same payload SaveBlock would build and sends it through the save flow. If the save fails, the publish is aborted and the error is surfaced via useMessageStore.
  2. 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 one block_version per block_id to 'active', marks the rest as 'published', and rebuilds bundle_data in GCS.
  3. Auto-fork a new draft — Dev Portal calls createNewAppVersion, Store Editor's publishStoreTheme clones in the same SQL transaction. Either way the response carries the new app_version_code / store_theme_version_code, the first block_id, and its block_version_code. The editor then navigates to the new URL — Dev Portal uses navigate(..., { replace: true }) (soft), Store Editor uses window.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. handlePublish reads hasUnsavedChanges from 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 flippublishAppVersionWithActiveFallback ensures every block_id in this app_version has exactly one 'active' sibling before the app_version itself 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_data in 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 createNewAppVersion and Store Editor's publishAndCloneStoreThemeVersion both run in one server roundtrip: clone the active block versions into new block_version rows with MAX(code) + 1, remap parent references via an oldToNewBlockVersionIdMap, transform bundle_data structure, 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 publishStoreTheme when 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) — publishAppVersion hard-refuses a 409 if the version is already 'publish' ("This version is already published"). The UI prevents this by showing the Published tag 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, DevEditor surfaces the error, leaves the editor in setReadOnly(true) with app_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 per block_id feeds 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.

StepReadsWritesPurpose / use case
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)

  1. User clicks Publish Theme in the toolbar. setPublishing(true) flips the button into its loading state.
  2. handlePublish reads useCommmerceEditor.getState().hasUnsavedChanges. If true, builds a save payload from the live store state and runs the inline save.
  3. If the save fails, the publish aborts and the server message is shown verbatim.
  4. The Dev Portal publish flow runs. Server promotes one block_version per block_id to 'active' via publishAppVersionWithActiveFallback, then transitions the app_version row to 'publish', then rebuilds bundle_data.
  5. On success, createNewAppVersion runs to fork a new draft.
  6. 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

  1. User pastes a URL pointing at app_version_status === "publish".
  2. DevEditor's mount effect fetches block data and detects the publish status.
  3. Without showing the editor, it fires createNewAppVersion silently.
  4. navigate(...new URL, { replace: true }) redirects to the new draft so the back button skips the published URL.
  5. 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

  1. Inside the dev's routing UI, the user picks which block_version should be active for a given block_id.
  2. The block-activate flow is invoked with the chosen block_version_code.
  3. activateBlockVersion demotes any previously-active sibling to 'published' (if the parent app_version is published) or 'draft' (otherwise), then marks the new one as 'active'.
  4. The next publish will pick up this selection and bake it into bundle_data.

4. Store theme publish

  1. Store Editor's handlePublish auto-saves the current block (same path as Dev Portal).
  2. The Store Editor publish flow is invoked for the current theme.
  3. 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.
  4. Editor does window.location.href = .../<theme_id>/<new_version_code>/... — full reload so all editor state resets cleanly.
  5. 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

  1. A regression is reported in Home v4. The dev wants to flip live traffic back to v3 while they investigate.
  2. In the version-management UI, pick v3 of block_id = 108.
  3. The block-activate flow runs for the chosen version.
  4. Server marks v4 as 'published' (demoted) and v3 as 'active'.
  5. 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)

  1. Merchant finishes editing their draft theme in the Store Editor.
  2. They navigate back to the theme list (the toolbar's PublishButton is currently empty in Store Editor).
  3. From the theme list they pick Publish — server publishes + clones to new draft.
  4. From the same list they pick Set live on the now-published version — setLiveTheme atomically demotes the previous live theme and promotes this one.
  5. The storefront begins serving the new theme on the next SSR cycle.

Keyboard shortcuts

ShortcutActionNotes
Publish has no shortcutUse the toolbar button; the action is destructive enough that there is no key binding.
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', that app_version is immutable. Edits land on the auto-forked next draft — there is no "un-publish".
  • The auto-fork is silent. If createNewAppVersion fails (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 PublishButton currently returns an empty fragment via an early return <></>. 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_id tracks its own block_version_code sequence; MAX + 1 for that block determines the next fork's code. This is why createNewAppVersion carries an oldToNewBlockVersionIdMap — to remap parent references when cloning a nested block tree.
  • Active vs published vs draft — these three statuses on block_version_tb are different. Only one sibling per block_id can be 'active' inside a given app_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_data only 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.