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>
124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
// Shared read cache for API responses.
|
|
//
|
|
// Every screen used to fetch on mount, so each tab switch repeated the same
|
|
// requests — the whole catalogue alone was fetched from ten places. Reads now go
|
|
// through `fetch(key, …)`:
|
|
//
|
|
// - cached entry → returned at once, no request
|
|
// - request in flight → the same promise is shared, never a second request
|
|
// - `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, 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.
|
|
//
|
|
// Keys under `me:` hold one customer's data. They are dropped whenever the
|
|
// session token changes — or the act-as target does, which changes whose data
|
|
// they hold without changing the token — so one user never sees another's.
|
|
import { defineStore } from 'pinia'
|
|
import { watch } from 'vue'
|
|
import { useSessionStore } from './session'
|
|
|
|
export const KEYS = {
|
|
catalog: 'catalog',
|
|
goldPrice: 'goldPrice',
|
|
homeSlider: 'homeSlider',
|
|
profile: 'me:profile',
|
|
orders: 'me:orders',
|
|
balance: 'me:balance',
|
|
ledger: 'me:ledger',
|
|
} as const
|
|
|
|
const USER_PREFIX = 'me:'
|
|
|
|
interface Entry {
|
|
data?: unknown
|
|
/** False until the first successful response. */
|
|
has: boolean
|
|
inflight?: Promise<unknown>
|
|
}
|
|
|
|
export interface FetchOptions {
|
|
/** Skip the cached value. An identical request already in flight is still shared. */
|
|
force?: boolean
|
|
}
|
|
|
|
export const useQueryCache = defineStore('queryCache', () => {
|
|
const entries = new Map<string, Entry>()
|
|
const session = useSessionStore()
|
|
|
|
function request<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
const entry = entries.get(key)
|
|
if (entry?.inflight) return entry.inflight as Promise<T>
|
|
|
|
const p: Promise<T> = fetcher()
|
|
.then((data) => {
|
|
// Invalidated (or the user changed) while this was in flight: the
|
|
// response may predate the change, so it is handed back but not kept.
|
|
if (entries.get(key)?.inflight === p) entries.set(key, { data, has: true })
|
|
return data
|
|
})
|
|
.finally(() => {
|
|
const current = entries.get(key)
|
|
if (current?.inflight === p) current.inflight = undefined
|
|
})
|
|
|
|
entries.set(key, { ...(entry ?? { has: false }), inflight: p })
|
|
return p
|
|
}
|
|
|
|
async function fetch<T>(key: string, fetcher: () => Promise<T>, opts: FetchOptions = {}): Promise<T> {
|
|
const entry = entries.get(key)
|
|
if (!opts.force && entry?.has) return entry.data as T
|
|
return request(key, fetcher)
|
|
}
|
|
|
|
/** The cached value, without fetching. */
|
|
function peek<T>(key: string): T | undefined {
|
|
const entry = entries.get(key)
|
|
return entry?.has ? (entry.data as T) : undefined
|
|
}
|
|
|
|
function invalidate(...keys: string[]) {
|
|
for (const key of keys) entries.delete(key)
|
|
}
|
|
|
|
function clearUserScoped() {
|
|
for (const key of [...entries.keys()]) {
|
|
if (key.startsWith(USER_PREFIX)) entries.delete(key)
|
|
}
|
|
}
|
|
|
|
// The token changes when the user changes — and, with «ورود به حساب مشتری»,
|
|
// WHOSE data `me:` keys hold changes without the token changing at all. Both
|
|
// have to drop the cache, or a sales expert entering a customer's account
|
|
// would be shown their own profile, orders, balance and ledger from before
|
|
// the switch.
|
|
watch(
|
|
() => [session.token, session.actingAs?.id ?? null],
|
|
() => clearUserScoped(),
|
|
)
|
|
|
|
return { fetch, peek, invalidate, clearUserScoped }
|
|
})
|