fix(apps): back button follows real history; screens re-read on entry

Reported: from the profile page, going to login and pressing back landed
on home instead of the profile — with the instruction to audit the whole
structure rather than that one case. Seven defects, four reachable in
ordinary use.

NAVIGATION

PageBar's back arrow pushed a FIXED route name (`backTo`) and ignored
history, so it could not know where the person came from. That was true
of 14 of the 15 PageBar uses in each app — only SearchView used
router.back() — so it was never one screen's bug. Inherited from the
reference, which pushed a fixed name in every component's back arrow;
not replicated (D4).

`backTo` is now a fallback, used only when there is nothing to go back
to (a shared link, a refresh). All 14 declarations are kept untouched,
because that is exactly when they are right.

Answering "is there anywhere to go back to?" needs care: history.length
counts entries from before the app was opened, so a link opened in a
fresh tab would look navigable and back would walk the person out of the
app. navStore records the history `position` of the app's first
navigation and compares the current one against it — direction-proof,
unlike a counter, which only grows and would be wrong after one
back-and-forward. Confirmed vue-router writes `position` on every entry
rather than assuming it.

Three pushes became replaces, each of which left behind a page the
guards immediately bounce, making "back" a no-op or a trip home:
  - after sign-in the login screen stayed in history — the worst, since
    the same file already honoured ?redirect=, so signing in and
    pressing back disagreed about where the person belonged;
  - the 401 handler kept the page whose request had just failed;
  - complete-profile kept a form that is unreachable once the documents
    are in.
CartView's post-order push is left alone: back lands on the emptied
cart, which is a real screen, not a dead end.

READ FRESHNESS

Owner's rule: fetch what a screen needs when it is opened, rather than
caching with a lifetime. Better than the TTL I had proposed — a TTL
guesses how long data stays true, while entering the screen is when it
actually matters.

OrdersView was the worst: it is in the keep-alive list but only loaded
in onMounted, so the second and every later visit made no request at
all, and the first read the cache. Order status changes on the admin
side and nothing here invalidates it, so the list could be stale for a
whole session. Ledger entries had the same shape and a stronger reason —
they are written by the accounting side, which this app can never learn
about. The gold price was forced only by pull-to-refresh on the two
screens that show it most prominently.

The catalogue is deliberately NOT re-read, by the owner's own reasoning
that this must not add server load: it is the entire product list in one
response, it changes when an admin edits it, and pull-to-refresh and
placing an order already invalidate it.

The cache layer itself is sound — concurrent callers share one in-flight
promise, and a response arriving after an invalidate is returned but not
stored, so a write cannot lose a race against a read in flight. Its one
real gap was that nothing ever went stale. The policy is now written at
the top of queryCache.ts so a TTL is not re-introduced.

Both apps vue-tsc clean. Not click-tested in a browser; the back-button
behaviour is worth one manual pass.

اپ: دکمه بازگشت به صفحه قبلی واقعی برمی‌گردد و اطلاعات هر صفحه در هر بازدید به‌روز می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-12 12:42:42 +03:30
co-authored by Claude Opus 5
parent ddcc8c5649
commit bfbac92df5
25 changed files with 498 additions and 34 deletions
+26
View File
@@ -265,3 +265,29 @@ the customer's own); a permanent «در حال مشاهده به‌عنوان
button, which the reference had no equivalent of — there, impersonation was
invisible and the only way out was a logout that also ended the expert's own
session; and the cart is cleared in both directions.
## Apps — navigation and read-freshness audit (2026-10-12)
Owner report: «من مثلا از صفحه پروفایل وارد صفحه لاگین میشم بک میزنم میره به صفحه
اصلی در صورتی که باید بیاد توی صفحه پروفایل … این یک نمونه بود، تمام ساختار این
مدلی رو برسی کن». Audited both, found four navigation defects and three
read-freshness ones.
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **NAV-01** | Medium (UX, pervasive) | TGT both apps `components/ui/PageBar.vue` + 14 views | The back arrow pushed a **fixed route name** (`backTo`) instead of going back, so it ignored where the person came from. The reported case: the guest dashboard → «ورود / ثبت‌نام» → back landed on **home**, not the dashboard they came from, because `LoginView` declared `back-to="home"`. Of 15 `PageBar` uses only `SearchView` used real history. Inherited from the reference, which did the same in every component's back arrow (`$router.push({name:'userPage::hasSubRoute'})`). | Read `PageBar.onBack` and grepped all `back-to=` uses. | ✅ **FIXED.** `backTo` is now a **fallback**, used only when there is nothing to go back to; otherwise `router.back()`. The 14 declarations are kept and unchanged — they are exactly the right fallback for a direct link or refresh. |
| **NAV-02** | Medium | TGT both apps `stores/navStore.ts` (new), `router/index.ts` | NAV-01 needs to know whether back lands inside the app. `history.length` cannot answer it: it counts entries from before the app was opened, so a link opened in a fresh tab would look like it had somewhere to go back to and the back button would leave the app. | — | ✅ `navStore.entryIndex` records the history position of the app's first navigation (`afterEach`), and `canGoBack` compares `history.state.position` against it. Direction-proof, unlike a counter, which only grows and would be wrong after one back-and-forward. Verified vue-router writes `position` on every entry (`vue-router.mjs:97,109,145`). |
| **NAV-03** | **High (loses the user's place)** | TGT both apps `views/user/LoginView.vue` | After a successful sign-in the app `push`ed the destination, leaving the **login screen in history**. One tap of back returned to the login box, which the guard then bounced to home — so back after signing in lost the person's place entirely. The same file already honoured `?redirect=`, so sign-in and back disagreed about where the person belonged. | Read `sendToken`'s navigation against the `to.name === 'login'` guard. | ✅ `router.replace`. |
| **NAV-04** | Medium | TGT both apps `main.ts` (401 handler), `views/user/CompleteProfileView.vue` | Two more `push`es where the page being left is no longer reachable: the 401 handler left the page whose request just failed in history (back → it → bounced to login again), and complete-profile left a form that the guard redirects home once the documents are in, so back appeared to do nothing. | Grepped every `router.push` in both apps and checked each against the guards. | ✅ Both are `router.replace`. `CartView`'s post-order `push` to the orders list was **left as-is** — back lands on the emptied cart, which is a valid screen, not a dead end. |
| **READ-01** | **High (stale money data)** | TGT both apps `views/user/OrdersView.vue` | The view is in MasterLayout's keep-alive list but only loaded in `onMounted`, so **the second and every later visit made no request at all** — and even the first used the cache. An order's status changes on the admin side, which nothing in the app invalidates, so the list could be stale for the whole session. | `KEEP_ALIVE = ['HomeView','SearchView','OrdersView','DashboardView']` vs the view's single `onMounted`. | ✅ `onMounted` + `onActivated`, both `force`, per the owner's ruling. |
| **READ-02** | Medium | TGT both apps `views/user/TransactionsView.vue`, `HomeView.vue`, `user/DashboardView.vue` | Ledger entries were read without `force`, so after the first visit the cached list was shown indefinitely — and they are written by the **accounting** side, which this app can never know about. The gold price was only forced by pull-to-refresh, on two screens that show it prominently; `HomeView` is kept alive, so it was fetched once per app launch. | Read each call site. | ✅ All forced on entry. `DashboardView.loadDashboard`'s `force` parameter is gone: with both of its reads always fresh it controlled nothing. |
| **READ-03** | — (deliberate) | TGT both apps `stores/catalogStore.ts` | The catalogue is **not** re-read on entry. | — | Intentional, per the owner's own reasoning («سربار برای سرور نداره»): it is the entire active product list in one response, it changes when an admin edits it rather than minute to minute, and pull-to-refresh and placing an order already invalidate it. Re-fetching it on every tab switch is the needless load the ruling is meant to avoid. |
**On the cache layer itself** (the owner also asked whether its structure is
sound). It is: concurrent callers share one in-flight promise, and a response
that arrives after an `invalidate` is handed to its caller but **not** stored, so
a write never loses a race against a read in progress. Both are easy to get
wrong and both are right here. Its one real gap was that nothing ever went
stale — no TTL, and `force` used only by pull-to-refresh — which READ-01/02 fix
at the point that matters: opening the screen. The policy is now written at the
top of `queryCache.ts` so the next person does not re-introduce a TTL.