Repository navigation
fix(staff): keep the project screen on a background scope reload - #143
maxidragon wants to merge 5 commits into
Conversation
The organization switcher settles its scope shortly after a page mounts, which bumps the scope version and makes the time-tracking project pages (detail, edit, board) reload the project. Each reload dropped back to the full-page loader, unmounting whatever was on screen and losing its local state: "Request sent" on the no-access guard flipped back to an enabled "Request access" (inviting a duplicate request), and an edit form lost its unsaved changes. On a slow runner the reload lands after the click, which is why TC-TT-017 fails intermittently. A reload of the project already on screen now keeps that screen and swaps in the answer when it arrives; the full-page loader only shows while nothing has been resolved for the project yet. The board does the same through react-query placeholder data scoped to the same project.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The retention logic can expose and leave actionable project data from the previous organization or tenant during a genuine scope switch.
Review effort: Balanced
Findings: 3
Open (3)
What changed in this PR
Prevents background scope reloads from unmounting project screens and losing local UI state.
Changes:
- Preserves detail and edit screens during same-project reloads.
- Retains board query data while its scoped query refreshes.
- Adds regression coverage for access-request state.
| File | Description |
|---|---|
projects/[id]/page.tsx |
Preserves resolved detail state during reloads. |
projects/[id]/edit/page.tsx |
Keeps the edit form mounted during reloads. |
projects/[id]/board/page.tsx |
Uses scoped query placeholder data. |
projects/[id]/__tests__/page.accessRequestScopeReload.test.tsx |
Tests access-request state retention. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Keeping the screen for any reload of the same project also kept it across a real organization or tenant switch, leaving the previous scope's project (and its actions) on screen until the new answer arrived. Retention is now limited to a screen resolved under scope version 0 — before the organization switcher first settled the scope — which is the reload that raced the access request. A switch between two known scopes goes through the full-page loader as before.
resolved?.projectId === projectId does not narrow resolved to non-null because projectId is string | undefined inside the async loader, which failed the app build's type check.
|
Found 2 test failures on Blacksmith runners: Failures
|
jtomaszewski
left a comment
There was a problem hiding this comment.
Summary
After a time-tracking project page loads, the organization switcher finishes resolving the scope and triggers a reload. Before this PR, that reload always showed the full-page loader, which threw away whatever was on screen. On the "no access" screen, a user who had just clicked Request access saw the button flip back from "Request sent" to an enabled "Request access", inviting them to send the same request twice. This also made TC-TT-017 flaky in CI. On the edit page, the same reload could wipe unsaved form edits.
The PR keeps the current screen up during that first reload, when the scope version goes from 0 to 1, and swaps in the new answer when it arrives. A real switch between two known scopes (version 1 or higher) still goes through the loader, so nothing from the old organization stays clickable.
🔍 Details
- Detail and edit pages: the load effect no longer resets
loading,accessDenied,isNotFoundanderrorbefore fetching. A singlesettle()sets all four together once the answer is in. AresolvedRefremembers which project and scope version the screen was last resolved under. The loader is skipped only when that version is 0 and the project is the same. - Board page: the query key already includes
scope:<n>. AplaceholderDatacallback returns the previous data only when the previous key wasscope:0for the same project. When the user has no access, that previous data isnull, and react-query v5 treatsnullas a real placeholder. SoisLoadingstays false andNoProjectAccessstays mounted. - New test: it covers the detail page for both cases. On the 0 → 1 reload, "Request sent" stays disabled. On a 1 → 2 switch, the page goes back through the loader.
Walkthrough
Keep the resolved screen through the first scope settlement
The detail and edit pages now settle all their status flags in one place, after the fetch finishes. They skip the loader only for the same project resolved under scope version 0. The board page does the same through react-query placeholderData. The thing to check is the condition that decides when a reload counts as a background refresh rather than a real scope switch.
Regression test for the access-request reload
A jsdom test renders the detail page, sends an access request, then bumps the mocked scope version. It covers both the first settlement (the screen is kept) and a known-scope switch (the loader comes back).
Reviewed with cerber 🐕 — drafted by AI, sent by a human.
The keep-screen check read the last load that settled. When the version-0 load was cancelled by a quick second scope switch before it returned, the ref still said version 0, so that second switch skipped the loader and left the version-0 screen clickable under the new scope. The detail and edit pages now record each load as it starts and compare against the one it replaces, settled or not.
The board keeps the no-access screen through the version 0 -> 1 re-key only because react-query treats `null` placeholder data as data. Cover it, and the known-scope switch that must still show the loader, so a change to the placeholder or to the denial value cannot regress it silently.
|
Superseded by open-mercato#7132 — this change is now under review upstream. Branch |

Problem
TC-TT-017("No access to a project … the request-access action works") fails intermittently inephemeral-integrationshard 12, ondevelopand on unrelated PRs (#140 failed it on four attempts in a row). The cause is a real UI bug, not the test.After a time-tracking project page mounts, the organization switcher resolves the scope and emits a scope change. That bumps
useOrganizationScopeVersion(), and the project pages reload the project. Every reload went through the full-page loader, which unmounts whatever was on screen:access-requestsPOST is followed by theorganization-switcherGET and then a secondtime-projectsGET, and the failure screenshot shows the reset button.CrudFormand drop unsaved edits.Solution
The reload that raced the click is the first scope settlement. The scope version starts at 0 and only increases on a real change, so a screen resolved under version 0 was loaded before the switcher first settled the scope. That screen now stays up through the reload and swaps in the new answer when it arrives.
A switch between two known scopes (version ≥ 1 → next) still goes through the full-page loader as before. Nothing loaded for the previous organization or tenant stays on screen or clickable.
loading/accessDenied/isNotFound/errorup front. It settles all of them together once the answer arrives. Each load is recorded as it starts, and the loader is skipped only when the load it replaces was for the same project at scope version 0. The replaced load counts even if it never finished, so a version-0 load cancelled by a quick second switch can't let that switch through.placeholderDatakeeps the previous answer, but only when it came fromscope:0for the same project.If the reload's answer differs, for example because the settled scope does grant access, the screen still updates to it. Only the blank-loader flash between the two answers is gone.
Verification
projects/[id]/__tests__/page.accessRequestScopeReload.test.tsx(detail page) has three cases:projects/[id]/board/__tests__/page.accessRequestScopeReload.test.tsx(board page) covers the same two paths as the first two cases, through react-query. RemovingplaceholderDatafails the first case, and dropping thescope:0condition fails the second.npx jest "time-tracking/projects|no-project-access"inpackages/core: 12 suites, 39 tests, all passing.tscreports nothing new in them: the only errors are the missing generated registries in a fresh checkout.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.