Skip to content

RFC 108: Autosave MVP#108

Open
gasman wants to merge 3 commits intowagtail:mainfrom
gasman:autosave-mvp
Open

RFC 108: Autosave MVP#108
gasman wants to merge 3 commits intowagtail:mainfrom
gasman:autosave-mvp

Conversation

@gasman
Copy link
Contributor

@gasman gasman commented May 23, 2025

Rendered

This RFC describes a minimum viable product for automatic background saving of pages and snippets, following preliminary work on concurrent editing notifications (RFC 95) and deferring validation when saving drafts (RFC 104).

@SebCorbin
Copy link

Have we excluded the ability to auto-save custom models too? Should it be possible to implement it rather easily through model/viewset mixins?

@gasman
Copy link
Contributor Author

gasman commented May 23, 2025

@SebCorbin Similarly to deferred validation (#12923), this functionality depends on the model extending DraftStateMixin, and the view code would most likely be implemented in wagtail.admin.generic.mixins.CreateEditViewOptionalFeaturesMixin, so technically it ought to work for any models / viewsets making use of those. However, we currently only formally support and document those features in the context of snippets, so I'm essentially referring to "snippets" in this RFC as a shorthand for "models extending DraftStateMixin and being edited through views extending CreateEditViewOptionalFeaturesMixin".

I know there's been requests to support optional features such as draft state on non-snippet models, and I'm not sure if @laymonage plans on acting on those - but FWIW I'm not really sold on the idea. Access to these optional features is the one remaining defining feature of "a snippet" as opposed to "a model" - asking for those features to be available outside of snippets is essentially asking for the concept of a snippet without it being called a snippet. And however much you might hate the name snippet, removing a name from a concept is surely a backward step.

On receiving an 'error' response to the request, a message will be displayed to the user indicating that the page could not be saved. If the error code indicates that `overwrite_revision_id` is not the newest revision (meaning that another user has edited the page), the code will stop sending further auto-save requests for the current page.

In the initial implementation of the feature, validation errors encountered while auto-saving will not be displayed alongside the corresponding field, as the logic for doing this correctly is currently only in place in server-side code as part of a full page render. Editors will need to manually save a draft, triggering a full page render, to see the validation errors in place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should outline what changes need to be made to the "you have unsaved changes" messaging. Even with autosave in place, we need to account for the window of time between saves, and the round-trip time from submitting a background POST to getting a response, and keep the "are you sure you want to navigate away" prompt if the user leaves the page before this completes.

On the other hand, we can probably remove the "you have unsaved changes" message in the footer - if autosave is active, then the unsaved changes will be short enough duration that there's no need for a notification, and if autosave is suspended due to a validation error or edit conflict, those will have their own notifications.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be more useful to have something like "Last saved xx seconds/minutes ago". This gives you some idea that there might be changes that haven't been saved (if you made some changes within the given duration), without necessarily becoming a "warning" that the user needs to worry much about.

It's also still useful when there's a validation error or edit conflict. Although, with that in mind, such information might be better placed at the header rather than the footer. Not sure how busy the header will be if that were the case, though.


On receiving an 'error' response to the request, a message will be displayed to the user indicating that the page could not be saved. If the error code indicates that `overwrite_revision_id` is not the newest revision (meaning that another user has edited the page), the code will stop sending further auto-save requests for the current page.

In the initial implementation of the feature, validation errors encountered while auto-saving will not be displayed alongside the corresponding field, as the logic for doing this correctly is currently only in place in server-side code as part of a full page render. Editors will need to manually save a draft, triggering a full page render, to see the validation errors in place.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worth adding the backend code that allows rendering the form errors as <template data-controller="w-teleport"> elements, so that we could render them without a full-page reload. Might be a bit tricky for accessibility if the user is currently interacting with the form input that has an error, though. We did a similar trick for updating the editing sessions list in the header, so maybe some learnings from that could be applied.

Although this would probably need a different handling for StreamField as it uses client-side rendering.

On receiving an 'error' response to the request, a message will be displayed to the user indicating that the page could not be saved. If the error code indicates that `overwrite_revision_id` is not the newest revision (meaning that another user has edited the page), the code will stop sending further auto-save requests for the current page.

In the initial implementation of the feature, validation errors encountered while auto-saving will not be displayed alongside the corresponding field, as the logic for doing this correctly is currently only in place in server-side code as part of a full page render. Editors will need to manually save a draft, triggering a full page render, to see the validation errors in place.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be more useful to have something like "Last saved xx seconds/minutes ago". This gives you some idea that there might be changes that haven't been saved (if you made some changes within the given duration), without necessarily becoming a "warning" that the user needs to worry much about.

It's also still useful when there's a validation error or edit conflict. Although, with that in mind, such information might be better placed at the header rather than the footer. Not sure how busy the header will be if that were the case, though.


## Open Questions

* As a performance optimisation, can we combine the background requests to the 'save' endpoint with the background HTTP requests that already exist - namely, the 'ping' endpoint for concurrent editing notifications, and live previews?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be great if we could, especially for live previews. Having both autosave and live previews would double the work on both the client and server.

The PreviewController already does a lot of similar logic: serialising the form, keeping the last state for comparison, and periodically sending the data to a backend endpoint. On the backend side, the preview view also performs validation on the form.

Not sure how feasible it is to do it as part of the MVP, though.

## Open Questions

* As a performance optimisation, can we combine the background requests to the 'save' endpoint with the background HTTP requests that already exist - namely, the 'ping' endpoint for concurrent editing notifications, and live previews?
* Is there a need to support user-defined server-side logic within the "updating form state" mechanism, or is it sufficient to support inline formsets?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this and how the form state will be updated. I assume Telepath will be involved?

I'm still wondering whether we should lean more heavily towards server-side logic and rendering, i.e. adding HTMX-style swapping of form elements with the updated state from the server (perhaps with TeleportController), vs. leaning towards client-side with Telepath & co.

The current situation isn't ideal as we duplicate the markup in both Django templates and our homegrown JS components like https://github.com/wagtail/wagtail/blob/034f71c43887e614802a9f643ace3b4976923df3/client/src/components/StreamField/blocks/FieldBlock.js#L19-L32


* As a performance optimisation, can we combine the background requests to the 'save' endpoint with the background HTTP requests that already exist - namely, the 'ping' endpoint for concurrent editing notifications, and live previews?
* Is there a need to support user-defined server-side logic within the "updating form state" mechanism, or is it sufficient to support inline formsets?
* How should we handle POST requests that never complete or fail with a non-JSON response - due to a loss of network connection, for example? In this situation there is no way to know whether the save actually occurred, and thus whether it is safe to resubmit in the case that the previous save involved creating objects such as InlinePanel children. (This is probably equally true for regular manual saves, though.)
Copy link
Member

@laymonage laymonage May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking out loud here: maybe there's value in storing the last timestamp (or counter that's always incremented in the client) of a successful autosave on the server and returning it to the client, to be used by the client for the next submission, so the server knows whether there was a blip since the last submission?

Might also be something that we can fit into/rework from the existing EditingSession model, as it has similar fields.

The changes will be as follows:

* On receiving a POST request with an `Accepts:` header that does _not_ include `text/html`, the view will return a JSON response instead of the standard behaviour of returning a redirect (on success) or an HTML page (on failing validation), and no notification messages will be added to the user session.
* The edit views will accept a new optional POST parameter `overwrite_revision_id`. When this is passed, a successful save will update the `Revision` record with the given ID instead of creating a new revision. An error response will be returned if the revision does not belong to the object being edited or the current user, or the object being edited has a revision with a newer timestamp than the one given (which would indicate that there has been a conflicting edit in another editing session).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since overwrite_revision_id is only populated after the first successful autosave, this means there's still a chance of conflict if someone has saved a new revision since the editor was loaded, but before the autosave request is sent.

With the approach described in the current RFC, the server will happily create a new revision, which the client will use as overwrite_revision_id, ignoring the new revision made by the other session.

The "concurrent editing notifications" feature #95 can help mitigate this, but only if the "ping" request has been sent to detect the new revision before the first autosave. However, given that the feature works at a configurable interval (and can be turned off), we cannot rely on it.

If the desired behaviour is to prevent conflict in such cases, we need to include an optional POST value loaded_revision_id for the revision ID that was loaded when the form was rendered.

Then, assuming overwrite_revision_id is always newer than loaded_revision_id, we detect conflicts with the following logic:

submitted_revision_id = request.POST.get("overwrite_revision_id") or request.POST.get("loaded_revision_id")
has_conflict = submitted_revision_id and (submitted_revision_id != str(page.latest_revision_id))

In the non-autosaving case, the loaded_revision_id is still useful to fix wagtail/wagtail#2565.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above change has now been implemented in wagtail/wagtail#13684.

However, the above isn't completely bulletproof:

We only check for the loaded_revision_id, but we don't check whether that revision hasn't been updated at the time of saving. Consider the following scenario:

  1. User A loaded the editor and made a successful autosave, setting their overwrite_revision_id to 2.
  2. User B loaded the editor afterwards, so their loaded_revision_id is 2.
  3. User A made further edits, so revision with ID 2 has now been updated (thus becomes different from what user B loaded).
  4. User B made some changes, triggering an autosave request with loaded_revision_id = 2 and overwrite_revision_id = null.

In this case, the server will happily create revision with ID 3 as User B's overwrite_revision_id. User A will be prevented from making further autosave (as their overwrite_revision_id = 2 is older than the latest_revision_id = 3). However, in step 4, User B would have unknowingly overwritten User A's changes in step 3.

Concurrent editing notifications won't help in this case, as it suffers from the same issue – it assumes the initially loaded revision 2 hasn't changed.

To help mitigate this, we need something else other than the ID to discriminate between an untouched revision vs an overwritten one. We could make use of the created_at timestamp (which gets updated when the revision is overwritten), or introduce a new field (e.g. an overwrite counter, or a UUID).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants