diff --git a/client/src/entrypoints/admin/page-chooser-modal.js b/client/src/entrypoints/admin/page-chooser-modal.js
index 381bcc3074d2..a7ee3539f9a7 100644
--- a/client/src/entrypoints/admin/page-chooser-modal.js
+++ b/client/src/entrypoints/admin/page-chooser-modal.js
@@ -69,6 +69,16 @@ const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
    function updateMultipleChoiceSubmitEnabledState(

@@ -95,16 +105,11 @@ const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
      u
      $

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 602d849422ba..0eef17a620a1 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -29,6 +29,7 @@ Changelog
  * Fix: Ensure the accessible labels and tooltips reflect the correct private/public status on the live link button within pages after changing the privacy (Ayaan Qadri)
  * Fix: Fix empty `th` (table heading) elements that are not compliant with accessibility standards (Jai Vignesh J)
  * Fix: Ensure `MultipleChooserPanel` using images or documents work when nested within an `InlinePanel` when no other choosers are in use within the model (Elhussein Almasri)
+ * Fix: Ensure `MultipleChooserPanel` works after doing a search in the page chooser modal (Matt Westcott)
  * Fix: Ensure new `ListBlock` instances get created with unique IDs in the admin client for accessibility and mini-map element references (Srishti Jaiswal)
  * Docs: Move the model reference page from reference/pages to the references section as it covers all Wagtail core models (Srishti Jaiswal)
  * Docs: Move the panels reference page from references/pages to the references section as panels are available for any model editing, merge panels API into this page (Srishti Jaiswal)
diff --git a/client/src/entrypoints/admin/page-chooser-modal.js b/client/src/entrypoints/admin/page-chooser-modal.js
index 381bcc3074d2..a7ee3539f9a7 100644
--- a/client/src/entrypoints/admin/page-chooser-modal.js
+++ b/client/src/entrypoints/admin/page-chooser-modal.js
@@ -69,6 +69,16 @@ const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
       $(this).data('timer', wait);
     });

+    function updateMultipleChoiceSubmitEnabledState() {
+      // update the enabled state of the multiple choice submit button depending on whether
+      // any items have been selected
+      if ($('[data-multiple-choice-select]:checked', modal.body).length) {
+        $('[data-multiple-choice-submit]', modal.body).removeAttr('disabled');
+      } else {
+        $('[data-multiple-choice-submit]', modal.body).attr('disabled', true);
+      }
+    }
+
     /* Set up behaviour of choose-page links in the newly-loaded search results,
     to pass control back to the calling page */
     function ajaxifySearchResults() {
@@ -95,16 +105,11 @@ const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
         modal.loadUrl(this.href);
         return false;
       });
-    }

-    function updateMultipleChoiceSubmitEnabledState() {
-      // update the enabled state of the multiple choice submit button depending on whether
-      // any items have been selected
-      if ($('[data-multiple-choice-select]:checked', modal.body).length) {
-        $('[data-multiple-choice-submit]', modal.body).removeAttr('disabled');
-      } else {
-        $('[data-multiple-choice-submit]', modal.body).attr('disabled', true);
-      }
+      updateMultipleChoiceSubmitEnabledState();
+      $('[data-multiple-choice-select]', modal.body).on('change', () => {
+        updateMultipleChoiceSubmitEnabledState();
+      });
     }

     function ajaxifyBrowseResults() {
diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md
index 57e2b9b9600f..c6aa5b5c00c2 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -41,6 +41,7 @@ depth: 1
  * Ensure the accessible labels and tooltips reflect the correct private/public status on the live link button within pages after changing the privacy (Ayaan Qadri)
  * Fix empty `th` (table heading) elements that are not compliant with accessibility standards (Jai Vignesh J)
  * Ensure `MultipleChooserPanel` using images or documents work when nested within an `InlinePanel` when no other choosers are in use within the model (Elhussein Almasri)
+ * Ensure `MultipleChooserPanel` works after doing a search in the page chooser modal (Matt Westcott)
  * Ensure new `ListBlock` instances get created with unique IDs in the admin client for accessibility and mini-map element references (Srishti Jaiswal)

 ### Documentation
diff --git a/wagtail/admin/templates/wagtailadmin/chooser/browse.html b/wagtail/admin/templates/wagtailadmin/chooser/browse.html
index e421226ecf81..66caccc3daf1 100644
--- a/wagtail/admin/templates/wagtailadmin/chooser/browse.html
+++ b/wagtail/admin/templates/wagtailadmin/chooser/browse.html
@@ -5,7 +5,8 @@
     {% trans "Choose a page" as choose_str %}
 {% endif %}

-{% include "wagtailadmin/shared/header.html" with title=choose_str subtitle=page_type_names|join:", " search_url="wagtailadmin_choose_page_search" query_parameters="page_type="|add:page_type_string icon="doc-empty-inverse" search_disable_async=True %}
+{% querystring as search_query_params %}
+{% include "wagtailadmin/shared/header.html" with title=choose_str subtitle=page_type_names|join:", " search_url="wagtailadmin_choose_page_search" query_parameters=search_query_params icon="doc-empty-inverse" search_disable_async=True %}

     {% include 'wagtailadmin/chooser/_link_types.html' with current='internal' %}
diff --git a/wagtail/admin/templates/wagtailadmin/chooser/tables/parent_page_cell.html b/wagtail/admin/templates/wagtailadmin/chooser/tables/parent_page_cell.html
index c8aa24d937f6..ad754ae243c6 100644
--- a/wagtail/admin/templates/wagtailadmin/chooser/tables/parent_page_cell.html
+++ b/wagtail/admin/templates/wagtailadmin/chooser/tables/parent_page_cell.html
@@ -1,7 +1,7 @@
 {% load wagtailadmin_tags %}

     {% if value %}
-        {{ value.get_admin_display_title }}
+        {{ value.get_admin_display_title }}
         {% if show_locale_labels %}{% status value.locale.get_display_name classname="w-status--label" %}{% endif %}
     {% endif %}

diff --git a/wagtail/admin/templates/wagtailadmin/shared/header.html b/wagtail/admin/templates/wagtailadmin/shared/header.html
index 8f2400656872..9095b3757a2d 100644
--- a/wagtail/admin/templates/wagtailadmin/shared/header.html
+++ b/wagtail/admin/templates/wagtailadmin/shared/header.html
@@ -11,7 +11,7 @@
     - `search_results_url` - URL to be used for async requests to search results, if not provided, the form's action URL will be used
     - `search_target` - A selector string to be used as the target for the search results to be swapped into. Defaults to '#listing-results'
     - `search_disable_async` - If True, the default header async search functionality will not be used
-    - `query_parameters` - a query string (without the '?') to be placed after the search URL
+    - `query_parameters` - a query string (with or without the '?') to be placed after the search URL
     - `icon` - name of an icon to place against the title
     - `merged` - if true, add the classname 'w-header--merged'
     - `action_url` - if present, display an 'action' button. This is the URL to be used as the link URL for the button
@@ -42,7 +42,7 @@

             {% if search_url %}
                 <form
                     class="col search-form"
-                    action="{% url search_url %}{% if query_parameters %}?{{ query_parameters }}{% endif %}"
+                    action="{% url search_url %}{% if query_parameters %}{% if query_parameters.0 != '?' %}?{% endif %}{{ query_parameters }}{% endif %}"
                     method="get"
                     novalidate
                     role="search"
diff --git a/wagtail/admin/views/chooser.py b/wagtail/admin/views/chooser.py
index 8ec9dde8b967..66c5e63e7951 100644
--- a/wagtail/admin/views/chooser.py
+++ b/wagtail/admin/views/chooser.py
@@ -408,7 +408,11 @@ class SearchView(View):
     @property
     def columns(self):
         cols = [
-            PageTitleColumn("title", label=_("Title")),
+            PageTitleColumn(
+                "title",
+                label=_("Title"),
+                is_multiple_choice=self.is_multiple_choice,
+            ),
             ParentPageColumn("parent", label=_("Parent")),
             DateColumn(
                 "updated",

Enable/disable the submit button based on checkbox selections and attach change event handlers to checkboxes in search results.

Prompt:
The following is the text of a github issue and related comments for this repository:

Uncaught TypeError in page-editor.js - MultipleChooserPanel
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

I have selection of authors for a book in my site

![Screenshot 2024-05-14 at 15 51 11](https://github.com/wagtail/wagtail/assets/10074977/e0883697-3622-43c9-8f18-17cc1e15384e)

And I can select whichever one without issue!

The problem comes when I try to filter by author name, the filtering occurs without any problems but I cannot select any of the authors from the output.

The result is correct, I get the color change on hover meaning I can click on it:

![Screenshot 2024-05-14 at 15 54 27](https://github.com/wagtail/wagtail/assets/10074977/27938ae7-caea-4fb3-aef0-7c72c3f8f0e1)

But when I do I get this in the console making reference to `page-editor.js`:

```Uncaught TypeError: e.forEach is not a function
    at chooserWidgetFactory.openModal.multiple (page-editor.js?v=0055a0ab:1:5607)
    at Object.pageChosen (vendor.js?v=0055a0ab:2:260739)
    at o.respond (modal-workflow.js?v=0055a0ab:1:2056)
    at HTMLAnchorElement.<anonymous> (page-chooser-modal.js?v=0055a0ab:1:758)
    at HTMLAnchorElement.dispatch (jquery-3.6.0.min.js?v=0055a0ab:2:43003)
    at v.handle (jquery-3.6.0.min.js?v=055a0ab:2:40998)
```

### Steps to Reproduce

Assuming you have a working Wagtail app running:

1. Go to edit any page with a MultipleChooserPanel (classic example of a Blog Post with an Author)
2. Filter by the author name
3. See you cannot select any of the given results after the filtering is done.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: no

### Technical details

-   Python version: `3.9.15`
-   Django version: `4.2.11`
-   Wagtail version: `5.2.3`
-   Browser version: `Chrome 124`

### Working on this

In the [original conversation](https://wagtailcms.slack.com/archives/C81FGJR2S/p1715695098743519) on the Slack support channel @lb- mentioned that his guess was that the chooser JS is not being reinstated after the filtering occurs.

The checkboxes are missing from the second screenshot, which probably means that the URL parameter that enables multiple choice has not been preserved when loading the filtered results - it then ends up returning a single result (rather than the expected array of results) to the calling page, which fails as the calling page tries to loop over the results.

FYI, in case it helps, the behaviour is correct when you try to change an existing author.

If you have a selected item in your MultipleChooserPanel and want to change it, using the menu option "Choose another page" you get the pop-up/modal and filtering works there without issue.
I still don't get the checkboxes but I can click the results and the value changes correctly.

Several bugs to fix:

 - The search bar's action URL needs to preserve all URL parameters from the initial opening of the modal, not just page_types - this includes multiple (so that we don't drop back to single-select mode after a search) but also others that influence the results, such as user_perms (without this, the parent page chooser in the "move page" view will let you choose pages in search results that are greyed out while in browse mode) as well as allow_external_links and friends (which are not used directly by the search results view, but need to be passed on through the parent page link so that we don't lose the top navigation when we return to browse mode).
 - ajaxifySearchResults was not attaching the event handler to the checkboxes for enabling/disabling the "confirm selection" button, so it was never being enabled
 - The parent page link in the search results was not preserving any URL params - it needs to preserve all except the search query q and page number p.

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/wagtail/admin/tests/test_page_chooser.py b/wagtail/admin/tests/test_page_chooser.py
index d054f8b22723..5ec5cfbdba00 100644
--- a/wagtail/admin/tests/test_page_chooser.py
+++ b/wagtail/admin/tests/test_page_chooser.py
@@ -65,12 +65,20 @@ def test_multiple_chooser_view(self):

         checkbox_value = str(self.child_page.id)
         decoded_content = response.content.decode()
+        response_json = json.loads(decoded_content)
+        self.assertEqual(response_json["step"], "browse")
+        response_html = response_json["html"]

-        self.assertIn(f'value=\\"{checkbox_value}\\"', decoded_content)
+        self.assertIn(f'value="{checkbox_value}"', response_html)

         self.assertEqual(response.status_code, 200)
         self.assertTemplateUsed(response, "wagtailadmin/chooser/browse.html")

+        soup = self.get_soup(response_html)
+        search_url = soup.find("form", role="search")["action"]
+        search_query_params = parse_qs(urlsplit(search_url).query)
+        self.assertEqual(search_query_params["multiple"], ["1"])
+
     @override_settings(USE_THOUSAND_SEPARATOR=False)
     def test_multiple_chooser_view_without_thousand_separator(self):
         self.page = Page.objects.get(id=1)
@@ -363,12 +371,22 @@ def get(self, params=None):
         return self.client.get(reverse("wagtailadmin_choose_page_search"), params or {})

     def test_simple(self):
-        response = self.get({"q": "foobarbaz"})
+        response = self.get({"q": "foobarbaz", "allow_external_link": "true"})
         self.assertEqual(response.status_code, 200)
         self.assertTemplateUsed(response, "wagtailadmin/chooser/_search_results.html")
         self.assertContains(response, "There is 1 match")
         self.assertContains(response, "foobarbaz")

+        # parent page link should preserve the allow_external_link parameter
+        expected_url = (
+            reverse("wagtailadmin_choose_page_child", args=[self.root_page.id])
+            + "?allow_external_link=true"
+        )
+        self.assertContains(
+            response,
+            f'{self.root_page.title}',
+        )
+
     def test_partial_match(self):
         response = self.get({"q": "fooba"})
         self.assertEqual(response.status_code, 200)


diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index cfeea825126a..4411028ac2b8 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -60,6 +60,7 @@ Changelog
  * Maintenance: Migrate jQuery class toggling & drag/drop event handling within the multiple upload views to the Stimulus ZoneController usage (Ayaan Qadri)
  * Maintenance: Test project template for warnings when run against Django pre-release versions (Sage Abdullah)
  * Maintenance: Refactor redirects create/delete views to use generic views (Sage Abdullah)
+ * Maintenance: Add JSDoc description, adopt linting recommendations, and add more unit tests for `ModalWorkflow` (LB (Ben) Johnston)

 6.3.2 (xx.xx.xxxx) - IN DEVELOPMENT
diff --git a/client/src/entrypoints/admin/modal-workflow.js b/client/src/entrypoints/admin/modal-workflow.js
index 2955f763eee3..54737ed19369 100644
--- a/client/src/entrypoints/admin/modal-workflow.js
+++ b/client/src/entrypoints/admin/modal-workflow.js
@@ -91,15 +91,15 @@ function ModalWorkflow(opts) {
   };

   self.ajaxifyForm = function ajaxifyForm(formSelector) {
-    $(formSelector).each(() => {
+    $(formSelector).each(function ajaxifyFormInner() {
       const action = this.action;
       if (this.method.toLowerCase() === 'get') {
-        $(this).on('submit', () => {
+        $(this).on('submit', function handleSubmit() {
           self.loadUrl(action, $(this).serialize());
           return false;
         });
       } else {
-        $(this).on('submit', () => {
+        $(this).on('submit', function handleSubmit() {
           self.postForm(action, $(this).serialize());
           return false;
         });
diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md
index 5dff2827c18f..a77da22022e3 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -79,6 +79,7 @@ depth: 1
  * Migrate jQuery class toggling & drag/drop event handling within the multiple upload views to the Stimulus ZoneController usage (Ayaan Qadri)
  * Test project template for warnings when run against Django pre-release versions (Sage Abdullah)
  * Refactor redirects create/delete views to use generic views (Sage Abdullah)
+ * Add JSDoc description, adopt linting recommendations, and add more unit tests for `ModalWorkflow` (LB (Ben) Johnston)

 ## Upgrade considerations - changes affecting all projects

Replace arrow functions with regular functions to maintain the correct `this` binding in form submissions.

Prompt:
The following is the text of a github issue and related comments for this repository:

Page choosers no longer working on `main`
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

The page chooser modal will not respond to any clicks when choosing a page, and an error is thrown in the console:

`Uncaught TypeError: Cannot read properties of undefined (reading 'toLowerCase')
    at HTMLFormElement.eval (modal-workflow.js:81:1)
    at Function.each (jquery-3.6.0.min.js?v=bd94a3b0:2:3003)
    at S.fn.init.each (jquery-3.6.0.min.js?v=bd94a3b0:2:1481)
    at Object.ajaxifyForm (modal-workflow.js:79:1)
    at Object.browse (page-chooser-modal.js:18:1)
    at Object.loadBody (modal-workflow.js:113:1)
    at Object.loadResponseText [as success] (modal-workflow.js:97:1)
    at c (jquery-3.6.0.min.js?v=bd94a3b0:2:28327)
    at Object.fireWith [as resolveWith] (jquery-3.6.0.min.js?v=bd94a3b0:2:29072)
    at l (jquery-3.6.0.min.js?v=bd94a3b0:2:79901)

### Steps to Reproduce

1. Spin up bakerydemo
2. Edit the "Welcome to the Wagtail bakery!" page
3. On the "Hero CTA link", click on the ... button > Choose another page
4. Try to choose any other page
5. Observe that the modal stays open and the error is thrown in the console

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead? The modal should be closed and the newly chosen page should replace the previously chosen one.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes on bakerydemo

### Working on this

This is a confirmed regression in #12380, specifically the changes in `ajaxifyForm` where the anonymous function is replaced with an arrow function, which results in a different object being bound to `this`.

There are other similar changes in the PR, so there might be other choosers/modals broken as a result.

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap b/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap
index a5f1675e428f..42497f9a15d6 100644
--- a/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap
+++ b/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap
@@ -45,6 +45,18 @@ HTMLCollection [
           >
             path/to/endpoint

+
+
+          <form
+            action="/path/to/form/submit"
+            id="form"
+            method="post"
+          >
+            <input
+              name="key"
+              value="value"
+            />
+          </form>

diff --git a/client/src/entrypoints/admin/modal-workflow.test.js b/client/src/entrypoints/admin/modal-workflow.test.js
index 0a5235f50362..bcfa56bcc07e 100644
--- a/client/src/entrypoints/admin/modal-workflow.test.js
+++ b/client/src/entrypoints/admin/modal-workflow.test.js
@@ -8,13 +8,29 @@ import './modal-workflow';

 $.get = jest.fn().mockImplementation((url, data, cb) => {
   cb(
-    JSON.stringify({ html: `<div id="url">${url}</div>`, data, step: 'start' }),
+    JSON.stringify({
+      data,
+      html: `
${url}

+      <form id="form" method="post" action="/path/to/form/submit"><input name="key" value="value"></input></form>`,
+      step: 'start',
+    }),
+  );
+  return { fail: jest.fn() };
+});
+
+$.post = jest.fn((url, data, cb) => {
+  cb(
+    JSON.stringify({
+      html: '
response
',
+    }),
   );
   return { fail: jest.fn() };
 });

 describe('modal-workflow', () => {
   beforeEach(() => {
+    jest.clearAllMocks();
+
     document.body.innerHTML =
       '
<button data-chooser-action-choose id="trigger">Open</button>
';
   });
@@ -102,6 +118,31 @@ describe('modal-workflow', () => {
     expect(triggerButton.disabled).toBe(false);
   });

+  it('should expose a `ajaxifyForm` method that allows forms to be submitted async', () => {
+    expect($.post).toHaveBeenCalledTimes(0);
+    expect(document.querySelectorAll('.modal-body #response')).toHaveLength(0);
+
+    const modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' });
+
+    modalWorkflow.ajaxifyForm('#form');
+
+    const event = new Event('submit');
+    event.preventDefault = jest.fn();
+    document.getElementById('form').dispatchEvent(event);
+
+    expect(event.preventDefault).toHaveBeenCalled();
+
+    expect($.post).toHaveBeenCalledTimes(1);
+    expect($.post).toHaveBeenCalledWith(
+      'http://localhost/path/to/form/submit',
+      'key=value',
+      expect.any(Function),
+      'text',
+    );
+
+    expect(document.querySelectorAll('.modal-body #response')).toHaveLength(1);
+  });
+
   it('should handle onload and URL param options', () => {
     const onload = { start: jest.fn() };
     const urlParams = { page: 23 };
@@ -125,12 +166,15 @@ describe('modal-workflow', () => {

     expect(modalWorkflow).toBeInstanceOf(Object);

-    // important: see mock implementation above, returning a response with injected data to validate behaviour
+    // important: see mock implementation above, returning a response with injected data to validate behavior
     const response = {
       data: urlParams,
-      html: '
path/to/endpoint
',
+      html: expect.stringContaining('
path/to/endpoint
'),
       step: 'start',
     };
-    expect(onload.start).toHaveBeenCalledWith(modalWorkflow, response);
+    expect(onload.start).toHaveBeenCalledWith(
+      modalWorkflow,
+      expect.objectContaining(response),
+    );
   });
 });


diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 0eef17a620a1..98757878477c 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -87,6 +87,7 @@ Changelog
  * Maintenance: Use the Stimulus `ZoneController` (`w-zone`) to remove ad-hoc jQuery for the privacy switch when toggling visibility of private/public elements (Ayaan Qadri)
  * Maintenance: Remove unused `is_active` & `active_menu_items` from `wagtail.admin.menu.MenuItem` (Srishti Jaiswal)
  * Maintenance: Only call `openpyxl` at runtime to improve performance for projects that do not use `ReportView`, `SpreadsheetExportMixin` and `wagtail.contrib.redirects` (Sébastien Corbin)
+ * Maintenance: Adopt the update value `mp` instead of `mm` for 'mystery person' as the default Gravatar if no avatar found (Harsh Dange)

 6.3.2 (xx.xx.xxxx) - IN DEVELOPMENT
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 1fbd97cac3be..3a93220731b3 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -858,6 +858,7 @@
 * Noah van der Meer
 * Strapchay
 * Alex Fulcher
+* Harsh Dange

 ## Translators

diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md
index c6aa5b5c00c2..b043aab52ddb 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -105,6 +105,7 @@ depth: 1
  * Use the Stimulus `ZoneController` (`w-zone`) to remove ad-hoc jQuery for the privacy switch when toggling visibility of private/public elements (Ayaan Qadri)
  * Remove unused `is_active` & `active_menu_items` from `wagtail.admin.menu.MenuItem` (Srishti Jaiswal)
  * Only call `openpyxl` at runtime to improve performance for projects that do not use `ReportView`, `SpreadsheetExportMixin` and `wagtail.contrib.redirects` (Sébastien Corbin)
+ * Adopt the update value `mp` instead of `mm` for 'mystery person' as the default Gravatar if no avatar found (Harsh Dange)

 ## Upgrade considerations - changes affecting all projects

diff --git a/wagtail/users/utils.py b/wagtail/users/utils.py
index ef3f0e6bb9c1..b3eee7970d07 100644
--- a/wagtail/users/utils.py
+++ b/wagtail/users/utils.py
@@ -26,7 +26,7 @@ def user_can_delete_user(current_user, user_to_delete):

 def get_gravatar_url(email, size=50):
-    default = "mm"
+    default = "mp"
     size = (
         int(size) * 2
     )  # requested at retina size by default and scaled down at point of use with css

Prompt:
The following is the text of a github issue and related comments for this repository:

Default URL param value for Gravatar URL have been deprecated (`mm` -> `mp`)
### Issue Summary

We currently pass in `mm` to the `d` (default) param, this is used to determine what avatar will show if there's no matching avatar. However, the latest documentation advises that this should be `mp` (mystery person) instead.

https://github.com/wagtail/wagtail/blob/c2676af857a41440e05e03038d85a540dcca3ce2/wagtail/users/utils.py#L28-L29

https://github.com/wagtail/wagtail/blob/c2676af857a41440e05e03038d85a540dcca3ce2/wagtail/users/utils.py#L45

https://docs.gravatar.com/api/avatars/images/#default-image

### Describe the solution you'd like

Update the param value from `mm` to `mp` and ensure any unit tests are updated.

This way, if the support for this legacy value gets dropped, it will not be a breaking change for Wagtail users.

### Describe alternatives you've considered

It might be nice to have a better approach to this by allowing the param to be passed into the function / overridden somehow. Best to discuss that in a different issue though - see https://github.com/wagtail/wagtail/issues/12659

### Additional context

Two PRs have attempted this (and other changes), see the feedback and the PRs for reference.

- #11077
- #11800

### Working on this

- Anyone can contribute to this, be sure you understand how to reproduce the avatar scenario.
- It might be good to tackle this small change before tackling the other related issues.
- 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.

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/wagtail/admin/tests/ui/test_sidebar.py b/wagtail/admin/tests/ui/test_sidebar.py
index e5c21bc7f049..f2e160144651 100644
--- a/wagtail/admin/tests/ui/test_sidebar.py
+++ b/wagtail/admin/tests/ui/test_sidebar.py
@@ -283,7 +283,7 @@ def test_adapt(self):
                     ],
                     {
                         "name": user.first_name or user.get_username(),
-                        "avatarUrl": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=100&d=mm",
+                        "avatarUrl": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=100&d=mp",
                     },
                 ],
             },


diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
index 636f7d13ef44..eac1265bb50a 100644
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -677,6 +677,16 @@ def setup(self, request, *args, **kwargs):
    @cached_property
    def object_pk(

@@ -687,9 +697,9 @@ def get_available_actions(self):
        # SingleObjectMixin.get_object looks for the unquoted pk in self.kwargs,
        # so we need to write it back there.
        self.kwargs[self.pk_url_kwarg] =
@@ -947,9 +957,15 @@ def get_object(self, queryset=None):

        # SingleObjectMixin.get_object looks for the unquoted pk in self.kwargs,
        # so we need to write it back there.
        try:
            quoted_pk =

        self.kwargs[self.pk_url_kwarg] =

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 4a5ad0704f70..20898fa5ff89 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -23,6 +23,7 @@ Changelog
  * Fix: Use correct connections on multi-database setups in database search backends (Jake Howard)
  * Fix: Ensure Cloudfront cache invalidation is called with a list, for compatibility with current botocore versions (Jake Howard)
  * Fix: Show the correct privacy status in the sidebar when creating a new page (Joel William)
+ * Fix: Prevent generic model edit view from unquoting non-integer primary keys multiple times (Matt Westcott)
  * Docs: Move the model reference page from reference/pages to the references section as it covers all Wagtail core models (Srishti Jaiswal)
  * Docs: Move the panels reference page from references/pages to the references section as panels are available for any model editing, merge panels API into this page (Srishti Jaiswal)
  * Docs: Move the tags documentation to standalone advanced topic, instead of being inside the reference/pages section (Srishti Jaiswal)
diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md
index 7c54a9ecd7dc..d6c931e5697c 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -36,6 +36,7 @@ depth: 1
  * Use correct connections on multi-database setups in database search backends (Jake Howard)
  * Ensure Cloudfront cache invalidation is called with a list, for compatibility with current botocore versions (Jake Howard)
  * Show the correct privacy status in the sidebar when creating a new page (Joel William)
+ * Prevent generic model edit view from unquoting non-integer primary keys multiple times (Matt Westcott)

 ### Documentation

diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
index 636f7d13ef44..eac1265bb50a 100644
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -677,6 +677,16 @@ def setup(self, request, *args, **kwargs):
         super().setup(request, *args, **kwargs)
         self.action = self.get_action(request)

+    @cached_property
+    def object_pk(self):
+        # Must be a cached_property to prevent this from being re-run on the unquoted
+        # pk written back by get_object, which would result in it being unquoted again.
+        try:
+            quoted_pk = self.kwargs[self.pk_url_kwarg]
+        except KeyError:
+            quoted_pk = self.args[0]
+        return unquote(str(quoted_pk))
+
     def get_action(self, request):
         for action in self.get_available_actions():
             if request.POST.get(f"action-{action}"):
@@ -687,9 +697,9 @@ def get_available_actions(self):
         return self.actions

     def get_object(self, queryset=None):
-        if self.pk_url_kwarg not in self.kwargs:
-            self.kwargs[self.pk_url_kwarg] = self.args[0]
-        self.kwargs[self.pk_url_kwarg] = unquote(str(self.kwargs[self.pk_url_kwarg]))
+        # SingleObjectMixin.get_object looks for the unquoted pk in self.kwargs,
+        # so we need to write it back there.
+        self.kwargs[self.pk_url_kwarg] = self.object_pk
         return super().get_object(queryset)

     def get_page_subtitle(self):
@@ -947,9 +957,15 @@ def get_object(self, queryset=None):
         # If the object has already been loaded, return it to avoid another query
         if getattr(self, "object", None):
             return self.object
-        if self.pk_url_kwarg not in self.kwargs:
-            self.kwargs[self.pk_url_kwarg] = self.args[0]
-        self.kwargs[self.pk_url_kwarg] = unquote(str(self.kwargs[self.pk_url_kwarg]))
+
+        # SingleObjectMixin.get_object looks for the unquoted pk in self.kwargs,
+        # so we need to write it back there.
+        try:
+            quoted_pk = self.kwargs[self.pk_url_kwarg]
+        except KeyError:
+            quoted_pk = self.args[0]
+        self.kwargs[self.pk_url_kwarg] = unquote(str(quoted_pk))
+
         return super().get_object(queryset)

     def get_usage(self):

Cache the unquoted primary key to prevent double unquoting, and update both `get_object` methods to properly use the cached value.

Prompt:
The following is the text of a github issue and related comments for this repository:

Snippets EditView unexpected double unquote
### Issue Summary

In case of EditView code snippets (I think this happens with all views as well), the get_object() method in wagtail/wagtail/admin/views/generic/models.py/EditView is called multiple times. This creates a problem when the object pk has quoted characters.

### Steps to Reproduce

1 Add object with pk web_407269_1
2. Add snippets view for this model
3. Try to edit that object results in 404 error

 I haven't confirmed that this issue can be reproduced as described on a fresh Wagtail project

### Technical details

Seems that when try to unquote it works in the first query before calling hooks but it fail on the second call of get_object()

https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/generic/models.py#L692

web_5F407269_5F1 ---(first unquote)---> web_407269_1 ---(second unquote)---> web_@7269_1

This result on the second query unable to find the object

I can confirm this on the current main branch, using a test project created with wagtail start and home/models.py edited to:

```python
from django.db import models

from wagtail.admin.panels import FieldPanel
from wagtail.models import Page
from wagtail.snippets.models import register_snippet

class HomePage(Page):
    pass

@register_snippet
class Thing(models.Model):
    id = models.CharField(primary_key=True, max_length=32)
    name = models.CharField(max_length=255)

    panels = [
        FieldPanel("name"),
    ]
```

From the ./manage.py shell prompt:

```
>>> from home.models import Thing
>>> Thing.objects.create(pk="abc", name="normal thing")
<Thing: Thing object (abc)>
>>> Thing.objects.create(pk="web_407269_1", name="strange thing")
<Thing: Thing object (web_407269_1)>
```
Within the Wagtail admin, under Snippets -> Things, the item with pk "abc" is editable but the item with pk "web_407269_1" yields a 404 error.

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/wagtail/admin/tests/test_views_generic.py b/wagtail/admin/tests/test_views_generic.py
index 5f3a425ac82a..9dc605f17153 100644
--- a/wagtail/admin/tests/test_views_generic.py
+++ b/wagtail/admin/tests/test_views_generic.py
@@ -15,7 +15,7 @@ def test_non_integer_primary_key(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
         response_object_count = response.context_data["object_list"].count()
-        self.assertEqual(response_object_count, 3)
+        self.assertEqual(response_object_count, 4)
         self.assertContains(response, "first modelwithstringtypeprimarykey model")
         self.assertContains(response, "second modelwithstringtypeprimarykey model")
         soup = self.get_soup(response.content)
@@ -34,7 +34,7 @@ def test_non_integer_primary_key(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
         response_object_count = response.context_data["object_list"].count()
-        self.assertEqual(response_object_count, 3)
+        self.assertEqual(response_object_count, 4)

 class TestGenericEditView(WagtailTestUtils, TestCase):
@@ -58,19 +58,29 @@ def test_non_url_safe_primary_key(self):
             response, "non-url-safe pk modelwithstringtypeprimarykey model"
         )

-    def test_using_quote_in_edit_url(self):
-        object_pk = 'string-pk-:#?;@&=+$,"[]<>%'
+    def test_unquote_sensitive_primary_key(self):
+        object_pk = "web_407269_1"
         response = self.get(quote(object_pk))
-        edit_url = response.context_data["action_url"]
-        edit_url_pk = edit_url.split("/")[-2]
-        self.assertEqual(edit_url_pk, quote(object_pk))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(
+            response, "unquote-sensitive modelwithstringtypeprimarykey model"
+        )
+
+    def test_using_quote_in_edit_url(self):
+        for object_pk in ('string-pk-:#?;@&=+$,"[]<>%', "web_407269_1"):
+            with self.subTest(object_pk=object_pk):
+                response = self.get(quote(object_pk))
+                edit_url = response.context_data["action_url"]
+                edit_url_pk = edit_url.split("/")[-2]
+                self.assertEqual(edit_url_pk, quote(object_pk))

     def test_using_quote_in_delete_url(self):
-        object_pk = 'string-pk-:#?;@&=+$,"[]<>%'
-        response = self.get(quote(object_pk))
-        delete_url = response.context_data["delete_url"]
-        delete_url_pk = delete_url.split("/")[-2]
-        self.assertEqual(delete_url_pk, quote(object_pk))
+        for object_pk in ('string-pk-:#?;@&=+$,"[]<>%', "web_407269_1"):
+            with self.subTest(object_pk=object_pk):
+                response = self.get(quote(object_pk))
+                delete_url = response.context_data["delete_url"]
+                delete_url_pk = delete_url.split("/")[-2]
+                self.assertEqual(delete_url_pk, quote(object_pk))

 class TestGenericDeleteView(WagtailTestUtils, TestCase):
@@ -89,3 +99,8 @@ def test_with_non_url_safe_primary_key(self):
         object_pk = 'string-pk-:#?;@&=+$,"[]<>%'
         response = self.get(quote(object_pk))
         self.assertEqual(response.status_code, 200)
+
+    def test_with_unquote_sensitive_primary_key(self):
+        object_pk = "web_407269_1"
+        response = self.get(quote(object_pk))
+        self.assertEqual(response.status_code, 200)
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
index c08a5af8c745..6bd8a9506e07 100644
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -5622,6 +5622,9 @@ def setUp(self):
         self.snippet_a = StandardSnippetWithCustomPrimaryKey.objects.create(
             snippet_id="snippet/01", text="Hello"
         )
+        self.snippet_b = StandardSnippetWithCustomPrimaryKey.objects.create(
+            snippet_id="abc_407269_1", text="Goodbye"
+        )

     def get(self, snippet, params={}):
         args = [quote(snippet.pk)]
@@ -5644,9 +5647,11 @@ def create(self, snippet, post_data={}, model=Advert):
         )

     def test_show_edit_view(self):
-        response = self.get(self.snippet_a)
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")
+        for snippet in [self.snippet_a, self.snippet_b]:
+            with self.subTest(snippet=snippet):
+                response = self.get(snippet)
+                self.assertEqual(response.status_code, 200)
+                self.assertTemplateUsed(response, "wagtailsnippets/snippets/edit.html")

     def test_edit_invalid(self):
         response = self.post(self.snippet_a, post_data={"foo": "bar"})
@@ -5669,8 +5674,10 @@ def test_edit(self):
         )

         snippets = StandardSnippetWithCustomPrimaryKey.objects.all()
-        self.assertEqual(snippets.count(), 2)
-        self.assertEqual(snippets.last().snippet_id, "snippet_id_edited")
+        self.assertEqual(snippets.count(), 3)
+        self.assertEqual(
+            snippets.order_by("snippet_id").last().snippet_id, "snippet_id_edited"
+        )

     def test_create(self):
         response = self.create(
@@ -5685,40 +5692,48 @@ def test_create(self):
         )

         snippets = StandardSnippetWithCustomPrimaryKey.objects.all()
-        self.assertEqual(snippets.count(), 2)
-        self.assertEqual(snippets.last().text, "test snippet")
+        self.assertEqual(snippets.count(), 3)
+        self.assertEqual(snippets.order_by("snippet_id").last().text, "test snippet")

     def test_get_delete(self):
-        response = self.client.get(
-            reverse(
-                "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
-                args=[quote(self.snippet_a.pk)],
-            )
-        )
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, "wagtailadmin/generic/confirm_delete.html")
+        for snippet in [self.snippet_a, self.snippet_b]:
+            with self.subTest(snippet=snippet):
+                response = self.client.get(
+                    reverse(
+                        "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
+                        args=[quote(snippet.pk)],
+                    )
+                )
+                self.assertEqual(response.status_code, 200)
+                self.assertTemplateUsed(
+                    response, "wagtailadmin/generic/confirm_delete.html"
+                )

     def test_usage_link(self):
-        response = self.client.get(
-            reverse(
-                "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
-                args=[quote(self.snippet_a.pk)],
-            )
-        )
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, "wagtailadmin/generic/confirm_delete.html")
-        self.assertContains(
-            response,
-            "This standard snippet with custom primary key is referenced 0 times",
-        )
-        self.assertContains(
-            response,
-            reverse(
-                "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:usage",
-                args=[quote(self.snippet_a.pk)],
-            )
-            + "?describe_on_delete=1",
-        )
+        for snippet in [self.snippet_a, self.snippet_b]:
+            with self.subTest(snippet=snippet):
+                response = self.client.get(
+                    reverse(
+                        "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:delete",
+                        args=[quote(snippet.pk)],
+                    )
+                )
+                self.assertEqual(response.status_code, 200)
+                self.assertTemplateUsed(
+                    response, "wagtailadmin/generic/confirm_delete.html"
+                )
+                self.assertContains(
+                    response,
+                    "This standard snippet with custom primary key is referenced 0 times",
+                )
+                self.assertContains(
+                    response,
+                    reverse(
+                        "wagtailsnippets_snippetstests_standardsnippetwithcustomprimarykey:usage",
+                        args=[quote(snippet.pk)],
+                    )
+                    + "?describe_on_delete=1",
+                )

     def test_redirect_to_edit(self):
         with self.assertWarnsRegex(
diff --git a/wagtail/test/testapp/fixtures/test.json b/wagtail/test/testapp/fixtures/test.json
index 756c3930e9c1..d6fd9e9996f9 100644
--- a/wagtail/test/testapp/fixtures/test.json
+++ b/wagtail/test/testapp/fixtures/test.json
@@ -1027,5 +1027,12 @@
     "fields": {
       "content": "non-url-safe pk modelwithstringtypeprimarykey model"
     }
+  },
+  {
+    "pk": "web_407269_1",
+    "model": "tests.modelwithstringtypeprimarykey",
+    "fields": {
+      "content": "unquote-sensitive modelwithstringtypeprimarykey model"
+    }
   }
 ]


diff --git a/wagtail/documents/models.py b/wagtail/documents/models.py
index e4ed73ae4b2..8495650374d 100644
--- a/wagtail/documents/models.py
+++ b/wagtail/documents/models.py
@@ -57,6 +57,7 @@ class AbstractDocument(CollectionMember, index.Indexed, models.Model):
        i

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 5cdc27de7fb..0f943ca6ab2 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -46,6 +46,7 @@ Changelog
  * Fix: Fix crash when loading the dashboard with only the "unlock" or "bulk delete" page permissions (Unyime Emmanuel Udoh, Sage Abdullah)
  * Fix: Improve deprecation warning for `WidgetWithScript` by raising it with `stacklevel=3` (Joren Hammudoglu)
  * Fix: Correctly place comment buttons next to date / datetime / time fields. (Srishti Jaiswal)
+ * Fix: Add missing `FilterField("created_at")` to `AbstractDocument` to fix ordering by `created_at` after searching in the documents index view (Srishti Jaiswal)
  * Docs: Move the model reference page from reference/pages to the references section as it covers all Wagtail core models (Srishti Jaiswal)
  * Docs: Move the panels reference page from references/pages to the references section as panels are available for any model editing, merge panels API into this page (Srishti Jaiswal)
  * Docs: Move the tags documentation to standalone advanced topic, instead of being inside the reference/pages section (Srishti Jaiswal)
diff --git a/docs/releases/6.4.md b/docs/releases/6.4.md
index 9fb788f79a8..dc2d2f58c94 100644
--- a/docs/releases/6.4.md
+++ b/docs/releases/6.4.md
@@ -58,6 +58,7 @@ depth: 1
  * Fix crash when loading the dashboard with only the "unlock" or "bulk delete" page permissions (Unyime Emmanuel Udoh, Sage Abdullah)
  * Improve deprecation warning for `WidgetWithScript` by raising it with `stacklevel=3` (Joren Hammudoglu)
  * Correctly place comment buttons next to date / datetime / time fields. (Srishti Jaiswal)
+ * Add missing `FilterField("created_at")` to `AbstractDocument` to fix ordering by `created_at` after searching in the documents index view (Srishti Jaiswal)

 ### Documentation

diff --git a/wagtail/documents/models.py b/wagtail/documents/models.py
index e4ed73ae4b2..8495650374d 100644
--- a/wagtail/documents/models.py
+++ b/wagtail/documents/models.py
@@ -57,6 +57,7 @@ class AbstractDocument(CollectionMember, index.Indexed, models.Model):
             ],
         ),
         index.FilterField("uploaded_by_user"),
+        index.FilterField("created_at"),
     ]

     def clean(self):

Add a filter field for the "created_at" property to enable filtering documents by creation date.

Prompt:
The following is the text of a github issue and related comments for this repository:

Ordering documents in search causes error
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

Issue was discovered by editors when searching through uploaded documents with similar names. Attempt to order them by date has failed.

### Steps to Reproduce

1. Login to wagtail admin
2. Search for an existing document
3. In search results click Documents tab
4. Click `Created` to sort the documents
5. Error - Cannot sort search results

### Technical details

-   Python version: 3.10.15
-   Django version: 5.0 - 5.1
-   Wagtail version: 6.0.6 - 6.3.1
-   Browser version: Chrome 131

### Working on this

Error message suggests adding index.FilterField('created_at') to AbstractDocument model. Adding this line to a local instance in virtual environment has fixed the issue

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/wagtail/documents/tests/test_admin_views.py b/wagtail/documents/tests/test_admin_views.py
index 4711ae8ccad..1554bfd5061 100644
--- a/wagtail/documents/tests/test_admin_views.py
+++ b/wagtail/documents/tests/test_admin_views.py
@@ -30,6 +30,7 @@
 )
 from wagtail.test.utils import WagtailTestUtils
 from wagtail.test.utils.template_tests import AdminTemplateTestUtils
+from wagtail.test.utils.timestamps import local_datetime

 class TestDocumentIndexView(WagtailTestUtils, TestCase):
@@ -95,7 +96,7 @@ def test_pagination_out_of_range(self):
         )

     def test_ordering(self):
-        orderings = ["title", "-created_at"]
+        orderings = ["title", "created_at", "-created_at"]
         for ordering in orderings:
             response = self.get({"ordering": ordering})
             self.assertEqual(response.status_code, 200)
@@ -371,6 +372,39 @@ def test_tag_filtering_with_search_term(self):
         response = self.get({"tag": "one", "q": "test"})
         self.assertEqual(response.context["page_obj"].paginator.count, 2)

+    def test_search_and_order_by_created_at(self):
+        # Create Documents, change their created_at dates after creation as
+        # the field has auto_now_add=True
+        doc1 = models.Document.objects.create(title="recent good Document")
+        doc1.created_at = local_datetime(2024, 1, 1)
+        doc1.save()
+
+        doc2 = models.Document.objects.create(title="latest ok Document")
+        doc2.created_at = local_datetime(2025, 1, 1)
+        doc2.save()
+
+        doc3 = models.Document.objects.create(title="oldest good document")
+        doc3.created_at = local_datetime(2023, 1, 1)
+        doc3.save()
+
+        cases = [
+            ("created_at", [doc3, doc1]),
+            ("-created_at", [doc1, doc3]),
+        ]
+
+        for ordering, expected_docs in cases:
+            with self.subTest(ordering=ordering):
+                response = self.get({"q": "good", "ordering": ordering})
+                self.assertEqual(response.status_code, 200)
+                self.assertEqual(response.context["query_string"], "good")
+
+                # Check that the documents are filtered by the search query
+                # and are in the correct order
+                documents = list(response.context["page_obj"].object_list)
+                self.assertEqual(documents, expected_docs)
+                self.assertIn("ordering", response.context)
+                self.assertEqual(response.context["ordering"], ordering)
+

 class TestDocumentIndexResultsView(WagtailTestUtils, TransactionTestCase):
     def setUp(self):


diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestExtension.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestExtension.java
index 8aed7644f2d8..023dd6fea6d2 100644
--- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestExtension.java
+++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestExtension.java
@@ -14,6 +14,7 @@
 import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations;

 import java.lang.reflect.Method;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.stream.Stream;
@@ -74,8 +75,13 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
 		ParameterizedTestNameFormatter formatter = createNameFormatter(extensionContext, methodContext);
 		AtomicLong invocationCount = new AtomicLong(0);

+		List<ArgumentsSource> argumentsSources = findRepeatableAnnotations(methodContext.method, ArgumentsSource.class);
+
+		Preconditions.notEmpty(argumentsSources,
+			"Configuration error: You must configure at least one arguments source for this @ParameterizedTest");
+
 		// @formatter:off
-		return findRepeatableAnnotations(methodContext.method, ArgumentsSource.class)
+		return argumentsSources
 				.stream()
 				.map(ArgumentsSource::value)
 				.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentsProvider.class, clazz, extensionContext))

Check that there is at least one arguments source configured, and fail with the message "Configuration error: You must configure at least one arguments source for this @ParameterizedTest" if none are found.

Prompt:
The following is the text of a github issue and related comments for this repository:

Fail `@ParameterizedTest` if there is no registered `ArgumentProvider`
Not declaring any `@...Source` annotation on a `@ParameterizedTest` method is most likely a user error and should be surfaced as a test failure rather than being silently ignored (now that #1477 permits zero invocations).

## Deliverables

- [x] Check that there's at least one `ArgumentProvider` registered in `ParameterizedTestExtension` and fail the container otherwise

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestExtensionTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestExtensionTests.java
index 638cdbf95e1f..fbdf70e080c7 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestExtensionTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestExtensionTests.java
@@ -146,16 +146,23 @@ void throwsExceptionWhenParameterizedTestIsNotInvokedAtLeastOnce() {

 	@Test
 	void doesNotThrowExceptionWhenParametrizedTestDoesNotRequireArguments() {
-		var extensionContextWithAnnotatedTestMethod = getExtensionContextReturningSingleMethod(
-			new TestCaseAllowNoArgumentsMethod());
+		var extensionContext = getExtensionContextReturningSingleMethod(new TestCaseAllowNoArgumentsMethod());

-		var stream = this.parameterizedTestExtension.provideTestTemplateInvocationContexts(
-			extensionContextWithAnnotatedTestMethod);
+		var stream = this.parameterizedTestExtension.provideTestTemplateInvocationContexts(extensionContext);
 		// cause the stream to be evaluated
 		stream.toArray();
 		stream.close();
 	}

+	@Test
+	void throwsExceptionWhenParameterizedTestHasNoArgumentsSource() {
+		var extensionContext = getExtensionContextReturningSingleMethod(new TestCaseWithNoArgumentsSource());
+
+		assertThrows(PreconditionViolationException.class,
+			() -> this.parameterizedTestExtension.provideTestTemplateInvocationContexts(extensionContext),
+			"Configuration error: You must configure at least one arguments source for this @ParameterizedTest");
+	}
+
 	@Test
 	void throwsExceptionWhenArgumentsProviderIsNotStatic() {
 		var extensionContextWithAnnotatedTestMethod = getExtensionContextReturningSingleMethod(
@@ -323,8 +330,8 @@ void method() {

 	static class TestCaseWithAnnotatedMethod {

-		@SuppressWarnings("JUnitMalformedDeclaration")
 		@ParameterizedTest
+		@ArgumentsSource(ZeroArgumentsProvider.class)
 		void method() {
 		}
 	}
@@ -332,10 +339,27 @@ void method() {
 	static class TestCaseAllowNoArgumentsMethod {

 		@ParameterizedTest(allowZeroInvocations = true)
+		@ArgumentsSource(ZeroArgumentsProvider.class)
 		void method() {
 		}
 	}

+	static class TestCaseWithNoArgumentsSource {
+
+		@ParameterizedTest(allowZeroInvocations = true)
+		@SuppressWarnings("JUnitMalformedDeclaration")
+		void method() {
+		}
+	}
+
+	static class ZeroArgumentsProvider implements ArgumentsProvider {
+
+		@Override
+		public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+			return Stream.empty();
+		}
+	}
+
 	static class ArgumentsProviderWithCloseHandlerTestCase {

 		@ParameterizedTest
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
index 13739ea4e018..ad28cd7fa555 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
@@ -457,7 +457,7 @@ void failsWhenArgumentsRequiredButNoneProvided() {
 	}

 	@Test
-	void failsWhenArgumentsAreNotRequiredAndNoneProvided() {
+	void doesNotFailWhenArgumentsAreNotRequiredAndNoneProvided() {
 		var result = execute(ZeroArgumentsTestCase.class, "testThatDoesNotRequireArguments", String.class);
 		result.allEvents().assertEventsMatchExactly( //
 			event(engine(), started()), event(container(ZeroArgumentsTestCase.class), started()),
@@ -467,6 +467,15 @@ void failsWhenArgumentsAreNotRequiredAndNoneProvided() {
 			event(engine(), finishedSuccessfully()));
 	}

+	@Test
+	void failsWhenNoArgumentsSourceIsDeclared() {
+		var result = execute(ZeroArgumentsTestCase.class, "testThatHasNoArgumentsSource", String.class);
+		result.containerEvents().assertThatEvents() //
+				.haveExactly(1, //
+					event(displayName("testThatHasNoArgumentsSource(String)"), finishedWithFailure(message(
+						"Configuration error: You must configure at least one arguments source for this @ParameterizedTest"))));
+	}
+
 	private EngineExecutionResults execute(DiscoverySelector... selectors) {
 		return EngineTestKit.engine(new JupiterTestEngine()).selectors(selectors).execute();
 	}
@@ -2428,6 +2437,12 @@ void testThatDoesNotRequireArguments(String argument) {
 			fail("This test should not be executed, because no arguments are provided.");
 		}

+		@ParameterizedTest(allowZeroInvocations = true)
+		@SuppressWarnings("JUnitMalformedDeclaration")
+		void testThatHasNoArgumentsSource(String argument) {
+			fail("This test should not be executed, because no arguments source is declared.");
+		}
+
 		public static Stream<Arguments> zeroArgumentsProvider() {
 			return Stream.empty();
 		}


diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.1.adoc
index 8dc92f6310d9..f720d245b68b 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.1.adoc
@@ -31,7 +31,9 @@ on GitHub.
 [[release-notes-5.11.1-junit-platform-new-features-and-improvements]]
 ==== New Features and Improvements

-* ❓
+* Improve parallelism and reduce number of blocked threads used by
+  `HierarchicalTestEngine` implementations when parallel execution is enabled and the
+  global read-write lock is used.

 [[release-notes-5.11.1-junit-jupiter]]
@@ -51,7 +53,8 @@ on GitHub.
 [[release-notes-5.11.1-junit-jupiter-new-features-and-improvements]]
 ==== New Features and Improvements

-* ❓
+* Improve parallelism and reduce number of blocked threads in the presence of `@Isolated`
+  tests when parallel execution is enabled

 [[release-notes-5.11.1-junit-vintage]]
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
index 4b2e332fd07a..c6ed2cb4b585 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java
@@ -13,6 +13,7 @@
 import static java.util.concurrent.CompletableFuture.completedFuture;
 import static org.apiguardian.api.API.Status.STABLE;
 import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT;
+import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD;

 import java.lang.Thread.UncaughtExceptionHandler;
 import java.lang.reflect.Constructor;
@@ -38,6 +39,7 @@
 import org.junit.platform.commons.logging.LoggerFactory;
 import org.junit.platform.commons.util.ExceptionUtils;
 import org.junit.platform.engine.ConfigurationParameters;
+import org.junit.platform.engine.support.hierarchical.SingleLock.GlobalReadWriteLock;

 /**
  * A {@link ForkJoinPool}-based
@@ -155,29 +157,34 @@ public void invokeAll(List<? extends TestTask> tasks) {
 			new ExclusiveTask(tasks.get(0)).execSync();
 			return;
 		}
-		Deque<ExclusiveTask> nonConcurrentTasks = new LinkedList<>();
+		Deque<ExclusiveTask> isolatedTasks = new LinkedList<>();
+		Deque<ExclusiveTask> sameThreadTasks = new LinkedList<>();
 		Deque<ExclusiveTask> concurrentTasksInReverseOrder = new LinkedList<>();
-		forkConcurrentTasks(tasks, nonConcurrentTasks, concurrentTasksInReverseOrder);
-		executeNonConcurrentTasks(nonConcurrentTasks);
+		forkConcurrentTasks(tasks, isolatedTasks, sameThreadTasks, concurrentTasksInReverseOrder);
+		executeSync(sameThreadTasks);
 		joinConcurrentTasksInReverseOrderToEnableWorkStealing(concurrentTasksInReverseOrder);
+		executeSync(isolatedTasks);
 	}

-	private void forkConcurrentTasks(List<? extends TestTask> tasks, Deque<ExclusiveTask> nonConcurrentTasks,
-			Deque<ExclusiveTask> concurrentTasksInReverseOrder) {
+	private void forkConcurrentTasks(List<? extends TestTask> tasks, Deque<ExclusiveTask> isolatedTasks,
+			Deque<ExclusiveTask> sameThreadTasks, Deque<ExclusiveTask> concurrentTasksInReverseOrder) {
 		for (TestTask testTask : tasks) {
 			ExclusiveTask exclusiveTask = new ExclusiveTask(testTask);
-			if (testTask.getExecutionMode() == CONCURRENT) {
-				exclusiveTask.fork();
-				concurrentTasksInReverseOrder.addFirst(exclusiveTask);
+			if (testTask.getResourceLock() instanceof GlobalReadWriteLock) {
+				isolatedTasks.add(exclusiveTask);
+			}
+			else if (testTask.getExecutionMode() == SAME_THREAD) {
+				sameThreadTasks.add(exclusiveTask);
 			}
 			else {
-				nonConcurrentTasks.add(exclusiveTask);
+				exclusiveTask.fork();
+				concurrentTasksInReverseOrder.addFirst(exclusiveTask);
 			}
 		}
 	}

-	private void executeNonConcurrentTasks(Deque<ExclusiveTask> nonConcurrentTasks) {
-		for (ExclusiveTask task : nonConcurrentTasks) {
+	private void executeSync(Deque<ExclusiveTask> tasks) {
+		for (ExclusiveTask task : tasks) {
 			task.execSync();
 		}
 	}

Split tasks into three groups: isolated tasks (using GlobalReadWriteLock), same-thread tasks, and concurrent tasks, then execute them in order with concurrent tasks in the middle to maximize parallelism.

Prompt:
The following is the text of a github issue and related comments for this repository:

Poor thread utilization when using ExclusiveResource.GLOBAL_KEY
When executing in parallel using a fixed number of threads, when several tests attempt to acquire a read/write lock against the `GLOBAL_KEY`, threads are likely to be assigned in a sub-optimal manner.

This issue was originally reported as https://github.com/cucumber/cucumber-jvm/issues/2910. The key to this issue is the low max-pool size. This is a common, if crude, way to limit the number of active web drivers.

## Steps to reproduce

See https://github.com/mpkorstanje/junit5-scheduling for a minimal reproducer.

```
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=3
junit.jupiter.execution.parallel.config.fixed.max-pool-size=3
```

```java
@Isolated
class SerialATest {

    @BeforeEach
    void simulateWebDriver() throws InterruptedException {
        System.out.println("Serial A: " + Thread.currentThread().getName() );
        Thread.sleep(1000);
    }

   ... Several tests

}
```

```java
@Isolated
class SerialBTest {

   ... Copy of SerialATest

}
```

```java
class ParallelTest {

    @BeforeEach
    void simulateWebDriver() throws InterruptedException {
        System.out.println("Parallel: " + Thread.currentThread().getName() );
        Thread.sleep(1000);
    }

    ... Several tests

}
```

Executing these tests will likely result in an output similar to this:

```log
Parallel: ForkJoinPool-1-worker-2
Parallel: ForkJoinPool-1-worker-2
Parallel: ForkJoinPool-1-worker-2
Parallel: ForkJoinPool-1-worker-2
Parallel: ForkJoinPool-1-worker-2
Serial A: ForkJoinPool-1-worker-3
Serial A: ForkJoinPool-1-worker-3
Serial B: ForkJoinPool-1-worker-1
Serial B: ForkJoinPool-1-worker-1
```

The output implies that  `worker-1` and `worker-3` are waiting to acquire the `ExclusiveResource.GLOBAL_KEY`, leaving only `worker-2` to process the parallel section on its own. Once done, the other workers can then acquire the lock in turn. In the ideal scenario, the parallel section would be executed with the maximum number of workers.

## Context

 - Jupiter: 5.10.3
 - Java 17

## Deliverables

To be determined.

Thanks for the reproducer! Any ideas for optimizing this? Scheduling @Isolated tests to run last? /cc @leonard84

I am thinking that if a thread can not aquire the read-write locks it needs, it could start work stealing and speed up the parallel sections that are able to run.

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ForkJoinTask.html#helpQuiesce--

I've never used this before, so I'm not sure how performant it would be.

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java
index dbd5d519be63..b556240bc295 100644
--- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java
@@ -20,10 +20,11 @@
 import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
 import static org.junit.jupiter.engine.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME;
 import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE;
+import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME;
 import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME;
 import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME;
 import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME;
-import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request;
+import static org.junit.platform.commons.util.CollectionUtils.getOnlyElement;
 import static org.junit.platform.testkit.engine.EventConditions.container;
 import static org.junit.platform.testkit.engine.EventConditions.event;
 import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
@@ -65,8 +66,10 @@
 import org.junit.jupiter.api.parallel.Isolated;
 import org.junit.jupiter.api.parallel.ResourceLock;
 import org.junit.platform.engine.TestDescriptor;
+import org.junit.platform.engine.discovery.ClassSelector;
 import org.junit.platform.engine.discovery.DiscoverySelectors;
 import org.junit.platform.engine.reporting.ReportEntry;
+import org.junit.platform.engine.support.descriptor.MethodSource;
 import org.junit.platform.testkit.engine.EngineExecutionResults;
 import org.junit.platform.testkit.engine.EngineTestKit;
 import org.junit.platform.testkit.engine.Event;
@@ -74,6 +77,7 @@
 /**
  * @since 1.3
  */
+@SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" })
 class ParallelExecutionIntegrationTests {

 	@Test
@@ -250,6 +254,35 @@ void canRunTestsIsolatedFromEachOtherAcrossClassesWithOtherResourceLocks() {
 		assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
 	}

+	@Test
+	void runsIsolatedTestsLastToMaximizeParallelism() {
+		var configParams = Map.of( //
+			DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent", //
+			PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME, "3" //
+		);
+		Class<?>[] testClasses = { IsolatedTestCase.class, SuccessfulParallelTestCase.class };
+		var events = executeWithFixedParallelism(3, configParams, testClasses) //
+				.allEvents();
+
+		assertThat(events.stream().filter(event(test(), finishedWithFailure())::matches)).isEmpty();
+
+		List<Event> parallelTestMethodEvents = events.reportingEntryPublished() //
+				.filter(e -> e.getTestDescriptor().getSource() //
+						.filter(it -> //
+						it instanceof MethodSource
+								&& SuccessfulParallelTestCase.class.equals(((MethodSource) it).getJavaClass()) //
+						).isPresent() //
+				) //
+				.toList();
+		assertThat(ThreadReporter.getThreadNames(parallelTestMethodEvents)).hasSize(3);
+
+		var parallelClassFinish = getOnlyElement(getTimestampsFor(events.list(),
+			event(container(SuccessfulParallelTestCase.class), finishedSuccessfully())));
+		var isolatedClassStart = getOnlyElement(
+			getTimestampsFor(events.list(), event(container(IsolatedTestCase.class), started())));
+		assertThat(isolatedClassStart).isAfterOrEqualTo(parallelClassFinish);
+	}
+
 	@Isolated("testing")
 	static class IsolatedTestCase {
 		static AtomicInteger sharedResource;
@@ -384,27 +417,30 @@ private List<Instant> getTimestampsFor(List<Event> events, Condition<Event> cond
 	}

 	private List<Event> executeConcurrently(int parallelism, Class<?>... testClasses) {
-		return executeWithFixedParallelism(parallelism, Map.of(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent"),
-			testClasses).allEvents().list();
+		Map<String, String> configParams = Map.of(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent");
+		return executeWithFixedParallelism(parallelism, configParams, testClasses) //
+				.allEvents() //
+				.list();
 	}

 	private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map<String, String> configParams,
 			Class<?>... testClasses) {
-		// @formatter:off
-		var discoveryRequest = request()
-				.selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).collect(toList()))
-				.configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true))
-				.configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed")
-				.configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism))
-				.configurationParameters(configParams)
-				.build();
-		// @formatter:on
-		return EngineTestKit.execute("junit-jupiter", discoveryRequest);
+		var classSelectors = Arrays.stream(testClasses) //
+				.map(DiscoverySelectors::selectClass) //
+				.toArray(ClassSelector[]::new);
+		return EngineTestKit.engine("junit-jupiter") //
+				.selectors(classSelectors) //
+				.configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true)) //
+				.configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") //
+				.configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism)) //
+				.configurationParameters(configParams) //
+				.execute();
 	}

 	// -------------------------------------------------------------------------

 	@ExtendWith(ThreadReporter.class)
+	@Execution(SAME_THREAD)
 	static class SuccessfulParallelTestCase {

 		static AtomicInteger sharedResource;
@@ -417,16 +453,19 @@ static void initialize() {
 		}

 		@Test
+		@Execution(CONCURRENT)
 		void firstTest() throws Exception {
 			incrementAndBlock(sharedResource, countDownLatch);
 		}

 		@Test
+		@Execution(CONCURRENT)
 		void secondTest() throws Exception {
 			incrementAndBlock(sharedResource, countDownLatch);
 		}

 		@Test
+		@Execution(CONCURRENT)
 		void thirdTest() throws Exception {
 			incrementAndBlock(sharedResource, countDownLatch);
 		}
@@ -782,6 +821,7 @@ private static void incrementBlockAndCheck(AtomicInteger sharedResource, CountDo
 		assertEquals(value, sharedResource.get());
 	}

+	@SuppressWarnings("ResultOfMethodCallIgnored")
 	private static int incrementAndBlock(AtomicInteger sharedResource, CountDownLatch countDownLatch)
 			throws InterruptedException {
 		var value = sharedResource.incrementAndGet();
@@ -790,6 +830,7 @@ private static int incrementAndBlock(AtomicInteger sharedResource, CountDownLatc
 		return value;
 	}

+	@SuppressWarnings("ResultOfMethodCallIgnored")
 	private static void storeAndBlockAndCheck(AtomicInteger sharedResource, CountDownLatch countDownLatch)
 			throws InterruptedException {
 		var value = sharedResource.get();
@@ -798,7 +839,7 @@ private static void storeAndBlockAndCheck(AtomicInteger sharedResource, CountDow
 		assertEquals(value, sharedResource.get());
 	}

-	/**
+	/*
 	 * To simulate tests running in parallel tests will modify a shared
 	 * resource, simulate work by waiting, then check if the shared resource was
 	 * not modified by any other thread.
@@ -806,10 +847,10 @@ private static void storeAndBlockAndCheck(AtomicInteger sharedResource, CountDow
 	 * Depending on system performance the simulation of work needs to be longer
 	 * on slower systems to ensure tests can run in parallel.
 	 *
-	 * Currently CI is known to be slow.
+	 * Currently, CI is known to be slow.
 	 */
 	private static long estimateSimulatedTestDurationInMiliseconds() {
-		var runningInCi = Boolean.valueOf(System.getenv("CI"));
+		var runningInCi = Boolean.parseBoolean(System.getenv("CI"));
 		return runningInCi ? 1000 : 100;
 	}


diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
index 5f8f864a4a6b..c9a3569af431 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
@@ -31,6 +31,8 @@ JUnit repository on GitHub.
   calling the internal `ReflectionUtils.makeAccessible(Field)` method directly.
 * Support both the primitive type `void` and the wrapper type `Void` in the internal
   `ReflectionUtils` to allow `String` to `Class` conversion in parameterized tests.
+* Add support for passing line and column number to `ConsoleLauncher` via
+  `--select-file` and `--select-resource`.

 [[release-notes-5.12.0-M1-junit-jupiter]]
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ResourceUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ResourceUtils.java
similarity index 74%
rename from junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ResourceUtils.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/util/ResourceUtils.java
index b61598d59b8e..fd6b78e3223e 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ResourceUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ResourceUtils.java
@@ -8,19 +8,21 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */

-package org.junit.platform.engine.support.descriptor;
+package org.junit.platform.commons.util;
+
+import static org.apiguardian.api.API.Status.INTERNAL;

 import java.net.URI;

-import org.junit.platform.commons.util.Preconditions;
-import org.junit.platform.commons.util.StringUtils;
+import org.apiguardian.api.API;

 /**
  * Collection of static utility methods for working with resources.
  *
- * @since 1.3
+ * @since 1.3 (originally in org.junit.platform.engine.support.descriptor)
  */
-final class ResourceUtils {
+@API(status = INTERNAL, since = "1.12")
+public final class ResourceUtils {

 	private ResourceUtils() {
 		/* no-op */
@@ -33,8 +35,10 @@ private ResourceUtils() {
 	 * @param uri the {@code URI} from which to strip the query component
 	 * @return a new {@code URI} with the query component removed, or the
 	 * original {@code URI} unmodified if it does not have a query component
+	 *
+	 * @since 1.3
 	 */
-	static URI stripQueryComponent(URI uri) {
+	public static URI stripQueryComponent(URI uri) {
 		Preconditions.notNull(uri, "URI must not be null");

 		if (StringUtils.isBlank(uri.getQuery())) {
diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/options/SelectorConverter.java b/junit-platform-console/src/main/java/org/junit/platform/console/options/SelectorConverter.java
index 7c18320eba67..6f1803185706 100644
--- a/junit-platform-console/src/main/java/org/junit/platform/console/options/SelectorConverter.java
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/options/SelectorConverter.java
@@ -19,12 +19,16 @@
 import static org.junit.platform.engine.discovery.DiscoverySelectors.selectPackage;
 import static org.junit.platform.engine.discovery.DiscoverySelectors.selectUri;

+import java.net.URI;
+
 import org.junit.platform.commons.PreconditionViolationException;
+import org.junit.platform.commons.util.ResourceUtils;
 import org.junit.platform.engine.DiscoverySelectorIdentifier;
 import org.junit.platform.engine.discovery.ClassSelector;
 import org.junit.platform.engine.discovery.ClasspathResourceSelector;
 import org.junit.platform.engine.discovery.DirectorySelector;
 import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.engine.discovery.FilePosition;
 import org.junit.platform.engine.discovery.FileSelector;
 import org.junit.platform.engine.discovery.IterationSelector;
 import org.junit.platform.engine.discovery.MethodSelector;
@@ -53,8 +57,12 @@ public UriSelector convert(String value) {
 	static class File implements ITypeConverter<FileSelector> {
 		@Override
 		public FileSelector convert(String value) {
-			return selectFile(value);
+			URI uri = URI.create(value);
+			String path = ResourceUtils.stripQueryComponent(uri).getPath();
+			FilePosition filePosition = FilePosition.fromQuery(uri.getQuery()).orElse(null);
+			return selectFile(path, filePosition);
 		}
+
 	}

 	static class Directory implements ITypeConverter<DirectorySelector> {
@@ -88,7 +96,10 @@ public MethodSelector convert(String value) {
 	static class ClasspathResource implements ITypeConverter<ClasspathResourceSelector> {
 		@Override
 		public ClasspathResourceSelector convert(String value) {
-			return selectClasspathResource(value);
+			URI uri = URI.create(value);
+			String path = ResourceUtils.stripQueryComponent(uri).getPath();
+			FilePosition filePosition = FilePosition.fromQuery(uri.getQuery()).orElse(null);
+			return selectClasspathResource(path, filePosition);
 		}
 	}

diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestDiscoveryOptionsMixin.java b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestDiscoveryOptionsMixin.java
index d71b1f0a6e2f..46298abb9d11 100644
--- a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestDiscoveryOptionsMixin.java
+++ b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestDiscoveryOptionsMixin.java
@@ -76,7 +76,10 @@ static class SelectorOptions {
 		private final List<UriSelector> selectedUris2 = new ArrayList<>();

 		@Option(names = { "-f",
-				"--select-file" }, paramLabel = "FILE", arity = "1", converter = SelectorConverter.File.class, description = "Select a file for test discovery. This option can be repeated.")
+				"--select-file" }, paramLabel = "FILE", arity = "1", converter = SelectorConverter.File.class, //
+				description = "Select a file for test discovery. "
+						+ "The line and column numbers can be provided as URI query parameters (e.g. foo.txt?line=12&column=34). "
+						+ "This option can be repeated.")
 		private final List<FileSelector> selectedFiles = new ArrayList<>();

 		@Option(names = { "--f", "-select-file" }, arity = "1", hidden = true, converter = SelectorConverter.File.class)
@@ -123,7 +126,10 @@ static class SelectorOptions {
 		private final List<MethodSelector> selectedMethods2 = new ArrayList<>();

 		@Option(names = { "-r",
-				"--select-resource" }, paramLabel = "RESOURCE", arity = "1", converter = SelectorConverter.ClasspathResource.class, description = "Select a classpath resource for test discovery. This option can be repeated.")
+				"--select-resource" }, paramLabel = "RESOURCE", arity = "1", converter = SelectorConverter.ClasspathResource.class, //
+				description = "Select a classpath resource for test discovery. "
+						+ "The line and column numbers can be provided as URI query parameters (e.g. foo.csv?line=12&column=34). "
+						+ "This option can be repeated.")
 		private final List<ClasspathResourceSelector> selectedClasspathResources = new ArrayList<>();

 		@Option(names = { "--r",
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ClasspathResourceSource.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ClasspathResourceSource.java
index 595dd33cd307..e326a3343a58 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ClasspathResourceSource.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/ClasspathResourceSource.java
@@ -19,6 +19,7 @@
 import org.apiguardian.api.API;
 import org.junit.platform.commons.PreconditionViolationException;
 import org.junit.platform.commons.util.Preconditions;
+import org.junit.platform.commons.util.ResourceUtils;
 import org.junit.platform.commons.util.ToStringBuilder;
 import org.junit.platform.engine.TestSource;

diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/UriSource.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/UriSource.java
index bd97bbe609e1..fef3e61b5db7 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/UriSource.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/UriSource.java
@@ -20,6 +20,7 @@
 import org.apiguardian.api.API;
 import org.junit.platform.commons.logging.LoggerFactory;
 import org.junit.platform.commons.util.Preconditions;
+import org.junit.platform.commons.util.ResourceUtils;
 import org.junit.platform.engine.TestSource;

 /**

Parse line and column numbers from URI query parameters when converting file and classpath resource selectors.

Prompt:
The following is the text of a github issue and related comments for this repository:

Add support for passing line and column number to ConsoleLauncher via `--select-file` and `--select-resource`
This should be done using the same syntax that is supported for the URI argument of dynamic tests (`?line=...&column=...`).

## Related issues
- https://github.com/junit-team/junit5/issues/3366#issuecomment-1625211213
- #3484
- #3485

Resolve the issue described above. Do not modify tests or user code. The issue can be resolved by modifying the actual library/application code. Make the minimal changes to resolve the issue while adhering to the specification and coding style of the codebase.

diff --git a/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java b/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java
index 3b9b10935c9f..acb491afa48f 100644
--- a/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java
@@ -44,6 +44,7 @@
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.platform.engine.DiscoverySelector;
 import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.engine.discovery.FilePosition;

 /**
  * @since 1.10
@@ -382,7 +383,9 @@ void parseValidFileSelectors(ArgsType type) {
 				() -> assertEquals(List.of(selectFile("foo.txt")), type.parseArgLine("-select-file=foo.txt").discovery.getSelectedFiles()),
 				() -> assertEquals(List.of(selectFile("foo.txt")), type.parseArgLine("--select-file foo.txt").discovery.getSelectedFiles()),
 				() -> assertEquals(List.of(selectFile("foo.txt")), type.parseArgLine("--select-file=foo.txt").discovery.getSelectedFiles()),
-				() -> assertEquals(List.of(selectFile("foo.txt"), selectFile("bar.csv")), type.parseArgLine("-f foo.txt -f bar.csv").discovery.getSelectedFiles())
+				() -> assertEquals(List.of(selectFile("foo.txt"), selectFile("bar.csv")), type.parseArgLine("-f foo.txt -f bar.csv").discovery.getSelectedFiles()),
+				() -> assertEquals(List.of(selectFile("foo.txt", FilePosition.from(5))), type.parseArgLine("-f foo.txt?line=5").discovery.getSelectedFiles()),
+				() -> assertEquals(List.of(selectFile("foo.txt", FilePosition.from(12, 34))), type.parseArgLine("-f foo.txt?line=12&column=34").discovery.getSelectedFiles())
 		);
 		// @formatter:on
 	}
@@ -509,7 +512,9 @@ void parseValidClasspathResourceSelectors(ArgsType type) {
 				() -> assertEquals(List.of(selectClasspathResource("/foo.csv")), type.parseArgLine("-select-resource=/foo.csv").discovery.getSelectedClasspathResources()),
 				() -> assertEquals(List.of(selectClasspathResource("/foo.csv")), type.parseArgLine("--select-resource /foo.csv").discovery.getSelectedClasspathResources()),
 				() -> assertEquals(List.of(selectClasspathResource("/foo.csv")), type.parseArgLine("--select-resource=/foo.csv").discovery.getSelectedClasspathResources()),
-				() -> assertEquals(List.of(selectClasspathResource("/foo.csv"), selectClasspathResource("bar.json")), type.parseArgLine("-r /foo.csv -r bar.json").discovery.getSelectedClasspathResources())
+				() -> assertEquals(List.of(selectClasspathResource("/foo.csv"), selectClasspathResource("bar.json")), type.parseArgLine("-r /foo.csv -r bar.json").discovery.getSelectedClasspathResources()),
+				() -> assertEquals(List.of(selectClasspathResource("/foo.csv", FilePosition.from(5))), type.parseArgLine("-r /foo.csv?line=5").discovery.getSelectedClasspathResources()),
+				() -> assertEquals(List.of(selectClasspathResource("/foo.csv", FilePosition.from(12, 34))), type.parseArgLine("-r /foo.csv?line=12&column=34").discovery.getSelectedClasspathResources())
 		);
 		// @formatter:on
 	}