Repository navigation
Conversation
This was referenced Oct 13, 2026
…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
force-pushed
the
fold/v8-api
branch
from
October 16, 2026 05:11
af8b74d to
3914dd5
Compare
tlgimenes
force-pushed
the
fold/v8-web
branch
from
October 16, 2026 05:11
b1e35fe to
be3506b
Compare
…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
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
… 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.deco serveconnection is chosen, or the account-less/site-editordeco servedeco serve/sandbox/:id/:branch/rpc(#7887)schema.geton/decofile/:id/main/rpcblocksMajormarker). In that case the editor waits and retries.What's in it
selectContentBackend/useContentBackendpick GitHub, a sandbox, or a localdeco serve./site-editorupload throughdeco serve.deco serve:deco serveon localhost./site-editoron the org rail.releasessidebar view, plus its generated tool contracts.deco servestand-in built from@decocms/blocks.@decocms/blocksis allowed in e2e as the published wire contract..gitleaksignore: a fingerprint for the synthetic site token insite-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}inapps/api/src/hosted/draft-store.ts), layered over main's current snapshot whenever it is read (layerDraftFiles).POST …/git/discard(draftGitDiscarddrops the entries fromsetanddelete). There is no main sync: nothing reads main's diff or writes main's body back.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.sameAsMaininapplyAttemptToDraft, 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.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.
GET …/reapply, which is read while the dialog is open. The button never waits for it, and a failed read leaves the line out.releases/retry).latest.jsonnames. It says nothing about what visitors see this second.site-editor-copy.test.tskeeps "live", "no ar", "republi…" and "anyway / mesmo assim" out of everyreleases.*string, on top of the existing git-word ban.reapply.content-protocol-github.spec.ts):schemaChanged: false.Reapply: Publish One,reapplyOf= first). Its stored content equals the first one's, main holds it, andlatest.jsonnames the new commit with a newerpublishedAt.releases/currentgives 404.releases/retryreleases main's head.PO decisions on differences a v7 user can see (2026-10-14)
$refis gone, for v7 and v8, andresolveSchemais main's (with fix(cms): stop recursive schema types from freezing the site editor #7899 and fix(cms): expand recursive schema types when their nested items are opened #7903) plus the v8 additions (lazy blocks, short__resolveTypenames), so a re-entered$refis cut and expanded one level at a time when the form opens it. The earlier cherry-picks of fix(cms): stop recursive schema types from freezing the site editor #7899/fix(cms): expand recursive schema types when their nested items are opened #7903 dropped out in the rebase, since main has them.193f9b992adds 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 f3dffbf (2026-10-16)
Stacked on #7887 at
3914dd50e(head10684efe8), which is rebased onorigin/mainatf3dffbf3e. The old merges offold/v8-apiand main are gone, and the branch is now:acf7aed67feat(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 onbackup/fold-v8-web-2026-10-16).519b66e20test(e2e): the e2e parts of those commits.193f9b992test(web): the A9 six-level menu test.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) andbe3506b16(a section form saves one change at a time).ff469ff01fix(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/releasesto Settings; the schema read is now a pending state for Releases, like the assets and site-access probes.05d4ae549fix(web): a merge never swaps a field under the user's cursor (below).f9bcd8460fix(web): what a user types after "The latest version was kept" is saved (below).894e51697test(e2e): the CMS settings guard test holds the editor's polls.8fa2ab3c5fix(web): typing the same text again after "The latest version was kept" saves it (below).10684efe8test(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:
writeLegacy. The form keeps main'sownversions (isOwnVersion/rememberOwnVersion) next to this branch's v8version, and a refused save is retried with main's backoff (up to 3 times) before the form reloads.be3506b16adds 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.ChatStreamValueProvider, and nofastPreviewoption on the git status and discard calls.useMCPClientOptionalis gone, souseVirtualMCPreads the self client through a suspense query that skips without an org (the account-less/site-editor).playwright.config.tskeeps main's build/migrate prefix and adds the hosted env and the GraphQL stub.ifMatch:v8 saves get the same protection (
b01564e93):blocks.applywithifMatch: { <block>: <version> }writes only while the entry's current version (any spelling) is still that one; otherwise it writes nothing and answers Conflict.blocks.listreads and from eachblocks.applyanswer; after a refusal, fromchanges.get. A block being saved keeps its last landed version through polls.SaveGuard'sifMatch(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.BlockConflictErrorand 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."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):
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, v8ifMatch= 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" andFormBase.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 testsform-conflict.test.ts(merge plan, choices, pointers, previews, word diff) andform-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:
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).__metaversions are reverted (63ed9b252).conflict-author.test.ts(only a hosted draft has a source, spelling match, member resolution,you, time only) andform-conflict-dialog.test.tsx(each line, hover time, no line).use-save-block-v8.test.tsxchecks a save sends the block exactly as the form holds it, with no__metaand no user id.content-protocol-github"saves into the CDN draft" expects one object whose JSON is exactly{ set, delete }with no editor data, and the names inx-amz-meta-deco-edited.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:
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.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.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.displayedholds 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 (andifMatchon v8). Nothing is ever a mix of both texts.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.c80bd99d9): all three view-transition promises are handled.e2e on a production build of
10684efe8:site-editor-stale-form11/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-breadcrumbsandstandalone-blocks-panelpass.content-protocol-github's conformance test hit anECONNRESETfrom the API twice with 4 workers on a loaded machine and passes 3/3 alone (no API code changed here). The test waits insite-editor-stale-formnow 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 theifMatchguard can tell (under load a poll showed the conflict first and the click hung).Checks
Who and when on hosted v8 (revert
63ed9b252, merged0188b4b6of feat(api): Blocks v8 server side — content protocol (GitHub + sandbox over the daemon's fs routes), hosted releases and site tokens #7887's0d8ce2bdf,806cea686web, 2026-10-16):apps/webandpackages/e2etypecheck (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/webandpackages/e2etypecheck (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-form12/12 andcontent-protocol-deco-serve11/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 in05b411361: 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-api19/19,fast-preview-git-sync3/3 andcontent-protocol-github21/21, one run each.On
10684efe8(2026-10-16):apps/webandpackages/e2etypecheck (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.mjspasses every step.After the rebase:
apps/web,apps/apiandpackages/sharedtypecheck (tsc, single-threaded), oxlint and biome are clean. Unit tests: the web suite 4771 pass, 0 fail (use-cms-publish-actions.test.tsxexpects main's discard call withoutfastPreview); 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.mjspasses every step, including the Releases deep link.Before the rebase: after the merge and the v8 protection:
apps/web,packages/e2etypecheck; oxlint, biome and knip are clean. Unit tests: sections-editor 1123 pass, including newcontent-protocol-versions.test.ts(7),use-save-block-v8.test.tsx(3) andformSaveGuard(7 inform-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.ts6 of 7 pass, the 7th fails locally afterpage.reload()withERR_INSUFFICIENT_RESOURCESfrom the dev server; three deco-serve tests fail locally the same way onfold/v8-webbefore this merge (b54688cb6), so they are environment, not regressions. CI runs the production build.git diff origin/main -- packages/sandboxis empty (before the rebase, so wasgit diff --stat origin/split/51-v8-per-site origin/fold/v8-web).Web, e2e and ui typecheck. oxlint, biome and knip are clean.
gitleaks gitover 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(includesorigin/mainatd1a1d7375), 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