Skip to content

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

Draft
tlgimenes wants to merge 27 commits into
fold/v8-apifrom
fold/v8-web
Draft

tlgimenes wants to merge 27 commits into
fold/v8-apifrom
fold/v8-web

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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.

Mode When Schema from v8 not v8
Localhost a deco serve connection is chosen, or the account-less /site-editor the deco serve content protocol on the deco serve guided "not v8" state
Sandbox the session has a sandbox the sandbox working tree, through Studio's /sandbox/:id/:branch/rpc (#7887) content protocol over the daemon's fs routes the existing v7 sandbox path
GitHub otherwise main, through schema.get on /decofile/:id/main/rpc main + 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, feat(api): Blocks v8 server side — content protocol (GitHub + sandbox over the daemon's fs routes), hosted releases and site tokens #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)

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 f3dffbf (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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 fix(web): keep loading states on the widget that is loading #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:

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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 (feat(api): Blocks v8 server side — content protocol (GitHub + sandbox over the daemon's fs routes), hosted releases and site tokens #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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 feat(api): Blocks v8 server side — content protocol (GitHub + sandbox over the daemon's fs routes), hosted releases and site tokens #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 chore(dev): replace managed CLI runtime with Compose Watch #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 feat(api): Blocks v8 server side — content protocol (GitHub + sandbox over the daemon's fs routes), hosted releases and site tokens #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

@github-actions github-actions Bot added the claude PR authored by a coding agent label Oct 13, 2026
This was referenced Oct 13, 2026
tlgimenes and others added 8 commits October 16, 2026 02:09
…changes.*

Squash of the e2e parts of the v8 web commits that predate the stack's
rebase onto main (old history on backup/fold-v8-web-2026-10-16): 39eb26f
(content protocol over GitHub and deco serve, drafts and Publish),
49a7cea (the frame check reads a draft pointer), c2180ca (changes.* on
hosted and Localhost; an 8.1.x deco serve is out of date), and the spec
updates of daadc1e, 0c9b3ba, 54f82e7, 172d120, 0dbccf5,
c9bca4b, 4bdeb62 and 8f539be. playwright.config.ts keeps main's
#8006 build/migrate prefix and adds the hosted env and the GraphQL stub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…level (A9)

Walks a Header menu (`MenuItem.children: MenuItem[]`) the way renderField
and ArrayField open it, six levels down, and checks each level keeps label,
href and children, and that the deepest stored item has a field for every
key. The 3-level cap failed this at level four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…hanged

#7909 protects Fast Preview section saves with `expect` fingerprints and a
409. A v8 save (content protocol) now gets the same protection through
`blocks.apply`'s `ifMatch`, compared by the protocol against the entry's
current version (the opaque `version` that `blocks.list`, `blocks.apply`
and `changes.get` report):

- The editor keeps each block's version next to the blocks cache
  (`KEYS.contentVersions`): set by full `blocks.list` reads (a block being
  saved keeps its last landed version), and by each `blocks.apply` answer.
- `formSaveGuard` (form-base.ts) turns the open form's starting point into
  the save's guard: a saved block expects the version the form's copy was
  checked against (moved by each landed save of the form, re-read when the
  form's edits were rebased on the stored copy); a page expects its cached
  version. Both are read when the save runs, after earlier saves landed.
- A refused guarded save with `reloadOnConflict` re-reads the content,
  puts the stored block and its version in the cache (`changes.get`, or
  `blocks.list` on an older server), skips the optimistic rollback and fails
  with #7909's BlockConflictError and copy, so the form retries once on the
  stored block and otherwise reloads, exactly as on Fast Preview.

Tests: formSaveGuard, the version bookkeeping and reloadProtocolBlock, the
save hook's refused/landed/opt-out paths, and an e2e over deco serve where
another writer changes the block, the form saves, and both changes stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
The outermost SchemaForm writes missing @defaults back through onChange
when it opens. On the v8 Settings form with no CMS block yet, that
onChange is the autosave, so opening Settings and waiting past the 700 ms
autosave delay created CMS.json with the defaults (the create-only save),
and the user's first change became a second save. The e2e "creates
CMS.json on the first save" saw two blocks.apply calls whenever the click
came later than 700 ms after the form opened (1 of 4 runs on a production
build).

SchemaForm takes `persistDefaults` (default true, unchanged for every
other form). The seeding moves into `useSeededFormValue`, which always
shows the defaults and writes them back only with `persist`. AppEditor
passes it through, and the CMS settings editor turns it off while the
block is absent: the defaults are shown and saved with the first real
change, which is the create-only save.

Tests: useSeededFormValue writes defaults back by default, writes nothing
for opening without persist (fails if the persist check is dropped), and
nothing when no default is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…uction build

Run against the production web build (vite preview, as CI does), four
deco serve tests failed and others timed out at random. None is a product
bug:

- Changes: the change card names its status in the icon's title since
  #7792 ("Edited" is not visible text). Assert it with getByTitle.
- Outdated 8.1.x server: the protocol handler gzips larger answers, and
  the stub's 1.0 rewrite read the gzip bytes as text and resent them with
  Content-Encoding: gzip, so Chromium failed with
  ERR_CONTENT_DECODING_FAILED and the page said "Another program is
  using localhost". The stub now decodes the answer and sends it plain.
- Localhost: picking it closes the drafts menu (edd6986), so
  "Connected" was only seen when the connection beat the close
  animation. The test waits for the menu to close and reopens it.
- Stub close: server.close() waits for idle keep-alive connections,
  which a polling editor never leaves, so a test hung in `finally` until
  its 120 s timeout. Close every connection first.
- Off-machine link: the guide's warning is checked with the 30 s timeout
  the other page loads use, not the 5 s default.

deco serve spec, production build, --repeat-each=4: 40/40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
A storefront that doesn't pass its page sends pathTemplate=/* with path=/,
and the deep link matched the template first, so "." on the home page
opened the Not Found page. A page whose path is exactly the visited one
now wins; the template is the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…rtError

Quick successive saves skip the running view transition; its ready and
updateCallbackDone promises then reject with "Transition was skipped" and
only finished was handled. The swap now settles all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…e can't drop the next edit

Under load the form's autosave could send a second save while a first one
was still on its way. When the first was refused as stale, the second was
built on a copy that never landed: mutate() reported only its outcome, its
starting point was put back to the unsaved first edit, and the replay on
the stored block then counted the user's own edit as a conflict and kept
the stored value. The user's edit was lost and the toast named the wrong
field (seen on a v7 site with two people on one block).

- A section form now has one save on the wire at a time; the next is built
  once that one lands or is refused (mutateAsync, so every outcome is
  heard). A retry after a refusal reads the form's value when it runs.
- The conflict toast names fields by their label in the form ("Headline",
  not "title"), says "The latest version was kept." when nothing else of
  the form's was left to save, and every outcome of one change shares one
  toast.
- With main's #8000, a refused save is retried with backoff (up to 3
  times) before the form reloads; 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.

Tests: e2e "a form that saves again while its refused save is on the wire
keeps both edits" (fails before: the headline goes back to the stored one
with a "Changed while you were editing: headline" toast); the same-field
e2e now expects the label and the shorter copy; unit tests for the labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
tlgimenes and others added 2 commits October 16, 2026 02:38
…bouncing to Settings

Releases is offered when main's schema is a Blocks v8 one
(useMainSchemaV8), but the deep-link check treated it as absent while that
read was still in flight, so opening /projects/<id>/releases directly
landed on Settings. On main as rebased, the tab bar no longer suspends on
the dev MCP client (#8002), which used to hold the page until the schema
read had landed; now the check runs first.

The schema read is now a pending state for Releases, like the assets and
site-access probes: a deep link stays on Releases while main's schema is
loading (or can't be read on a site last found v8), and falls back to the
default view only once the site is known not to have it.

Test: project-sidebar-views.test.ts "keeps a Releases deep link while
main's schema is being read". Seen on the spike kit: smoke-persite's
"v8: Releases page opens" failed (the page showed Project settings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Under load the form's autosave fires while the user is still typing. When
the save comes back merged and the merge kept another editor's value of
the field being typed in, the form showed that value at once, so the next
keystrokes landed after it ("Picks A again" + "holk" ...) and the mix was
saved as the user's edit, over the other editor's value. A change synced
in from elsewhere, and the reload after the last retry, re-seeded the form
under the cursor the same way.

While focus is in the open form, those now wait: the form keeps showing
what the user types (its saves keep merging onto the stored section, so
the field keeps the stored value) and re-reads the stored section once
the user leaves the form and its own saves have settled. Merges that kept
every edit of the form still show at once.

Tests: e2e "typing on after a save kept the other editor's value does not
add the keystrokes to that value" (fails before: the field reads
"Published headline more"); the same-field e2e checks the stored value
after the user leaves the field; unit tests for mergedFormDisplay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
tlgimenes and others added 9 commits October 16, 2026 14:14
When a save kept another editor's value of the field the user is typing in,
the form keeps showing their text until they leave it (05d4ae5), but its
baseline stayed the refused copy. Their next keystrokes were merged against
that copy again, lost to the stored value with a second toast, and the field
went back to the stored value when they left it: the edit was gone.

The form's baseline is now the merge it saved, and FormBase.displayed holds
what the form shows instead. A field the user changes after the notice is
saved over the stored value they were told about; a field they leave alone
keeps the stored value; a change from elsewhere after that is still caught
by #7909's check and ifMatch. Nothing is ever a mix of both texts.

Tests: e2e "typing on after a save kept the other editor's value saves what
the user typed, never mixed with that value" (fails before: the stored
headline stays "Published headline"); unit tests for formValueOnBase and
displayedOverBase. The spec's toast and poll waits allow for main's backoff
retry (#8000) under load, and toasts are counted as they appear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
"Settings' first save never replaces a CMS block created meanwhile" relied
on the click beating the editor's next poll. Under load the poll read the
other writer's CMS.json first, the editor showed "CMS settings unavailable"
before any save, and the click on the switch hung until the test timed out.
The test now holds blocks.list once the other writer saves, until the
editor's blocks.apply goes out, so only the ifMatch guard can tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…pt" saves it

After a save kept another editor's value of the field the user is in, the
field still shows the user's text (until they leave the form). A user who
insists, selecting it and typing the same text again, ended with the value
the form already showed, so nothing counted as changed: the save kept the
stored value and the field flipped to it when they left.

Every value the user changes after the merge is now marked in
FormBase.displayed, so it is theirs even when it ends as it was shown.
Values they don't touch still keep the stored value.

Tests: e2e "typing the same text again after the latest version was kept
saves it" (fails before: the stored headline stays "Published headline");
unit tests for markEditedSinceDisplayed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
"a form that saves again while its refused save is on the wire keeps both
edits" counted the toasts on screen after a poll of up to 30 s; on a busy
machine the toast had already expired (count 0). Toasts are now counted by
text as they appear, as in the typing-on test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…keep

When the user and someone else changed the same values of a section, the
form no longer keeps the stored value with a toast and the "on-leave"
heuristics. Nothing of the form's is written: its saves wait, and one dialog
("Someone else changed this while you were editing") lists each value by
its form label, "Your version" next to "Their version" (a word diff for
text, a thumbnail for images, a summary for lists and objects), with "Keep
mine" / "Use theirs" per field and "Keep all mine" / "Use all theirs".
Apply saves the choice against the stored version the dialog showed
(Fast Preview `expect`, v8 `ifMatch`); a newer one refuses it and the same
choice is asked again with that version. "Decide later" keeps their
version stored and the user's edits in the form, unsaved, with a "Choose
versions" notice. Values only one side changed still merge silently, now
item by item within lists.

Removed: the "Changed while you were editing" toasts, mergedFormDisplay
"on-leave", FormBase.displayed (typing on after the toast saving over
theirs). A save's page, page variant and section variant are fixed when it
is scheduled, so a choice made after the user moved on still writes there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
site-editor-stale-form: keep mine, decide later then use theirs, mixed
per-field choices, different fields merge with no dialog, a third change
while the dialog is open asks again with the newer version. The hero
fixture gains a Subtitle field. content-protocol-deco-serve: one v8
same-field case saved with the version guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
The Keep mine case checked the headline after typing into the open dialog
with the form's role locator. The dialog is modal, so everything outside it
is aria-hidden and the locator found nothing ("element(s) not found", the
form showing "Draft headline" behind the dialog in the screenshot). The
check now includes hidden elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…nge, and when

Under the dialog's title: "Changed by Ana Souza · 3 minutes ago" with the
member's avatar and the exact time on hover; "You, in another tab or window
· …" for the viewer's own change; "Changed on your computer · …" on
Localhost; "Changed … ago" when only the time is known. No line while it
isn't known (never "unknown"). en and pt-BR.

- Fast Preview and hosted v8 drafts ask Studio's history
  (GET …/changed-by), Localhost asks deco serve (`changes.get`, the
  optional `modifiedAt`; an older deco serve has none: no line). A sandbox
  working tree shows nothing.
- Asked once per version of theirs the dialog shows (the query key carries
  it), never per poll: "Their version changed again" reads who and when
  again.
- The conflict remembers the block its save writes (a saved block, else
  the page), whose history is read.

e2e (written, not run here): stale-form asserts "You, in another tab or
window" for the same user's other save and "Changed by Dev Person" after a
developer's push while the dialog is open (the GitHub stub gains path
history, commit dates/authors and an admin push); the deco-serve same-field
case asserts "Changed on your computer" (the stub adds `modifiedAt` where
the installed @decocms/blocks lacks it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
tlgimenes added a commit that referenced this pull request Oct 16, 2026
…nge, and when

Under the dialog's title: "Changed by Ana Souza · 3 minutes ago" with the
member's avatar and the exact time on hover; "You, in another tab or window
· …" for the viewer's own change; "Changed … ago" when only the time is
known. No line while it isn't known (never "unknown"). en and pt-BR.

A Fast Preview session asks Studio's history (GET …/changed-by) once per
version of theirs the dialog shows, never per poll: "Their version changed
again" reads who and when again. A sandbox session shows nothing. The
conflict remembers the block its save writes (a saved block, else the page).
formatRelativeTime comes from the Blocks v8 line (#7888).

e2e (written, not run here): stale-form asserts "You, in another tab or
window" for the same user's other save and "Changed by Dev Person" after a
developer's push while the dialog is open. The GitHub stub gains the
GraphQL commit history (as on #7888) with a path filter, commit dates and
authors, and an admin push; the API's GraphQL URL points at the stub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
tlgimenes and others added 7 commits October 16, 2026 17:42
… people from the org

PO: "write the user_id and grab user data on the front". On a hosted v8
draft the conflict dialog reads the draft's edits (the session read with
`?edits=1`: { userId, at } per name the draft holds) and resolves the
member's name and avatar from the org's members (useMembersQuery's cache;
`you` for the viewer). An id that isn't a member shows only the time; a
name the draft holds without a record shows nothing; a name it doesn't hold
is main's (changed-by), as before. Spellings match by the protocol's rule.

e2e content-protocol-github "saves into the CDN draft" now checks the draft
the site reads is still exactly { set, delete } and that the edits object
next to it holds { userId, at } per changed name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…t first

PO: "just add, on the block a metadata … something that tells last edited
by. but on the same json"; "do not create an edits.json please".

- useSaveBlock and useMoveBlocks stamp `__meta: { lastEditedBy: <Studio
  user id>, lastEditedAt }` on every block they write, on the wire: Fast
  Preview (v7), hosted v8 and deco serve alike (without a signed-in user an
  earlier record is dropped, never kept on this change). The form never
  holds it; canonical JSON and fingerprints ignore it (@decocms/shared), so
  no version, conflict or own-save check moves for it.
- The conflict dialog reads theirs' `__meta` from the stored block first and
  resolves the id from the org's members (time only when it isn't one);
  git history (`changed-by`) and deco serve's `modifiedAt` are the fallback
  for blocks without it. The `?edits=1` draft read is gone.
- The schemaless editor never shows `__meta` as a field.

e2e (written, not run): content-protocol-github "saves into the CDN draft"
checks the drafts folder holds one object, exactly { set, delete }, with the
block's `__meta` kept; the Review & Publish discard case checks the editor's
save stamped the block; new "a field another editor also changed asks whose
version to keep, saying who and when" (hosted v8: the other tab's save
carries the user's `__meta`, the dialog says "You, in another tab or
window", Keep mine saves the user's value stamped with their id).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
This reverts commits 93c3e8c, 34200e8 and 866ebdc. PO (final): "only
have metadata and changed by on v8 hosted": no "changed by" line on Fast
Preview (v7) or Localhost, nothing stamped on blocks. The next commit brings
the line back for hosted v8 drafts only, from the draft object's metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…s only

PO (final): "only have metadata and changed by on v8 hosted". On a hosted v8
draft the dialog reads the block's last editor from the session read's
`changedBy` (the draft object's storage metadata, #7887), once per version of
theirs shown, and resolves the id from the org's members (useMembersQuery's
cache): "Changed by Ana Souza · 3 minutes ago" with the avatar and the exact
time on hover, "You, in another tab or window · …", or "Changed … ago" when
the id isn't a member's. Fast Preview (v7), Localhost and sandboxes show no
line; nothing is stamped on any block.

e2e (written, not run): stale-form (v7) and deco-serve same-field cases
expect no line; content-protocol-github "saves into the CDN draft" checks one
object, JSON exactly { set, delete } with no editor data, and the names in
`x-amz-meta-deco-edited`; new hosted-v8 same-field case expects "You, in
another tab or window" and the JSON stays free of editor data after Keep
mine. The delivery stub keeps `x-amz-meta-*` and answers it on GET/HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant