Repository: wagtail/wagtail (python)

Issue:
wagtail 7.3 inline + revisionMixin duplicates content

### Issue summary

I upgraded my project I'm developing to 7.3 
I have a model called products and encountered strange behaviour with inline model when the RevisionMixin is used.
On save a inline row get duplicated once.

```python
class Product(index.Indexed, RevisionMixin, ClusterableModel):
     ...

     doc_panels = [
        InlinePanel("documents", label=_("Documenten")),
    ]
```

My doc_panel is a inline model. Where the user can upload multiple documents to his product.
When I use the UI to upload 1 document and I go back to look 2 items are created. Each time I upload a item a duplication is made. When I remove the RevisionMixin from the Product() then this behavior stops.

After searching what the hell went wrong I decided to **downgrade** to from 7.3 to **Wagtail 7.2.** The problem went away


### Steps to reproduce

```python
class Product(index.Indexed, RevisionMixin, ClusterableModel):
     name = models.CharField(_("Name"), max_length=255)
     doc_panels = [
        InlinePanel("documents", label=_("Documenten")),
    ]

    edit_handler = TabbedInterface(
        [
            ObjectList(doc_panels, heading="Docs"),
        ]
    )

class ProductDocument(Orderable):
        product = ParentalKey(Product, related_name="documents", on_delete=models.CASCADE)
    class DocumentType(models.TextChoices):
        PRODUCT_FICHE = "product_fiche", _("Product Sheet")
        MANUAL = "manual", _("Manual")
    product = ParentalKey(Product, related_name="documents", on_delete=models.CASCADE)

    doc_type = models.CharField(
        _("Type document"),
        max_length=50,
        choices=DocumentType.choices,
        default=DocumentType.PRODUCT_FICHE,
    )
    document = models.ForeignKey(
        get_document_model(),
        verbose_name=_("Document"),
        on_delete=models.CASCADE,
        related_name="+",
    )
```

Hit save it will duplicate the the row on save. Workaround remove the RevisionMixin
   



### Additional information

_No response_

### Can be reproduced

Not confirmed

### Technical details

- Python version: Run `python --version`.
- Django version: Look in your requirements.txt, or run `pip show django | grep Version`.
- Wagtail version: Look at the bottom of the Settings menu in the Wagtail admin, or run `pip show wagtail | grep Version:`.
- Browser version: You can use https://www.whatsmybrowser.org/ to find this out.


### Working on this

If you would like to contribute to this issue, follow these steps:
- Confirm that the issue is reproducible, either on a fresh Wagtail project or the [bakerydemo](https://github.com/wagtail/bakerydemo).
- Once confirmed, view our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you're ready to start.



Patch:
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 0123acc15287..ebfb2c63c7fd 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -34,6 +34,12 @@ Changelog
  * Maintenance: Fix `PermissionError` on document serve tests under Windows (Matt Westcott)
 
 
+7.3.1 (xx.xx.xxxx) - IN DEVELOPMENT
+~~~~~~~~~~~~~~~~~~
+
+ * Fix: Fix duplicate inline panel items when editing snippets with autosave enabled (Sage Abdullah)
+
+
 7.3 (03.02.2026)
 ~~~~~~~~~~~~~~~~
 
diff --git a/docs/releases/7.3.1.md b/docs/releases/7.3.1.md
new file mode 100644
index 000000000000..8a4a128fed44
--- /dev/null
+++ b/docs/releases/7.3.1.md
@@ -0,0 +1,29 @@
+# Wagtail 7.3.1 release notes - IN DEVELOPMENT
+
+_Unreleased_
+
+```{contents}
+---
+local:
+depth: 1
+---
+```
+
+## What's new
+
+
+### Other features
+
+ * ...
+
+### Bug fixes
+
+ * Fix duplicate inline panel items when editing snippets with autosave enabled (Sage Abdullah)
+
+### Documentation
+
+ * ...
+
+### Maintenance
+
+ * ...
diff --git a/docs/releases/index.rst b/docs/releases/index.rst
index 84c37d253d6a..9bf1158cfb79 100644
--- a/docs/releases/index.rst
+++ b/docs/releases/index.rst
@@ -7,6 +7,7 @@ Release notes
    upgrading
    release_process
    7.4
+   7.3.1
    7.3
    7.2.2
    7.2.1
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
index eedd67e7d183..2c5a5d6493f8 100644
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -623,8 +623,9 @@ def has_unsaved_changes(self):
         return self.form.is_bound
 
     def get_context_data(self, **kwargs):
+        self.form = self.form if hasattr(self, "form") else self.get_form()
+        kwargs["form"] = self.form
         context = super().get_context_data(**kwargs)
-        self.form = context.get("form")
         side_panels = self.get_side_panels()
         context["action_url"] = self.add_url
         context["submit_button_label"] = self.submit_button_label
@@ -1082,8 +1083,18 @@ def hydrate_create_view(self):
         return bool(self.request.GET.get("_w_hydrate_create_view"))
 
     def get_context_data(self, **kwargs):
+        # We want to set self.form for use in other methods/properties below.
+        # If form is not passed as a kwarg to super(), Django instantiates a new
+        # form and adds it to the context (without setting self.form). This is
+        # normal for GET requests. However, for POST requests (e.g. when
+        # rendering template partials after autosave), we already instantiated
+        # the form and set self.form. Make sure to reuse the form instead of
+        # letting Django instantiate another one (and worse, overwriting
+        # self.form with the fresh instance). For consistency, we instantiate
+        # the form ourselves for GET requests too, and pass it to super().
+        self.form = self.form if hasattr(self, "form") else self.get_form()
+        kwargs["form"] = self.form
         context = super().get_context_data(**kwargs)
-        self.form = context.get("form")
         side_panels = self.get_side_panels()
         context["action_url"] = self.get_edit_url()
         context["history_url"] = self.get_history_url()


Test patch:
diff --git a/wagtail/snippets/tests/test_create_view.py b/wagtail/snippets/tests/test_create_view.py
index 6ff9ba14454d..3c2d9bcf08d7 100644
--- a/wagtail/snippets/tests/test_create_view.py
+++ b/wagtail/snippets/tests/test_create_view.py
@@ -25,9 +25,11 @@
 from wagtail.test.testapp.models import (
     Advert,
     DraftStateModel,
+    RevisableCluster,
     RevisableModel,
 )
 from wagtail.test.utils import WagtailTestUtils
+from wagtail.test.utils.form_data import inline_formset, nested_form_data
 from wagtail.test.utils.timestamps import submittable_timestamp
 
 
@@ -219,6 +221,36 @@ def test_create_with_json_response(self):
             f"{edit_url}?_w_hydrate_create_view=1",
         )
 
+    def test_create_with_inline_models_and_json_response(self):
+        form_data = nested_form_data(
+            {
+                "text": "Created with one child",
+                "children": inline_formset([{"id": "", "text": "Child 1"}]),
+            }
+        )
+        response = self.post(
+            post_data=form_data,
+            model=RevisableCluster,
+            headers={"Accept": "application/json"},
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response["Content-Type"], "application/json")
+
+        snippets = RevisableCluster.objects.filter(text="Created with one child")
+        self.assertEqual(snippets.count(), 1)
+        snippet = snippets.first()
+        self.assertEqual(snippet.children.count(), 1)
+        child = snippet.children.first()
+        self.assertEqual(child.text, "Child 1")
+
+        response_json = response.json()
+        self.assertEqual(response_json["success"], True)
+        self.assertEqual(response_json["pk"], snippet.pk)
+        self.assertEqual(
+            response_json["field_updates"],
+            {"children-INITIAL_FORMS": "1", "children-0-id": str(child.pk)},
+        )
+
     def test_create_with_tags(self):
         tags = ["hello", "world"]
         response = self.post(
diff --git a/wagtail/snippets/tests/test_edit_view.py b/wagtail/snippets/tests/test_edit_view.py
index 587ea4e01df2..0291a6b9fbf6 100644
--- a/wagtail/snippets/tests/test_edit_view.py
+++ b/wagtail/snippets/tests/test_edit_view.py
@@ -35,9 +35,11 @@
     DraftStateModel,
     FullFeaturedSnippet,
     PreviewableModel,
+    RevisableCluster,
     RevisableModel,
 )
 from wagtail.test.utils import WagtailTestUtils
+from wagtail.test.utils.form_data import inline_formset, nested_form_data
 from wagtail.test.utils.timestamps import submittable_timestamp
 from wagtail.utils.timestamps import render_timestamp
 
@@ -986,6 +988,32 @@ def test_edit_snippet_with_revision_and_json_response(self):
         self.assertEqual(log_entries.count(), 1)
         self.assertEqual(log_entries.first().revision, revision)
 
+    def test_edit_with_inline_models_and_json_response(self):
+        self.test_snippet = RevisableCluster.objects.create(text="Test for inline")
+        form_data = nested_form_data(
+            {
+                "text": "Edited and added one child",
+                "children": inline_formset([{"id": "", "text": "Child 1"}]),
+            }
+        )
+        response = self.post(
+            post_data=form_data,
+            headers={"Accept": "application/json"},
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response["Content-Type"], "application/json")
+        self.assertEqual(self.test_snippet.children.count(), 1)
+        child = self.test_snippet.children.first()
+        self.assertEqual(child.text, "Child 1")
+
+        response_json = response.json()
+        self.assertEqual(response_json["success"], True)
+        self.assertEqual(response_json["pk"], self.test_snippet.pk)
+        self.assertEqual(
+            response_json["field_updates"],
+            {"children-INITIAL_FORMS": "1", "children-0-id": str(child.pk)},
+        )
+
     def test_save_outdated_revision_with_json_response(self):
         self.test_snippet.text = "Initial revision"
         revision = self.test_snippet.save_revision(user=self.user)
diff --git a/wagtail/test/testapp/migrations/0061_revisablecluster_revisableclusterchild.py b/wagtail/test/testapp/migrations/0061_revisablecluster_revisableclusterchild.py
new file mode 100644
index 000000000000..52d1590937fc
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0061_revisablecluster_revisableclusterchild.py
@@ -0,0 +1,68 @@
+# Generated by Django 5.2.10 on 2026-02-16 14:54
+
+import django.db.models.deletion
+import modelcluster.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0060_eventpagespeakeraward_certificate"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="RevisableCluster",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("text", models.TextField()),
+                (
+                    "latest_revision",
+                    models.ForeignKey(
+                        blank=True,
+                        editable=False,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="wagtailcore.revision",
+                        verbose_name="latest revision",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="RevisableClusterChild",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("text", models.TextField()),
+                (
+                    "parent",
+                    modelcluster.fields.ParentalKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="children",
+                        to="tests.revisablecluster",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
index 18a779e85a33..c16cba59142f 100644
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -1319,7 +1319,7 @@ class FullFeaturedSnippet(
     TranslatableMixin,
     Orderable,
     index.Indexed,
-    models.Model,
+    ClusterableModel,
 ):
     class CountryCode(models.TextChoices):
         INDONESIA = "ID"
@@ -1386,6 +1386,30 @@ class Meta(TranslatableMixin.Meta):
         verbose_name_plural = "full-featured snippets"
 
 
+class RevisableCluster(RevisionMixin, ClusterableModel):
+    text = models.TextField()
+
+    panels = ["text", "children"]
+
+    def __str__(self):
+        return self.text
+
+
+class RevisableClusterChild(models.Model):
+    parent = ParentalKey(
+        RevisableCluster,
+        related_name="children",
+        on_delete=models.CASCADE,
+    )
+    text = models.TextField()
+
+    def __str__(self):
+        return self.text
+
+
+register_snippet(RevisableCluster)
+
+
 def get_default_advert():
     return Advert.objects.first()