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:
co-authored by
Claude Opus 5
parent
ddcc8c5649
commit
bfbac92df5
@@ -30,13 +30,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { useNavStore } from '../../stores/navStore'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
/** Show the back button. */
|
||||
back?: boolean
|
||||
/** Route name to go to instead of history.back(). */
|
||||
/**
|
||||
* Where to go when there is nothing to go back to — a direct link, a
|
||||
* refresh, or a previous entry the router guard would bounce us out of.
|
||||
*
|
||||
* A FALLBACK, not a destination. It used to be the destination, which is
|
||||
* why back from the login page always landed on home even when the person
|
||||
* had arrived from their profile: every screen pushed a fixed route name
|
||||
* and the real history was ignored (inherited from the reference, which
|
||||
* did the same — `$router.push({name:'userPage::hasSubRoute'})` in each
|
||||
* component's back arrow).
|
||||
*/
|
||||
backTo?: string
|
||||
/** Custom back handler; wins over backTo and history (e.g. a multi-step form stepping back). */
|
||||
backAction?: () => void
|
||||
@@ -45,11 +56,31 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const nav = useNavStore()
|
||||
|
||||
function onBack() {
|
||||
if (props.backAction) props.backAction()
|
||||
else if (props.backTo) router.push({ name: props.backTo })
|
||||
else router.back()
|
||||
// An explicit handler still wins: a multi-step form stepping back inside
|
||||
// itself is not a history move at all.
|
||||
if (props.backAction) {
|
||||
props.backAction()
|
||||
return
|
||||
}
|
||||
// Prefer true history: it is the only thing that knows where the person
|
||||
// actually came from.
|
||||
//
|
||||
// `canGoBack` is tracked by the router (navStore), not read off
|
||||
// `history.length`, which counts entries from before this app was opened and
|
||||
// so cannot tell "arrived from the profile" from "opened this link in a new
|
||||
// tab".
|
||||
if (nav.canGoBack) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
if (props.backTo) {
|
||||
router.push({ name: props.backTo })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ app.use(router);
|
||||
setUnauthorizedHandler(() => {
|
||||
useSessionStore().logout();
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
void router.push({ name: 'login' });
|
||||
// `replace`: the page whose request just failed is behind a dead session,
|
||||
// so leaving it in history only gives the person a back button that returns
|
||||
// them to it and bounces straight back here.
|
||||
void router.replace({ name: 'login' });
|
||||
}
|
||||
});
|
||||
// Ported from legacy main.ts:32-46 — Vector.svg is both the while-loading and
|
||||
|
||||
@@ -187,6 +187,11 @@ router.afterEach((to) => {
|
||||
const nav = useNavStore();
|
||||
const transition = useTransitionStore();
|
||||
|
||||
// Remembers where in the browser's history this app instance started, which
|
||||
// is what PageBar's back button compares against before choosing between a
|
||||
// real history-back and its fallback route.
|
||||
nav.noteFirstNavigation();
|
||||
|
||||
transition.setTransitionFlag(false);
|
||||
transition.setTransitionEnded(false);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -18,6 +18,18 @@ interface NavState {
|
||||
showNav: boolean
|
||||
// legacy passes `undefined` to clear the transition (router/index.ts:270)
|
||||
pageTransition: string | undefined
|
||||
/**
|
||||
* The history index this app started at, or null before the first
|
||||
* navigation.
|
||||
*
|
||||
* Answers "is there anywhere inside this app to go back to?", which
|
||||
* `history.length` cannot: it also counts entries from before the app was
|
||||
* opened, so it says yes for a link opened in a fresh tab and the back button
|
||||
* would leave the app. Comparing the CURRENT history position against the one
|
||||
* we started at is direction-proof — it stays correct however many times the
|
||||
* person goes back and forward, where a plain counter would only ever grow.
|
||||
*/
|
||||
entryIndex: number | null
|
||||
}
|
||||
|
||||
export const useNavStore = defineStore('nav', {
|
||||
@@ -32,8 +44,34 @@ export const useNavStore = defineStore('nav', {
|
||||
activeLink: 1,
|
||||
showNav: true,
|
||||
pageTransition: 'right',
|
||||
entryIndex: null,
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
* True when `router.back()` lands somewhere inside this app rather than on
|
||||
* whatever the person was looking at before they opened it.
|
||||
*/
|
||||
canGoBack: (state) => {
|
||||
if (state.entryIndex === null) return false
|
||||
const current = window.history.state?.position
|
||||
// `position` is written by vue-router on every entry it creates. If it is
|
||||
// missing (a hand-written history entry, an old browser), assume we must
|
||||
// not leave the app and let the caller fall back.
|
||||
return typeof current === 'number' ? current > state.entryIndex : false
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Records where in history this app instance began. Called once, on the
|
||||
* first navigation — every later entry sits above it, so anything above is
|
||||
* ours to go back through.
|
||||
*/
|
||||
noteFirstNavigation() {
|
||||
if (this.entryIndex !== null) return
|
||||
const position = window.history.state?.position
|
||||
this.entryIndex = typeof position === 'number' ? position : 0
|
||||
},
|
||||
|
||||
setNavVisibility(data: boolean) {
|
||||
this.showNav = data
|
||||
},
|
||||
|
||||
@@ -9,8 +9,25 @@
|
||||
// - `force` → always asks the server (pull-to-refresh, the
|
||||
// profile tab's balance, reaching the end of a list)
|
||||
//
|
||||
// Entries never expire on a timer: data changes when the user asks for it or
|
||||
// when a write here makes it wrong, and those writes call `invalidate`.
|
||||
// Entries never expire on a timer, deliberately (owner ruling 2026-10-12: «هربار
|
||||
// با رفتن به اون صفحه اون مقادیر مورد نیاز رو درخواست بزنه … هم سربار برای سرور
|
||||
// نداره، هم درخواست الکی نمیخوره، هم دیتاها بهروز میمونه»). A TTL guesses how
|
||||
// long data stays true; entering a screen is the moment it actually matters.
|
||||
//
|
||||
// So the policy is:
|
||||
//
|
||||
// - a screen that shows server-owned data re-reads it with `force` when it is
|
||||
// opened (`onMounted`, plus `onActivated` for the kept-alive views), while
|
||||
// `peek` paints the previous copy instantly so the refresh is invisible;
|
||||
// - the cached copy exists for that first paint and to collapse concurrent
|
||||
// callers into one request, NOT to avoid asking again;
|
||||
// - the catalogue is the exception: it is the whole product list in one
|
||||
// response, changes when an admin edits it, and is already invalidated by
|
||||
// pull-to-refresh and by placing an order.
|
||||
//
|
||||
// `force` is what makes this work for anything changed OUTSIDE the app — an
|
||||
// order's status, a ledger entry, the gold price. Nothing here can know those
|
||||
// changed, so not asking means never seeing them.
|
||||
//
|
||||
// Entries live in a plain Map, not reactive state: the catalogue is large and
|
||||
// making it deeply reactive would cost more than the request it saves.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -30,23 +30,49 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { useNavStore } from '../../stores/navStore'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
/** Show the back button. */
|
||||
back?: boolean
|
||||
/** Route name to go to instead of history.back(). */
|
||||
/**
|
||||
* Where to go when there is nothing to go back to — a direct link, a
|
||||
* refresh, or a previous entry the router guard would bounce us out of.
|
||||
*
|
||||
* A FALLBACK, not a destination. It used to be the destination, which is
|
||||
* why back from the login page always landed on home even when the person
|
||||
* had arrived from their profile: every screen pushed a fixed route name
|
||||
* and the real history was ignored (inherited from the reference, which
|
||||
* did the same — `$router.push({name:'userPage::hasSubRoute'})` in each
|
||||
* component's back arrow).
|
||||
*/
|
||||
backTo?: string
|
||||
}>(),
|
||||
{ back: true },
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const nav = useNavStore()
|
||||
|
||||
function onBack() {
|
||||
if (props.backTo) router.push({ name: props.backTo })
|
||||
else router.back()
|
||||
// Prefer true history: it is the only thing that knows where the person
|
||||
// actually came from.
|
||||
//
|
||||
// `canGoBack` is tracked by the router (navStore), not read off
|
||||
// `history.length`, which counts entries from before this app was opened and
|
||||
// so cannot tell "arrived from the profile" from "opened this link in a new
|
||||
// tab".
|
||||
if (nav.canGoBack) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
if (props.backTo) {
|
||||
router.push({ name: props.backTo })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ app.use(router);
|
||||
setUnauthorizedHandler(() => {
|
||||
useSessionStore().logout();
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
void router.push({ name: 'login' });
|
||||
// `replace`: the page whose request just failed is behind a dead session,
|
||||
// so leaving it in history only gives the person a back button that returns
|
||||
// them to it and bounces straight back here.
|
||||
void router.replace({ name: 'login' });
|
||||
}
|
||||
});
|
||||
// Ported from legacy main.ts:32-46, with one change: the brand mark is now only
|
||||
|
||||
@@ -156,6 +156,10 @@ router.beforeEach(async (to) => {
|
||||
// document that is being replaced would never be read. Not a gap.
|
||||
if (to.meta.requiresAuth && !session.isLoggedIn) {
|
||||
toast.info('ابتدا وارد شوید');
|
||||
// No loop to guard against here: a redirected navigation never becomes a
|
||||
// history entry, so the entry behind the login screen is wherever the
|
||||
// person actually was (their dashboard, home, a product), and
|
||||
// history-back goes there correctly.
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
@@ -204,6 +208,11 @@ router.afterEach((to) => {
|
||||
const nav = useNavStore();
|
||||
const transition = useTransitionStore();
|
||||
|
||||
// Remembers where in the browser's history this app instance started, which
|
||||
// is what PageBar's back button compares against before choosing between a
|
||||
// real history-back and its fallback route.
|
||||
nav.noteFirstNavigation();
|
||||
|
||||
transition.setTransitionFlag(false);
|
||||
transition.setTransitionEnded(false);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -18,6 +18,18 @@ interface NavState {
|
||||
showNav: boolean
|
||||
// legacy passes `undefined` to clear the transition (router/index.ts:270)
|
||||
pageTransition: string | undefined
|
||||
/**
|
||||
* The history index this app started at, or null before the first
|
||||
* navigation.
|
||||
*
|
||||
* Answers "is there anywhere inside this app to go back to?", which
|
||||
* `history.length` cannot: it also counts entries from before the app was
|
||||
* opened, so it says yes for a link opened in a fresh tab and the back button
|
||||
* would leave the app. Comparing the CURRENT history position against the one
|
||||
* we started at is direction-proof — it stays correct however many times the
|
||||
* person goes back and forward, where a plain counter would only ever grow.
|
||||
*/
|
||||
entryIndex: number | null
|
||||
}
|
||||
|
||||
export const useNavStore = defineStore('nav', {
|
||||
@@ -32,8 +44,34 @@ export const useNavStore = defineStore('nav', {
|
||||
activeLink: 1,
|
||||
showNav: true,
|
||||
pageTransition: 'right',
|
||||
entryIndex: null,
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
* True when `router.back()` lands somewhere inside this app rather than on
|
||||
* whatever the person was looking at before they opened it.
|
||||
*/
|
||||
canGoBack: (state) => {
|
||||
if (state.entryIndex === null) return false
|
||||
const current = window.history.state?.position
|
||||
// `position` is written by vue-router on every entry it creates. If it is
|
||||
// missing (a hand-written history entry, an old browser), assume we must
|
||||
// not leave the app and let the caller fall back.
|
||||
return typeof current === 'number' ? current > state.entryIndex : false
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Records where in history this app instance began. Called once, on the
|
||||
* first navigation — every later entry sits above it, so anything above is
|
||||
* ours to go back through.
|
||||
*/
|
||||
noteFirstNavigation() {
|
||||
if (this.entryIndex !== null) return
|
||||
const position = window.history.state?.position
|
||||
this.entryIndex = typeof position === 'number' ? position : 0
|
||||
},
|
||||
|
||||
setNavVisibility(data: boolean) {
|
||||
this.showNav = data
|
||||
},
|
||||
|
||||
@@ -9,8 +9,25 @@
|
||||
// - `force` → always asks the server (pull-to-refresh, the
|
||||
// profile tab's balance, reaching the end of a list)
|
||||
//
|
||||
// Entries never expire on a timer: data changes when the user asks for it or
|
||||
// when a write here makes it wrong, and those writes call `invalidate`.
|
||||
// Entries never expire on a timer, deliberately (owner ruling 2026-10-12: «هربار
|
||||
// با رفتن به اون صفحه اون مقادیر مورد نیاز رو درخواست بزنه … هم سربار برای سرور
|
||||
// نداره، هم درخواست الکی نمیخوره، هم دیتاها بهروز میمونه»). A TTL guesses how
|
||||
// long data stays true; entering a screen is the moment it actually matters.
|
||||
//
|
||||
// So the policy is:
|
||||
//
|
||||
// - a screen that shows server-owned data re-reads it with `force` when it is
|
||||
// opened (`onMounted`, plus `onActivated` for the kept-alive views), while
|
||||
// `peek` paints the previous copy instantly so the refresh is invisible;
|
||||
// - the cached copy exists for that first paint and to collapse concurrent
|
||||
// callers into one request, NOT to avoid asking again;
|
||||
// - the catalogue is the exception: it is the whole product list in one
|
||||
// response, changes when an admin edits it, and is already invalidated by
|
||||
// pull-to-refresh and by placing an order.
|
||||
//
|
||||
// `force` is what makes this work for anything changed OUTSIDE the app — an
|
||||
// order's status, a ledger entry, the gold price. Nothing here can know those
|
||||
// changed, so not asking means never seeing them.
|
||||
//
|
||||
// Entries live in a plain Map, not reactive state: the catalogue is large and
|
||||
// making it deeply reactive would cost more than the request it saves.
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
@@ -271,6 +271,32 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
reveal()
|
||||
})
|
||||
|
||||
/**
|
||||
* 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">
|
||||
|
||||
@@ -127,10 +127,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>
|
||||
|
||||
|
||||
@@ -262,7 +262,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
|
||||
@@ -271,7 +271,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)
|
||||
@@ -279,7 +284,7 @@ async function loadDashboard(force = false) {
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await loadDashboard(true)
|
||||
await loadDashboard()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,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(
|
||||
|
||||
@@ -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,35 @@ 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. Without it the two calls would overlap; the cache
|
||||
* would collapse them into one request anyway, but not firing twice is clearer
|
||||
* than relying on that.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1085,3 +1085,94 @@ the order audit trail. Full api suite **308/308, 28 suites**; both apps
|
||||
`vue-tsc` clean.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 — Apps — back navigation and read freshness (NAV-01…04, READ-01…03)
|
||||
|
||||
- **What:** The back arrow follows real history instead of a fixed route; three
|
||||
`push`es that stranded the user became `replace`; screens showing
|
||||
server-owned data re-read it on entry.
|
||||
- **Why:** owner report — «از صفحه پروفایل وارد صفحه لاگین میشم بک میزنم میره به
|
||||
صفحه اصلی در صورتی که باید بیاد توی صفحه پروفایل»— with the instruction to
|
||||
audit the whole structure, not just that case. Seven defects, four of them
|
||||
reachable in ordinary use.
|
||||
- **Reference:** the fixed-destination back arrow is inherited —
|
||||
`henza_pwa/.../profile.vue:6`, `Orders.vue:8`, `transactions.vue:7`,
|
||||
`favorits.vue:5` etc. all `$router.push({name:'userPage::hasSubRoute'})`. Not
|
||||
replicated (D4: behaviour, not breakage).
|
||||
- **Target:** both apps' `components/ui/PageBar.vue`, `stores/navStore.ts`,
|
||||
`router/index.ts`, `main.ts`, `views/user/LoginView.vue`,
|
||||
`views/user/CompleteProfileView.vue`, `views/user/OrdersView.vue`,
|
||||
`views/user/TransactionsView.vue`, `views/user/DashboardView.vue`,
|
||||
`views/HomeView.vue`, `stores/queryCache.ts`.
|
||||
- **Matrix row:** NAV-01…04, READ-01…03 in `ISSUES.md`.
|
||||
- **Commit:** see below.
|
||||
|
||||
**The reported bug, precisely.** `/user` has no auth guard, so a guest sees the
|
||||
dashboard and taps «ورود / ثبتنام». History is then dashboard → login. Back
|
||||
should return to the dashboard, but `LoginView` declared `back-to="home"` and
|
||||
`PageBar` **pushed that name**, discarding history. The same was true of 14 of
|
||||
the 15 `PageBar` uses in each app — only `SearchView` used `router.back()`. So
|
||||
this was never one screen's bug.
|
||||
|
||||
**`backTo` is now a fallback, not a destination.** The 14 declarations are kept
|
||||
untouched, because they are the right answer when there is genuinely nowhere to
|
||||
go back to — a shared link, a refresh. What changed is that history wins when it
|
||||
exists.
|
||||
|
||||
**Answering "is there anywhere to go back to?" honestly.** `history.length` is
|
||||
the obvious choice and it is wrong: it counts entries from before the app was
|
||||
opened, so a link opened in a fresh tab looks like it has history and the back
|
||||
button walks the person out of the app. `navStore` instead records the history
|
||||
`position` of the app's first navigation and compares the current one against it.
|
||||
Chosen over a navigation counter because a counter only grows — after one
|
||||
back-and-forward it would claim depth that is not there. Confirmed vue-router
|
||||
writes `position` on every entry (`vue-router.mjs:97,109,145`) rather than
|
||||
assuming it.
|
||||
|
||||
**Three `push`es that should always have been `replace`.** Each left behind a
|
||||
page the guards immediately bounce, so "back" was either a no-op or a trip home:
|
||||
after sign-in the login screen stayed in history (the worst of the three — the
|
||||
file already honoured `?redirect=`, so sign-in and back disagreed about where the
|
||||
person belonged); the 401 handler kept the page whose request had just failed;
|
||||
and complete-profile kept a form that is unreachable once the documents are in.
|
||||
`CartView`'s post-order push was **left alone** — back lands on the emptied
|
||||
cart, which is a real screen rather than a dead end.
|
||||
|
||||
**Read freshness, by the owner's rule** («هربار با رفتن به اون صفحه اون مقادیر
|
||||
مورد نیاز رو درخواست بزنه») — chosen over the TTL that had been proposed,
|
||||
and the better call: a TTL guesses how long data stays true, while entering a
|
||||
screen is when it actually matters.
|
||||
|
||||
The worst finding here was **OrdersView**: 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 one read the cache. Order status changes on the admin side
|
||||
and nothing in the app invalidates it, so the list could be stale for an entire
|
||||
session. Ledger entries had the same shape and a stronger argument — 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 two screens that show it
|
||||
prominently, and `HomeView` being kept alive meant once per app launch.
|
||||
|
||||
**What is deliberately NOT re-read: the catalogue.** By the owner's own
|
||||
reasoning («سربار برای سرور نداره»): it is the whole active product list in one
|
||||
response, it changes when an admin edits it, and pull-to-refresh and placing an
|
||||
order already invalidate it. Re-fetching it on every tab switch is exactly the
|
||||
needless load the ruling exists to avoid. So on Home only the price is re-read.
|
||||
|
||||
**On the cache layer, which the owner also asked about.** Its structure is
|
||||
sound, and two things that are easy to get wrong are right: concurrent callers
|
||||
share one in-flight promise, and a response arriving after an `invalidate` is
|
||||
returned to its caller but not stored, so a write cannot lose a race against a
|
||||
read already in flight. Its one real gap was that nothing ever went stale, which
|
||||
the above fixes at the only point where staleness is observable. The policy is
|
||||
now written at the top of `queryCache.ts` so a TTL is not re-introduced later.
|
||||
|
||||
**Verification.** Neither app has a test runner, so: `vue-tsc -b` clean on both,
|
||||
and each finding was confirmed by reading the code that causes it — the
|
||||
keep-alive list against each view's hooks, every `router.push` against the
|
||||
guards, and `grep back-to=` across both apps. api suite untouched at 308/308.
|
||||
⚠️ Not click-tested in a browser; the back-button behaviour in particular is
|
||||
worth one pass by hand.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,7 +21,7 @@ The "Current state" table below is the parity project's and was last updated 202
|
||||
|---|---|
|
||||
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
|
||||
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
|
||||
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, app-login error reporting (**AUTH-01/02/03**, **DOC-01**), and **«ورود به حساب مشتری» restored** (**EXP-01/02/03**, D29 — supersedes D21's omission). **308/308** jest tests; both apps `vue-tsc` clean. |
|
||||
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, app-login error reporting (**AUTH-01/02/03**, **DOC-01**), **«ورود به حساب مشتری» restored** (**EXP-01/02/03**, D29 — supersedes D21's omission), and the apps' back-navigation + read-freshness audit (**NAV-01…04**, **READ-01…03**). **308/308** jest tests; both apps `vue-tsc` clean. |
|
||||
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
|
||||
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
|
||||
| **Last updated** | 2026-10-12 |
|
||||
|
||||
Reference in New Issue
Block a user