Skip to content

fix(staff): keep the project screen on a background scope reload - #143

Closed
maxidragon wants to merge 5 commits into
developfrom
fix/time-tracking-access-request-survives-scope-reload
Closed

maxidragon wants to merge 5 commits into
developfrom
fix/time-tracking-access-request-survives-scope-reload

Conversation

@maxidragon

@maxidragon maxidragon commented Oct 7, 2026 •

Copy link
Copy Markdown

Problem

TC-TT-017 ("No access to a project … the request-access action works") fails intermittently in ephemeral-integration shard 12, on develop and 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:

  • On the no-access guard (screen 17), "Request sent" flips back to an enabled "Request access" once the reload lands after the click. The user is invited to send a request they already sent. On a slow runner the reload does land after the click: in the failing run's Playwright trace, the access-requests POST is followed by the organization-switcher GET and then a second time-projects GET, and the failure screenshot shows the reset button.
  • On the edit page, the same reload would unmount the CrudForm and 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.

  • Detail and edit pages: the load effect no longer resets loading/accessDenied/isNotFound/error up 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.
  • Board page: the scope version is part of the react-query key, so the scope settling re-keys the query. placeholderData keeps the previous answer, but only when it came from scope:0 for 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

  • New projects/[id]/__tests__/page.accessRequestScopeReload.test.tsx (detail page) has three cases:
    • First settlement (version 0 → 1): the project reloads and "Request sent" stays disabled. This case fails on the old page ("Unable to find … button 'Request sent'").
    • Known-scope switch (1 → 2): the page goes through the loader and comes back without the previous scope's state. This case fails if the version-0 condition is removed.
    • Cancelled first settlement: the page settles at 0, the version-1 load is held in flight, and the scope switches to 2. The loader must come back. This case fails if the decision is made from the last settled load.
  • New projects/[id]/board/__tests__/page.accessRequestScopeReload.test.tsx (board page) covers the same two paths as the first two cases, through react-query. Removing placeholderData fails the first case, and dropping the scope:0 condition fails the second.
  • npx jest "time-tracking/projects|no-project-access" in packages/core: 12 suites, 39 tests, all passing.
  • ESLint is clean on the changed files. tsc reports nothing new in them: the only errors are the missing generated registries in a fresh checkout.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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.
@maxidragon maxidragon added bug Something isn't working review Ready for code review needs-qa Requires manual QA before merge priority-high risk-low labels Oct 7, 2026
@maxidragon
maxidragon requested a balanced review from Copilot October 7, 2026 14:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 High severity

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.
@blacksmith-sh

blacksmith-sh Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
src/modules/progress/tests/progressService.test.ts/
markCancelled — broadcasts the flushed row rather than the pre-flush copy
View Logs
src/modules/progress/tests/progressService.test.ts/
markCancelled — flushes the throttled meta so a producer's partial summary survives the
cancel
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@jtomaszewski jtomaszewski left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, isNotFound and error before fetching. A single settle() sets all four together once the answer is in. A resolvedRef remembers 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>. A placeholderData callback returns the previous data only when the previous key was scope:0 for the same project. When the user has no access, that previous data is null, and react-query v5 treats null as a real placeholder. So isLoading stays false and NoProjectAccess stays 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.
@maxidragon

Copy link
Copy Markdown
Author

Superseded by open-mercato#7132 — this change is now under review upstream. Branch fix/time-tracking-access-request-survives-scope-reload is left in place because it is the head of that upstream PR; deleting it would close the PR.

@maxidragon maxidragon closed this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working needs-qa Requires manual QA before merge priority-high review Ready for code review risk-low

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants