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
+27 -1
View File
@@ -80,7 +80,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
@@ -247,6 +247,32 @@ onMounted(async () => {
hasRevealed = true
})
})
/**
* Owner ruling 2026-10-12: re-read on entering a screen rather than caching
* with a lifetime. This view is kept alive, so without this the gold price was
* fetched once per app launch and then never again until pull-to-refresh — on
* the screen that shows it most prominently.
*
* Only the PRICE is re-read, not the catalogue or the slides: the catalogue is
* the whole active product list in one response, and re-fetching it on every
* tab switch is exactly the needless load the ruling is meant to avoid. It
* changes when an admin edits it, not minute to minute, and pull-to-refresh and
* placing an order both already invalidate it.
*/
let activatedOnce = false
onActivated(async () => {
if (!activatedOnce) {
activatedOnce = true
return
}
try {
goldPrice.value = await reads.goldPrice({ force: true })
} catch (e) {
// Keep the figure already on screen rather than blanking it.
console.log(e)
}
})
</script>
<style scoped lang="less">
@@ -122,10 +122,16 @@ async function submit() {
}
}
/** The reference sent the user home after the dialog (:175-177). */
/**
* The reference sent the user home after the dialog (:175-177).
*
* `replace`: once the documents are in, this route is no longer reachable — the
* router guard redirects it home — so leaving it in history gives a back button
* that appears to do nothing.
*/
function finish() {
done.value = false
router.push({ name: 'home' })
router.replace({ name: 'home' })
}
</script>
@@ -308,7 +308,7 @@ async function logOut() {
router.push({ name: 'home' })
}
async function loadDashboard(force = false) {
async function loadDashboard() {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
@@ -317,7 +317,12 @@ async function loadDashboard(force = false) {
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
reads.goldPrice({ force }),
// Also forced, per the owner's 2026-10-12 ruling: both figures on this tab
// are read fresh on every visit. This used to follow a `force` parameter
// that only pull-to-refresh set, so the price here could be as old as the
// app launch. With both reads always fresh the parameter had nothing left
// to control, so it is gone.
reads.goldPrice({ force: true }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
@@ -325,7 +330,7 @@ async function loadDashboard(force = false) {
}
async function onRefresh() {
await loadDashboard(true)
await loadDashboard()
refreshing.value = false
}
@@ -182,7 +182,11 @@ const sendToken = async (otpCode: string) => {
session.setSalesExpert(false)
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
// `replace`, not `push`: the login screen must not stay in history. Pushed,
// going back from the page they just reached returns them to the login box
// they have already used, which the guard above then bounces to home — so
// one tap of "back" loses their place entirely.
await router.replace(redirect ?? { name: 'home' })
toast.success('با موفقیت وارد شدید')
} catch (e: unknown) {
toast.error(
+28 -2
View File
@@ -147,7 +147,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
@@ -232,7 +232,33 @@ async function onRefresh() {
refreshing.value = false
}
onMounted(() => loadOrders())
/**
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
* نمیخوره، هم دیتاها به‌روز میمونه».
*
* `force` matters because an order's status changes on the ADMIN side — nothing
* in this app invalidates the cache when that happens, so without it a list
* fetched once was shown unchanged for the rest of the session.
*
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
* keep-alive list, so it is mounted once and every later visit only activates
* it. With `onMounted` alone the second visit made no request at all.
*
* The cached copy still renders instantly (`peek` above) and `loading` only
* shows a spinner when there is nothing to show, so the refresh is invisible.
*
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
* shape DashboardView uses.
*/
onMounted(() => loadOrders(true))
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void loadOrders(true)
activatedOnce = true
})
</script>
<style scoped lang="less">
@@ -142,11 +142,23 @@ const openEntry = (item: LedgerEntrySummary) => {
showPopUp.value = true
}
/**
* Owner ruling 2026-10-12: re-read on entering the screen.
*
* `force` is the point here. Ledger entries are written by the ACCOUNTING side,
* never by this app, so nothing here ever invalidated the cached copy — the
* first visit's list was shown for the rest of the session, and a document
* posted meanwhile stayed invisible until pull-to-refresh.
*
* This view is not in the keep-alive list, so `onMounted` runs on every visit
* and no `onActivated` is needed. The cached copy renders immediately (`peek`
* above), so the re-read is not visible as a loading state.
*/
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = reads.peek(KEYS.ledger) === undefined
try {
entries.value = await reads.myLedgerEntries()
entries.value = await reads.myLedgerEntries({ force: true })
} finally {
loading.value = false
}