feat(web): Blocks v8 site editor — content backends, deco serve, hosted Publish, Versions and site tokens

Draft
TL
3h ago
+26750-1203

Stacked on #7887, the API PR. This is the editor side of Blocks v8 (part 2 of 2).

Together with #7887, this replaces the v8 stack (#7877, #7800–#7825, #7803–#7840, #7841–#7843, #7871–#7878), whose PRs are closed with a link here; their branches are kept. Before main moved on, the final tree of this branch equalled the stack's top, split/51-v8-per-site; it is now rebased on main (below).

Decision model (PO, 2026-10-12)

The editor waits until it has both a schema and the content for its mode. Then it decides with isV8Schema, and never renders the v7 path first.

ModeWhenSchema fromv8not v8
Localhosta deco serve connection is chosen, or the account-less /site-editorthe deco servecontent protocol on the deco serveguided "not v8" state
Sandboxthe session has a sandboxthe sandbox working tree, through Studio's /sandbox/:id/:branch/rpc (#7887)content protocol over the daemon's fs routesthe existing v7 sandbox path
GitHubotherwisemain, through schema.get on /decofile/:id/main/rpcmain + CDN draft (hosted CMS)content from the branch, as on main
  • A schema that can't be read counts as v7, unless the server last found the site v8 (the blocksMajor marker). In that case the editor waits and retries.
  • The marker is only a hint. It never makes a site v8.
  • No sandbox: a session without a sandbox gets a 404 on the sandbox route, which is read as v7 straight away.

What's in it

  • Content backend:
    • selectContentBackend / useContentBackend pick GitHub, a sandbox, or a local deco serve.
    • One content-protocol client feeds the sections editor, the Content tab and Preview.
    • v8 variant previews go through the draft pointer.
  • v8 editing:
    • v8 schema shapes and a schemaless editor.
    • Protocol fields with secrets encrypted in the browser, and asset uploads. Uploads go to S3 in project mode; only Localhost and /site-editor upload through deco serve.
  • deco serve:
    • The editor finds a running deco serve on localhost.
    • Connect links, a first-run guide and notices.
    • The Local draft option, and the account-less /site-editor on the org rail.
  • Hosted v8 projects:
    • Publish, the Versions (releases) tab, and site tokens in settings.
    • A guided state when another project owns the site.
    • Every message is in business language, one toast per action.
  • Shared:
    • The releases sidebar view, plus its generated tool contracts.
    • The web halves of the v8 detector guard test.
  • e2e:
    • The content protocol over GitHub, using a stubbed Git Data API.
    • The content protocol over a deco serve stand-in built from @decocms/blocks.
    • Drafts, and hosted Publish against a stub delivery bucket.
    • The v8 gates.
    • @decocms/blocks is allowed in e2e as the published wire contract.
  • .gitleaksignore: a fingerprint for the synthetic site token in site-token-section.test.tsx, recorded for this PR's commit.

Discard on hosted v8 projects (PO decision)

The v8 draft is a sparse map of the blocks that differ from main ({set, delete} in apps/api/src/hosted/draft-store.ts), layered over main's current snapshot whenever it is read (layerDraftFiles).

  • Discard = remove those blocks from the draft. It always calls POST …/git/discard (draftGitDiscard drops the entries from set and delete). There is no main sync: nothing reads main's diff or writes main's body back.
  • Without the entry, the editor and the preview show main as it is now, and follow main when it moves later.
  • It runs in the editor's per-block write queue (runContentDiscard): armed autosaves of the discarded blocks are dropped, and it lands after writes already in flight. Then comes the shared refresh: blocks re-read, draft pointer → preview reload, git status, change list, form re-seed. The header and the preview show the shared saving/reloading state.
  • The server's "same as main → no entry" rule (sameAsMain in applyAttemptToDraft, #7887) stays, for an edit typed back to main's exact value. Discard no longer depends on it.
  • v7 discard is unchanged.

Releases: Reapply version and Latest (PO decisions 2026-10-14)

The API side is in #7887. This supersedes the earlier "Go live" and "Republish" wording; none of it is left.

  • Action: Reapply version (pt-BR Reaplicar versão) in a version's ⋮ menu, on every version with a release except the Latest one.
    • The dialog is calm and asks once.
      • Title: "Reapply this version?"
      • Body: "This publishes a new version with the same content as “”. Changes made since will be replaced on your site."
      • Button: Reapply
    • If that version was made for an earlier design of the site, the dialog adds one line: "If something doesn't look right afterwards, reapply the version you have now to go back."
      • The line comes from GET …/reapply, which is read while the dialog is open. The button never waits for it, and a failed read leaves the line out.
      • There is no second confirmation and no warning.
    • Toasts:
      • "Reapplying…"
      • Then "Reapplied. Your site will show it shortly." It adds "If something doesn't look right, reapply the previous version to go back." only when the schema changed.
      • On failure, "Couldn't reapply this version." with Try again.
    • Other outcomes:
      • Saved but not released: Publish's Try again (releases/retry).
      • A concurrent publish: a guiding warning.
      • Content already there: "Your site already has this version's content."
  • Rows: the new row reads "Reapply: " (pt-BR "Reaplicação: …").
    • A version that had no note gets a dated label instead.
    • A "Same content as the version from " link scrolls to the original.
  • Pill: Latest / Mais recente marks the version latest.json names. It says nothing about what visitors see this second.
    • No served-revision reads, no polling, no "Going live…".
    • The header says "Last published ." or "Nothing published yet".
  • Copy guard: site-editor-copy.test.ts keeps "live", "no ar", "republi…" and "anyway / mesmo assim" out of every releases.* string, on top of the existing git-word ban.
  • Content-change kind: reapply.
  • e2e (content-protocol-github.spec.ts):
    • The GET check says schemaChanged: false.
    • Reapplying the first version makes a new top version (Reapply: Publish One, reapplyOf = first). Its stored content equals the first one's, main holds it, and latest.json names the new commit with a newer publishedAt.
    • Reapplying again is up-to-date.
    • A commit with no release gives 404 on both the GET and the POST.
    • releases/current gives 404.
    • A later Publish lands on the restored content.
    • releases/retry releases main's head.

PO decisions on differences a v7 user can see (2026-10-14)

  • Approved by the PO (2026-10-14): A1–A4, A6–A8, A10–A13, A23
  • A9 is main's behaviour. The 3-level cap on a recursive $ref is gone, for v7 and v8, and resolveSchema is main's (with #7899 and #7903) plus the v8 additions (lazy blocks, short __resolveType names), so a re-entered $ref is cut and expanded one level at a time when the form opens it. The earlier cherry-picks of #7899/#7903 dropped out in the rebase, since main has them. 193f9b992 adds a test: a six-level self-referencing menu renders and edits every level.

A5 and A14–A18 were found in the same audit and are fixed in this tree: v7 picks the legacy backend with no probes, and v7's Advanced → Local is main's.

Rebased on main f3dffbf3e (2026-10-16)

Stacked on #7887 at 3914dd50e (head 10684efe8), which is rebased on origin/main at f3dffbf3e. The old merges of fold/v8-api and main are gone, and the branch is now:

  1. acf7aed67 feat(web): the v8 site editor, hosted Publish, Releases and site tokens: a squash of the 23 web commits that predated #7909 (they were written before it and would each have had to be redone on main's save path; the list is in the commit message, and the old history is on backup/fold-v8-web-2026-10-16).
  2. 519b66e20 test(e2e): the e2e parts of those commits.
  3. 193f9b992 test(web): the A9 six-level menu test.
  4. The six later commits, replayed with their own subjects: b01564e93 (v8 stale-save protection, below), 475e58012 (Settings no longer creates CMS.json), 88fcbc9d4 (deco serve e2e on a production build), 32edcd153 ("." on a page opens that page), c80bd99d9 (no uncaught AbortError from a skipped preview transition) and be3506b16 (a section form saves one change at a time).
  5. ff469ff01 fix(web): a deep link to Releases waits for main's schema instead of bouncing to Settings. With #8002 the tab bar no longer suspends on the dev MCP client, so the deep-link check ran before main's schema read had landed and sent /releases to Settings; the schema read is now a pending state for Releases, like the assets and site-access probes.
  6. 05d4ae549 fix(web): a merge never swaps a field under the user's cursor (below).
  7. f9bcd8460 fix(web): what a user types after "The latest version was kept" is saved (below).
  8. 894e51697 test(e2e): the CMS settings guard test holds the editor's polls.
  9. 8fa2ab3c5 fix(web): typing the same text again after "The latest version was kept" saves it (below).
  10. 10684efe8 test(e2e): stale-form toasts are counted as they appear.

The final tree equals a plain merge of the old head with main. What main changed under this branch, and how it is kept:

  • #8000 (stale reads of your own saves): main's Fast Preview save path, with its own-version echo check, is this branch's writeLegacy. The form keeps main's own versions (isOwnVersion/rememberOwnVersion) next to this branch's v8 version, and a refused save is retried with main's backoff (up to 3 times) before the form reloads. be3506b16 adds that, while a retry waits out its backoff, the form's next saves wait behind it, as behind a save on the wire, and the retry carries the form's latest value.
  • #8007 (desktop app removed): no ChatStreamValueProvider, and no fastPreview option on the git status and discard calls.
  • #8002: useMCPClientOptional is gone, so useVirtualMCP reads the self client through a suspense query that skips without an org (the account-less /site-editor).
  • #8006: playwright.config.ts keeps main's build/migrate prefix and adds the hosted env and the GraphQL stub.
  • #7909 holds for v7 exactly as on main, and for v8 through ifMatch:

v8 saves get the same protection (b01564e93):

  • What is compared: the content protocol's per-entry version. blocks.apply with ifMatch: { <block>: <version> } writes only while the entry's current version (any spelling) is still that one; otherwise it writes nothing and answers Conflict.
  • Where the version comes from: the editor keeps each block's version next to the blocks cache, from full blocks.list reads and from each blocks.apply answer; after a refusal, from changes.get. A block being saved keeps its last landed version through polls.
  • Section form: the form's starting point becomes the SaveGuard's ifMatch (formSaveGuard, form-base.ts). A saved block expects the version the form's copy was checked against, moved by each landed save of the form; a page expects its cached version. Both are read when the save runs, after the branch's earlier saves land.
  • A refused save behaves like #7909's 409: the block is read again (the stored copy and its version replace the edit in the cache), there is no optimistic rollback, and the save fails with the same BlockConflictError and copy, so the form retries once on the stored block (applying its edits on top, with #7909's "This block was updated while you were editing" toast) and otherwise reloads with "This block changed while you were editing and could not be saved. The latest version is loaded."
  • Other v8 editors (SEO sheets, Settings, app editors) keep their current guards; Settings' create-only guard is unchanged.

Same-field conflicts: the user chooses (PO decision 2026-10-16)

PO: "instead of a toast, add a dialog with accept mine vs theirs and maybe show the diff? … Instead of using these heuristics, let the user decide". This replaces the same-field toast and the heuristics that served it.

  • Values only one side changed still merge silently (with #7909's "This block was updated while you were editing" toast), now item by item inside lists, so two people editing different fields of the same slide no longer clash.

  • Values both sides changed to different things: nothing of the form's is written. Its saves for that section wait, and one dialog opens (never a toast, never two dialogs: a second waiting choice opens after the first is settled):

    ┌ Someone else changed this while you were editing ─────────────── x ┐
    │ You both changed the fields below. Choose which version to keep    │
    │ for each one. Their version stays saved until you apply your      │
    │ choice.                                                            │
    │                                   [Keep all mine] [Use all theirs] │
    │ Headline                                                           │
    │ ┌ (o) Your version ──────────┐ ┌ ( ) Their version ─────────────┐  │
    │ │ [Summer] sale on all shoes │ │ [Winter] sale on all shoes     │  │
    │ └────────────────────────────┘ └────────────────────────────────┘  │
    │ Slides › 1 › Image                                                 │
    │ ┌ ( ) Your version ──────────┐ ┌ ( ) Their version ─────────────┐  │
    │ │ [thumbnail]                │ │ [thumbnail]                    │  │
    │ └────────────────────────────┘ └────────────────────────────────┘  │
    │ Choose a version for each field to apply.  [Decide later] [Apply]  │
    └────────────────────────────────────────────────────────────────────┘
    

    Fields are named by their form labels. Text shows a word-level diff (the words the other version lacks are highlighted, rich text without tags); images show thumbnails; lists show their size and first item titles; small objects show their fields; Yes/No for booleans. (Superseded: the dialog now says who and when, see the next section.)

  • Apply writes one save of the chosen values over their version, guarded by that version (Fast Preview expect = its fingerprint, v8 ifMatch = its content-protocol version), and the form shows the result. If yet another change landed meanwhile, the save is refused and the same dialog comes back with the newer version ("Their version changed again while this was open."), keeping the choices whose two versions didn't change. On v8 the dialog also refreshes in place when the editor's poll brings a newer version. It never retries silently.

  • Closing without choosing ("Decide later", Escape, x): their version stays stored; the user's edits stay in the form, unsaved, with a calm notice above the editor ("Your changes to this section aren't saved yet: someone else changed it too." + "Choose versions"). Anything they type meanwhile joins their version and waits too. If they move to another section or page, the choice keeps its place: "Choose versions" still saves to that section. This is the simplest behaviour that never drops an edit and never writes over theirs without a choice.

  • Typing while the dialog is open: it is modal (focus is trapped), so nothing reaches the form behind it.

  • Removed: the "Changed while you were editing … The latest version was kept" toasts, mergedFormDisplay "on-leave" and FormBase.displayed / markEditedSinceDisplayed / formValueOnBase (typing on after the toast saved over theirs). Kept: one save at a time per form, the retry backoff, and the on-leave reload for a section replaced or a content sync while typing (not same-field specific).

  • A form save's page, page variant and section variant are now fixed when it is scheduled (FormSaveTarget), so a retry or a later choice writes to the section it was made for.

  • Copy in en and pt-BR (sectionsEditor.formConflict.*); unit tests form-conflict.test.ts (merge plan, choices, pointers, previews, word diff) and form-conflict-dialog.test.tsx (labels, diff marks, images, lists, per-field / all choices, Apply gating, Decide later, the notice).

Who made the other change, and when: hosted v8 only (PO 2026-10-16)

PO: "yes, this is pretty important", then (final) "only have metadata and changed by on v8 hosted". On a hosted v8 draft the same-field dialog says who made the other change and when, under its title:

┌ Someone else changed this while you were editing ─────────────── x ┐
│ You both changed the fields below. Choose which version to keep    │
│ for each one. Their version stays saved until you apply your      │
│ choice.                                                            │
│ (AS) Changed by Ana Souza · 3 minutes ago                          │
│                                   [Keep all mine] [Use all theirs] │
│ ...                                                                │
  • What the line says:
    • "Changed by Ana Souza · 3 minutes ago", with the member's avatar and the exact time on hover.
    • "You, in another tab or window · 2 minutes ago", when the other change was the viewer's own.
    • "Changed 5 minutes ago", when the id isn't a member's.
    • No line when nothing is recorded. en and pt-BR.
  • Source: the session read's changedBy, which comes from the draft object's storage metadata (#7887). The dialog reads it once per version of theirs shown, so "Their version changed again" reads it again; never per poll. The id is resolved from the org's members (useMembersQuery's cache).
  • Fast Preview (v7), Localhost and sandboxes: no line. Nothing is stamped on any block. The earlier git-history, draft-edits and block-__meta versions are reverted (63ed9b252).
  • Tests:
    • Unit: conflict-author.test.ts (only a hosted draft has a source, spelling match, member resolution, you, time only) and form-conflict-dialog.test.tsx (each line, hover time, no line).
    • Unit: use-save-block-v8.test.tsx checks a save sends the block exactly as the form holds it, with no __meta and no user id.
    • e2e (written, not run): the v7 stale-form and deco-serve same-field cases expect no line.
    • e2e: content-protocol-github "saves into the CDN draft" expects one object whose JSON is exactly { set, delete } with no editor data, and the names in x-amz-meta-deco-edited.
    • e2e: a new hosted-v8 same-field case expects "You, in another tab or window".
    • The e2e delivery stub keeps x-amz-meta-* and answers it on GET/HEAD. The spike kit runs that stub from its worktree and picks it up when moved to this head; the kit itself is untouched.

Concurrent edits: fixes also proposed for main (#8027)

The second and third bullets below (the on-leave display and typing on after the toast) are superseded by the dialog above.

Found on the spike kit with two people editing one block of a v7 site with Fast Preview, under load. They are main's code (#7909's merge and #8000's retries), so the same fixes are in #8027, a small draft PR against main, so v7 sites get them without waiting for this stack:

  • An edit lost after a refused save (be3506b16): the form could send a second save while the first was on the wire; mutate() reports only the latest call, so after the first was refused the replay counted the user's own edit as a conflict and kept the stored value (and the toast named the wrong field). A section form now sends one save at a time (mutateAsync), and a retry reads the form's value when it runs.
  • Keystrokes appended to the other person's text (05d4ae549): a merge that kept the other editor's value of the field being typed in replaced it under the cursor ("Picks A again" + "holk"), and that mix was saved. While focus is in the form, the form keeps what the user types and re-reads the stored section once they leave it. A sync that rewrites the content (this stack's content epoch) waits the same way.
  • What the user types after "The latest version was kept" was lost (f9bcd8460, 8fa2ab3c5): found by the respike on both v7 and v8. The field still showed the user's text, but the form's baseline stayed the refused copy, so the next keystrokes were merged against it again, dropped with a second toast, and the field flipped to the other editor's value when they left. The form's baseline is now the merge it saved (FormBase.displayed holds what it shows instead): a value the user changes after the notice, even typed back to the same text, is saved over the stored one they were told about; values they don't touch keep the stored value; a change from elsewhere after that is still caught by #7909's check (and ifMatch on v8). Nothing is ever a mix of both texts.
  • Toasts (be3506b16): the form's field labels ("Headline", not "title"), "The latest version was kept." when nothing else of the user's was left to save, and one toast per action.
  • Uncaught "AbortError: Transition was skipped" in the preview (c80bd99d9): all three view-transition promises are handled.

e2e on a production build of 10684efe8: site-editor-stale-form 11/11 in 3 runs with 4 workers (the two new tests fail without the fixes: the stored headline stays "Published headline"); content-protocol-deco-serve, fast-preview-git-sync, decofile-api, site-editor-breadcrumbs and standalone-blocks-panel pass. content-protocol-github's conformance test hit an ECONNRESET from the API twice with 4 workers on a loaded machine and passes 3/3 alone (no API code changed here). The test waits in site-editor-stale-form now allow for main's backoff retry (#8000) under load, and its toasts are counted as they appear (a toast could expire before it was counted). The deco serve test "Settings' first save never replaces a CMS block created meanwhile" holds the editor's polls until its save goes out, so only the ifMatch guard can tell (under load a poll showed the conflict first and the click hung).

Checks

  • Who and when on hosted v8 (revert 63ed9b252, merge d0188b4b6 of #7887's 0d8ce2bdf, 806cea686 web, 2026-10-16): apps/web and packages/e2e typecheck (tsc, single-threaded); oxlint and biome are clean. Unit tests: sections-editor + i18n + sandbox/content 1847 pass. e2e is written but not run yet.

  • Same-field dialog (731befa59, c63ee03c3, 05b411361, 2026-10-16): apps/web and packages/e2e typecheck (tsc, single-threaded), oxlint and biome are clean; sections-editor unit tests 1160 pass, i18n 13 pass. e2e on a production web build (4 workers, shared Postgres/NATS, clean database and streams per run): site-editor-stale-form 12/12 and content-protocol-deco-serve 11/11, three runs each (keep mine, decide later then use all theirs, mixed per-field choices, different fields with no dialog, a third change while the dialog is open, the v8 deco-serve same-field case). The first run had one failure, a test bug fixed in 05b411361: the Keep mine case looked for the form behind the modal dialog with a role locator, and a modal dialog hides everything else from roles (the screenshot shows the dialog open and "Draft headline" in the form). decofile-api 19/19, fast-preview-git-sync 3/3 and content-protocol-github 21/21, one run each.

  • On 10684efe8 (2026-10-16): apps/web and packages/e2e typecheck (tsc, single-threaded), oxlint and biome are clean; sections-editor unit tests 1145 pass. On the spike kit (Vite dev, this head), the respike's stale-save flows pass: v7 different fields / same field / typing on after the notice / mid-typing / mid-typing with a pause, 5 of 5 rounds each; v8 different fields, same field then leave, typing on in the form and typing the same text again. smoke-persite.mjs passes every step.

  • After the rebase: apps/web, apps/api and packages/shared typecheck (tsc, single-threaded), oxlint and biome are clean. Unit tests: the web suite 4771 pass, 0 fail (use-cms-publish-actions.test.tsx expects main's discard call without fastPreview); the api, migrations and shared suites 5946 pass, 1 fail (heartbeatWhileQueued, a timing test outside this stack). e2e was not re-run after the rebase. On the spike kit (this head, with Postgres and NATS run by the kit since #8006), smoke-persite.mjs passes every step, including the Releases deep link.

  • Before the rebase: after the merge and the v8 protection: apps/web, packages/e2e typecheck; oxlint, biome and knip are clean. Unit tests: sections-editor 1123 pass, including new content-protocol-versions.test.ts (7), use-save-block-v8.test.tsx (3) and formSaveGuard (7 in form-base.test.ts); the web and shared suites 5579 pass (6 fail only under load in one parallel run, each passes alone). Local e2e (Vite dev server): the new deco-serve test "a section form saved over another writer's change keeps that change" passes (another writer changes the teaser on disk, the form saves its title with the stale version, the save is refused, retried on the stored block, and both changes stay); site-editor-stale-form.spec.ts 6 of 7 pass, the 7th fails locally after page.reload() with ERR_INSUFFICIENT_RESOURCES from the dev server; three deco-serve tests fail locally the same way on fold/v8-web before this merge (b54688cb6), so they are environment, not regressions. CI runs the production build.

  • git diff origin/main -- packages/sandbox is empty (before the rebase, so was git diff --stat origin/split/51-v8-per-site origin/fold/v8-web).

  • Web, e2e and ui typecheck. oxlint, biome and knip are clean.

  • gitleaks git over this PR's range and #7887's finds nothing.

  • CI on this branch at 35c2565f3 (includes origin/main at d1a1d7375), dispatched because the PR isn't based on main: Checks & Unit Tests 38442302426, E2E 38442306822 and Sandbox 38442311474 all passed.

  • The stack's top (87ed82439, same tree) passed Multi-Pod 38444814521.

  • After the discard change (172d120e3): Checks & Unit Tests 38495201848 and E2E 38495204785 passed. feat/blocks-v8-hosted (d78a2a58f) is a normal merge with the same tree.

  • Reapply version (c9bca4bd4), dispatched: Checks & Unit Tests 38505661256, E2E 38505663535 and Sandbox 38505665790 all passed. Web typecheck, oxlint, biome and knip are clean. feat/blocks-v8-hosted (4cb4845ad) is a normal merge with the same tree.

  • A9 revert (b54688cb6): web typecheck, oxlint and biome are clean, and the web unit tests pass (4733). feat/blocks-v8-hosted (8c8bb29cf) is a normal merge with the same tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu