From 07f336498fda925f4c7b640fdcf9615d43a71563 Mon Sep 17 00:00:00 2001
From: Dombi Attila <83396+dombesz@users.noreply.github.com>
Date: Thu, 3 Sep 2026 15:53:54 +0300
Subject: [PATCH 1/3] [AGILE-417] Display a loading state when the backlog
frame is filtered/refreshed
https://community.openproject.org/wp/AGILE-417
Disable the #backlogs_container turbo frame while loading and display a loading spinner.
---
.../global_styles/content/modules/_backlogs.sass | 10 ++++++++++
.../spec/features/backlogs/filters_spec.rb | 15 +++++++++++++++
2 files changed, 25 insertions(+)
diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass
index 01a6729b2c3d..db136ba4eb66 100644
--- a/frontend/src/global_styles/content/modules/_backlogs.sass
+++ b/frontend/src/global_styles/content/modules/_backlogs.sass
@@ -44,6 +44,16 @@
container-name: backlogsListsContainer
container-type: inline-size
+// Disable interaction while the #backlogs_container is loading
+turbo-frame#backlogs_container[busy]
+ opacity: 0.4
+ pointer-events: none
+ cursor: progress
+
+// Display the loading indicator while the #backlogs_container is loading
+body:has(turbo-frame#backlogs_container[busy]) #global-loading-indicator
+ display: block !important
+
.op-sprint-planning-container
display: flex
flex-direction: row
diff --git a/modules/backlogs/spec/features/backlogs/filters_spec.rb b/modules/backlogs/spec/features/backlogs/filters_spec.rb
index f58490d09cdc..94efce696779 100644
--- a/modules/backlogs/spec/features/backlogs/filters_spec.rb
+++ b/modules/backlogs/spec/features/backlogs/filters_spec.rb
@@ -96,6 +96,21 @@ def create_sprint_wp(subject:, status: status_a) = create(:work_package, project
backlogs_page.expect_inbox_work_package_count(1)
end
+ it "shows the loading spinner on the backlogs container while the subject quick search is in flight" do
+ expect(page).to have_no_css("turbo-frame#backlogs_container[busy]")
+ expect(page).to have_no_css("#global-loading-indicator", visible: :visible)
+
+ # Not using `apply_subject_filter`, as it waits for the network to go
+ # idle, which would hide the very state under test here.
+ fill_in "Search work packages by subject", with: "Needle"
+
+ expect(page).to have_css("turbo-frame#backlogs_container[busy]")
+ expect(page).to have_css("#global-loading-indicator", visible: :visible)
+
+ expect(page).to have_no_css("turbo-frame#backlogs_container[busy]")
+ expect(page).to have_no_css("#global-loading-indicator", visible: :visible)
+ end
+
it "narrows the listings when an advanced filter is applied" do
backlogs_page.expect_bucket_items(bucket, items: status_b_bucket_wp)
backlogs_page.expect_sprint_items(sprint, items: status_b_sprint_wp)
From e2df8544cedadbb4004f12cce0641be1b1f89343 Mon Sep 17 00:00:00 2001
From: Dombi Attila <83396+dombesz@users.noreply.github.com>
Date: Fri, 4 Sep 2026 12:08:05 +0300
Subject: [PATCH 2/3] Patch turbo navigation to strip busy attributes from the
cloned snapshot before it get's stored.
---
.../src/turbo/turbo-navigation-patch.spec.ts | 30 +++++++++++++++-
frontend/src/turbo/turbo-navigation-patch.ts | 35 ++++++++++++++++---
2 files changed, 60 insertions(+), 5 deletions(-)
diff --git a/frontend/src/turbo/turbo-navigation-patch.spec.ts b/frontend/src/turbo/turbo-navigation-patch.spec.ts
index 619d253f6d0d..b0e44c0d683f 100644
--- a/frontend/src/turbo/turbo-navigation-patch.spec.ts
+++ b/frontend/src/turbo/turbo-navigation-patch.spec.ts
@@ -26,7 +26,7 @@
//++
import * as Turbo from '@hotwired/turbo';
-import { applyTurboNavigationPatch } from './turbo-navigation-patch';
+import { applyTurboNavigationPatch, stripStaleBusyState } from './turbo-navigation-patch';
// The FrameController prototype the patch reaches into. Internal Turbo API,
// not part of the public types, hence the casts.
@@ -60,3 +60,31 @@ describe('turbo-navigation-patch (tripwire for hotwired/turbo#1300)', () => {
expect(String(patched)).toContain('ownerDocument');
});
});
+
+describe('stripStaleBusyState', () => {
+ it('removes busy and aria-busy from every busy turbo-frame in the subtree', () => {
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
+ `;
+
+ stripStaleBusyState(root);
+
+ expect(root.querySelector('#a')!.hasAttribute('busy')).toBe(false);
+ expect(root.querySelector('#a')!.hasAttribute('aria-busy')).toBe(false);
+ expect(root.querySelector('#b')!.hasAttribute('busy')).toBe(false);
+ expect(root.querySelector('#b')!.hasAttribute('aria-busy')).toBe(false);
+ expect(root.querySelector('#c')!.hasAttribute('busy')).toBe(false);
+ });
+
+ it('leaves unrelated content untouched', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'content
';
+
+ stripStaleBusyState(root);
+
+ expect(root.querySelector('p')!.textContent).toBe('content');
+ });
+});
diff --git a/frontend/src/turbo/turbo-navigation-patch.ts b/frontend/src/turbo/turbo-navigation-patch.ts
index d8e80b5ac461..9e6b1119a4fc 100644
--- a/frontend/src/turbo/turbo-navigation-patch.ts
+++ b/frontend/src/turbo/turbo-navigation-patch.ts
@@ -30,6 +30,20 @@
// part of the public type surface, so every access below is untyped.
import * as Turbo from '@hotwired/turbo';
+// Turbo marks a turbo-frame `busy`/`aria-busy` for the duration of a
+// navigation, but for a form-submission-driven navigation it doesn't clear
+// that state until well after `proposeVisitIfNavigatedWithAction` below has
+// already cloned the frame into the history-cache snapshot -- so the clone
+// freezes the frame as permanently busy. `turbo:before-cache` fires too late
+// to fix this: by then the clone is already a detached copy, disconnected
+// from the live DOM. Exported for direct unit testing.
+export function stripStaleBusyState(root: ParentNode): void {
+ root.querySelectorAll('turbo-frame[busy]').forEach((frame) => {
+ frame.removeAttribute('busy');
+ frame.removeAttribute('aria-busy');
+ });
+}
+
/**
* Workaround for https://github.com/hotwired/turbo/issues/1300: with a
* ``, the URL advances but the
@@ -41,10 +55,13 @@ import * as Turbo from '@hotwired/turbo';
* ships in a release we depend on, drop this patch in favor of it.
*
* This is a verbatim fork of `FrameController.proposeVisitIfNavigatedWithAction`
- * with a single divergence: the history snapshot is built from the live
- * document (`frame.ownerDocument.documentElement.outerHTML`) instead of the
- * fetch response (`await fetchResponse.responseHTML`), so the restored snapshot
- * reflects the fully rendered page rather than the bare frame fragment.
+ * with two divergences:
+ * - The history snapshot is built from the live document
+ * (`frame.ownerDocument.documentElement.outerHTML`) instead of the fetch
+ * response (`await fetchResponse.responseHTML`), so the restored snapshot
+ * reflects the fully rendered page rather than the bare frame fragment.
+ * - `stripStaleBusyState` clears any frame left stuck `busy` in that snapshot
+ * (see its own comment above).
*
* Pinned to @hotwired/turbo 8.0.x. `turbo-navigation-patch.spec.ts` is a
* tripwire: it fails if Turbo renames the patched method or stops sourcing the
@@ -57,6 +74,16 @@ export function applyTurboNavigationPatch() {
if (this.action) {
const pageSnapshot = Turbo.PageSnapshot.fromElement(frame).clone();
+
+ // Form submission cycle:
+ // Form marked busy, frame marked busy -> Request starts -> Request Completes -> proposeVisit called ->
+ // it caches the pageSnapshot which is still marked busy -> Issues a visit -> visitCachedSnapshot
+ // will update the stale pageSnapshot with the current previousFrameElement to keep a fresh copy.
+ // But the pageSnapshot busy attribute is untouched. Ideally visitCacheSnapshot would remove the
+ // busy attribute from the pageSnapshot, but since that is not happening, the busy state is stripped
+ // from the pageSnapshot here.
+ stripStaleBusyState(pageSnapshot.element);
+
const { visitCachedSnapshot } = frame.delegate;
// Kept `async` to mirror upstream's method shape even though the body no
From ed537207573e8164ad313d218cca3970b405b9d1 Mon Sep 17 00:00:00 2001
From: Dombi Attila <83396+dombesz@users.noreply.github.com>
Date: Fri, 4 Sep 2026 16:18:18 +0300
Subject: [PATCH 3/3] Do not make the backlogs page opaque when loading
---
frontend/src/global_styles/content/modules/_backlogs.sass | 6 ------
1 file changed, 6 deletions(-)
diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass
index db136ba4eb66..928bba7162f2 100644
--- a/frontend/src/global_styles/content/modules/_backlogs.sass
+++ b/frontend/src/global_styles/content/modules/_backlogs.sass
@@ -44,12 +44,6 @@
container-name: backlogsListsContainer
container-type: inline-size
-// Disable interaction while the #backlogs_container is loading
-turbo-frame#backlogs_container[busy]
- opacity: 0.4
- pointer-events: none
- cursor: progress
-
// Display the loading indicator while the #backlogs_container is loading
body:has(turbo-frame#backlogs_container[busy]) #global-loading-indicator
display: block !important