Making Drafts and Version History Feel Safe

When you build an editor with drafts, publishing, and version history, the hardest problems are often not the obvious ones.

Saving a draft is straightforward enough. Rendering a published document is straightforward enough. Showing an older version of a document is also straightforward enough.

The difficulty starts when all three of those things are true at the same time.

A user may have unpublished changes for a document, then navigate to an older published version. Another user may publish a newer version while the first user is still editing. A route may point at a historical version, while the local draft still contains newer unpublished content. If the system is not very explicit about which source of truth it is rendering, the editor can accidentally show the wrong content, allow edits in the wrong place, or make the user worry that their draft was lost.

This is the kind of bug that feels small from the outside and very serious from the inside. The UI might only look “a little stale,” but the user experience is much more alarming: “Where did my changes go?” or “Why am I seeing draft text while I’m looking at an old version?” or “Can I accidentally edit history?”

We recently tightened this behavior in Seed’s document editor.

The goal was simple:

A draft should never be lost.
An old version should never pretend to be editable.
The UI should always make it clear which document state the user is looking at.

The problem: drafts and old versions were competing for the screen

A document editor usually has a few obvious states:

    loading

    viewing

    editing

    saving

    publishing

    error

But once drafts and version history enter the system, “viewing” is no longer a single thing.

The user might be viewing:

    the latest published version,

    the latest version with a local draft overlay,

    an explicit older published version,

    an older version while a newer local draft exists,

    a local draft that has not been published yet,

    a document that receives a newer remote version while editing.

Those states look similar in the UI, but they have very different rules.

The risky case was this one:

The user has a draft for a document, then navigates to an older version of that document.

In that situation, the draft still exists and must be preserved. But the screen should not render the draft content, because the route is asking for a historical version. If the draft overlays the old version, the user sees content that does not belong to the selected version. That is misleading.

The opposite mistake is also dangerous: if the system clears or overwrites the draft when the user visits an older version, the user may lose unpublished work.

So the editor needed to separate two ideas that are easy to accidentally merge:

    Does a draft exist?

    Should the draft be used for what we are rendering right now?

A draft can exist without being the correct thing to display.

That distinction became the core of the fix.

The invariant: old versions are read-only

The most important rule is now:

If the route points at an explicit older version of a document, that route is read-only, even if the current user has permission to edit the document.

This is not about account capability. The user may be an editor. They may be the author. They may have full write access to the latest document.

But an older version is history. Editing it directly would be conceptually wrong.

So “can this user edit this document?” is no longer enough. The real question is:

Can this user edit the document in the current route context?

That route context matters.

On the latest version, editing can start normally. A saved draft can overlay the published document. The user can continue their work.

On an older explicit version, editing is blocked. Draft overlays are disabled. The UI renders the historical published content, not local draft content. Edit affordances are hidden or disabled.

This includes more than the main editor body. We also had to block secondary edit paths:

    changing the title,

    changing the summary,

    adding or removing an icon,

    adding or changing a cover,

    editing metadata,

    using document settings that mutate the document.

It is not enough for the editor body to be read-only if nearby controls can still mutate the document.

Moving the decision into the document machine

One important design choice was keeping this logic inside the document machine.

It would have been easy to patch the page with scattered checks:

if (isOldVersion) return if (!isLatest) disableButton if (route.version) ignoreDraft

That kind of fix works briefly, then becomes fragile. Different components learn slightly different versions of the same rule. One button is disabled while another still works. One hook thinks drafts are allowed while another hook renders published content. Stale route state starts leaking into UI decisions.

Instead, the machine now owns the document rules and exposes derived selectors to the UI.

The UI should not need to reinvent document semantics. It should ask the machine questions like:

    Can this route be edited?

    Is draft overlay allowed here?

    Should the rendered content come from the draft or from the published document?

    Is the machine currently editing?

    Should a brand-new draft focus the title?

That gives us a cleaner contract:

The machine decides.
The UI reacts.

This is especially important because route changes, draft resolution, document loading, autosave, and remote updates can arrive in different orders. The machine is the right place to centralize those transitions.

Preserving drafts while showing historical content

The key behavior is that visiting an old version does not delete or overwrite draft state.

If the user has unpublished work, that work remains safe. But when the route points at an older version, the render path uses the selected published version instead of the draft overlay.

That means:

    the draft still exists,

    the old version displays its own historical content,

    the user cannot accidentally edit the old version,

    returning to the latest version can re-enable the draft overlay.

This is the distinction that prevents both data loss and stale rendering.

The model is:

Draft exists? Maybe. Draft should render here? Only if the route allows it.

For explicit old-version routes, the answer to the second question is no.

Handling dirty drafts when navigating away

Another important case is when the user is actively editing and then navigates to an older version.

If there are unsaved local changes, the machine should not simply abandon editing and switch content. That would create the feeling of lost work. Instead, the dirty draft is saved before the machine exits editing for the old-version route.

The user’s unpublished work remains a draft. The old version remains read-only. The transition between them is explicit and safe.

This makes version navigation feel much less risky.

Fixing stale content on first version switch

A subtle bug showed up during testing: the first switch to an older version sometimes did not update the content, while the second switch did.

That kind of issue usually points to stale data or mismatched ownership. Some part of the UI believes it should render one source of content, while another part of the system has already moved on.

The fix was to make route/version changes explicit in the machine and to ensure pending document content is promoted only when it matches the current route version. In other words, the machine should not blindly accept any incoming document update as the thing currently being viewed. It needs to know whether that document belongs to the selected route.

This matters because document loading and route changes are asynchronous. A document update can arrive before or after the route version state changes. Without a clear machine-level rule, the UI can briefly render content from the wrong version.

The resulting behavior is stricter:

    route version changes update machine context,

    document updates are matched against the current route,

    draft overlay is derived from route/version state,

    rendered blocks come from one selected source of truth.

That removed the stale-content behavior and made the first version switch reliable.

Making new empty drafts editable immediately

While tightening old-version behavior, we also found a regression in the opposite direction: brand-new empty drafts became non-editable on first entry.

The reproduction was simple:

    open the document options menu,

    choose New,

    choose New Document,

    try typing in the title.

The expected behavior is that a new draft opens in editing mode with the title focused. The user should be able to start typing immediately.

The bug came from treating a local-only reserved draft like a normal fetched document. During the initial render, the page could fabricate an empty placeholder document before the draft record existed. If the resource query was still carrying previous route state, the document machine could remain in loading and ignore edit events.

So the fix had to recognize that a reserved local draft is edit-capable before it has a persisted draft record. Once again, the important thing was making the route and draft state explicit rather than relying on incidental fetch state.

The result:

    new empty drafts enter editing,

    the title is focused,

    typing updates immediately,

    the document body accepts the cursor.

This matters because “New Document” should feel instant. A blank editor that cannot be typed into is one of the fastest ways to make users lose confidence.

Blocking hidden edit paths on old versions

Once old versions became read-only at the machine level, the UI needed to respect that everywhere.

The visible editor body was not the only path into editing. There were also buttons and affordances for:

    icon changes,

    cover changes,

    summary changes,

    metadata editing,

    home document metadata controls,

    document settings.

Those controls now derive their availability from the machine’s current-route editability.

This is a small detail with a large UX impact. If a page looks read-only but one button still changes something, the user model breaks. Read-only should mean read-only.

A better explanation when editing is blocked

After old-version editing was blocked, there was still a UX gap.

A user who has edit permission may naturally click into the document body expecting to start typing. If nothing happens, the app feels broken. The system knows why editing is blocked, but the user does not.

So we added a small explanation.

When the user tries to edit an older version, the document machine emits a blocked-edit notice. The UI listens for that machine event and shows a short toast:

This version is read-only.
Go to the latest version to make changes.

The notice appears once for that old-version visit, so repeated clicks do not spam the user.

We already had a separate toast explaining that the route is linked to an older version and offering “Go to latest.” But the blocked-edit message serves a different purpose. It appears in response to the user’s attempted action. To make it noticeable and avoid competing with the existing older-version toast, the blocked-edit toast appears at the bottom center, has no button, and hides after a few seconds.

This keeps the interaction lightweight:

    the user clicks,

    editing is blocked,

    the app explains why,

    the user can decide whether to navigate to latest.

No modal. No interruption. Just enough feedback.

The final behavior

The intended behavior is now:

If the user has changes and navigates to an old version

The draft is preserved. If needed, dirty changes are saved. The route renders the selected historical document content. Draft overlay is disabled. Editing is blocked.

If the user has no changes and navigates to an old version

The route renders the selected historical document content. Editing remains blocked because the route is historical, even if the user can edit the latest document.

If the user has changes and a new version syncs

The machine treats the incoming version as a remote update. It does not blindly overwrite local draft content. It can preserve the draft, classify whether changes can be merged, and avoid showing stale or mismatched content for the current route.

If the user returns to latest

Draft overlay is allowed again. The saved draft can be shown and editing can resume.

If the user creates a new empty draft

The draft opens in editing mode. The title is focused. Typing works immediately.

What made this fix safer

The main improvement was not just adding more conditionals. It was clarifying ownership.

The document machine owns document state. The route tells the machine what version is being viewed. The machine decides whether draft overlay is allowed and whether editing can start. The UI renders based on those decisions.

That reduces the chance of future stale-data bugs because there is one place to reason about document lifecycle.

A few principles came out of this work:

1. Draft existence is not the same as draft visibility

A draft may exist while the UI renders published content. That is correct when the route points at an old version.

2. Edit permission is not enough

A user can have write permission and still be unable to edit the current route. Historical routes are read-only.

3. UI affordances should derive from machine state

Buttons should not independently guess whether editing is allowed. They should reflect the same state machine rules as the editor body.

4. Route changes are domain events

Navigating between versions is not just a React render detail. It changes the document lifecycle and should be modeled as such.

5. Silent blocking feels broken

If a user expects editing to start and it does not, explain why. A short contextual toast can make a blocked action feel intentional instead of buggy.

Why this matters

Documents are trust-sensitive UI.

If a user sees the wrong content, they may think they lost work. If an old version shows draft content, they may misunderstand what was actually published. If a read-only view still exposes edit controls, they may worry about corrupting history.

The implementation details are technical, but the product goal is simple:

The editor should make it obvious what you are looking at, what can be changed, and whether your work is safe.

Drafts should feel durable. Version history should feel stable. Editing should feel intentional.

This change moves us closer to that.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime