Skip to content

Commit 7060dbe

Browse files
committed
fix: only hold fully-loaded pages + reset all primary tokens on clear (#256)
Two conservative follow-ups to the displayedToken anti-flash work: - fetchHasDisplayablePage now requires EVERY manifest depth to have page data before a fetch can be held as the displayed page. The depth-0-only check wrongly held a half-loaded nested page (shared parent loaded, child still loading) when clicking away, leaving the user on a parent + spinning child instead of the last fully-loaded page. - clearPrimaryFetch resets fetchingToken and displayedToken too, not just successToken, so abandoning a primary fetch can't leave a stale displayed reference. Adds a nested-child-then-home reproduction and a clearPrimaryFetch unit test. Note: an OPEN rapid nested-navigation regression remains (new route's fetch not superseding the in-flight one) — not reproducible headlessly, tracked in CLAUDE.md #256 for follow-up.
1 parent f358872 commit 7060dbe

4 files changed

Lines changed: 104 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,14 @@ Consuming-app **unscoped** global element CSS (`h1/h2/h3`, `a`, `button`, …) r
367367
**[#157](https://github.com/components-web-app/cwa-nuxt-module/issues/157) — Clone a resource**
368368
Admin UI functionality to duplicate an existing resource (page, component, etc.).
369369

370-
**[#256](https://github.com/components-web-app/cwa-nuxt-module/issues/256) — Bug (regression): navigation reverts to a stale (last *fully-loaded*) page instead of holding the page on screen** ✅ Fixed (dev)
370+
**[#256](https://github.com/components-web-app/cwa-nuxt-module/issues/256) — Bug (regression): navigation reverts to a stale (last *fully-loaded*) page instead of holding the page on screen** ⚠️ Partially fixed — OPEN nested-nav regression (see "Follow-up" below)
371+
372+
> **⚠️ CURRENT STATE (2026-07-09, end of session — RESUME HERE):** The `displayedToken` fix below is **deployed to edge** (`@cwa/nuxt-edge`). After deploying, Daniel found a **cluster of rapid nested-navigation regressions** that are NOT yet fixed and could NOT be reproduced headlessly (timing/lag-dependent — prod only):
373+
> - Switching to a nested page, then clicking a **CWA static home page** before the nested loaded → **`fetchRoute('/_/routes//')` is never called** (no HTTP), the URL changes to home, but the in-flight nested fetch (still the fetching token, never superseded) **fully loads and keeps rendering** — home never shows.
374+
> - Switching between two **never-before-loaded nested siblings** (same data-page parent) before the first loads → **neither loads / no sub-page renders**.
375+
> - Confirmed by Daniel: the last fully-resolved success was a **sibling nested page, NOT home**, so this is NOT the same-path short-circuit. The new route's fetch simply isn't taking over the in-flight one.
376+
> **Leading theory:** rapid-nav supersession in the fetcher/middleware (`route-middleware.ts` fire-and-forget `fetchRoute(to).then(...)` + `startFetch` not aborting the previous in-flight primary for a *different* path, so it stays `fetchingToken`, completes, and wins). Possibly pre-existing / exposed by #250 manifest migration, possibly worsened by the `displayedToken` hold. **Decision pending (Daniel, tomorrow):** (a) revert the #256 `displayedToken` change on dev to isolate whether it caused the nested regression, (b) add dev/console logging to `fetchRoute`/`startFetch`/`finishFetch`/`displayFetchStatus` to capture the real prod sequence, or (c) build the #246 recorded-API replay harness (the only way to deterministically catch this class). I recommended (a) first to isolate.
377+
> **Pushed this session (the safe, tested fixes only):** `fetchHasDisplayablePage` now requires **ALL depths** to have page data before a fetch can be held (was depth-0-only, which wrongly held a half-loaded nested page — parent + spinning child — when clicking away); `clearPrimaryFetch` now resets **all three** primary tokens (was successToken-only). Repro/guards in `navigation-retention.spec.ts`. The risky nested-nav orchestration fix was NOT attempted — awaiting Daniel's decision.
371378
372379
**Root cause (confirmed).** Not a data-drop — a headless store+gate audit (`src/runtime/resources/navigation-retention.spec.ts`) proved data is retained through `IN_PROGRESS` at every gate and the token machinery holds the old page. The real bug: the anti-flash **hold** (`displayFetchStatus` fallback) read `resolvedSuccessFetchStatus` = the **last fully-resolved success token**, which only advances in `finishFetch` when the fetch completes **before the user navigates again** (client nav is fire-and-forget — `route-middleware.ts:104`). So clicking faster than a page loads leaves the success token frozen several navs back; the hold then surfaces that stale page (home / a different conference / "a page from a few clicks ago") while the new page loads. Arriving via a redirect (SRNTE "Next Conference") is one way to strand it. Intermittent = timing-dependent.
373380

src/runtime/api/fetcher/fetch-status-manager.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,28 @@ describe('FetchStatusManager -> startFetch (Start a new fetch chain)', () => {
236236
})
237237
})
238238

239+
describe('FetchStatusManager -> clearPrimaryFetch', () => {
240+
let fetchStatusManager: FetchStatusManager
241+
242+
beforeEach(() => {
243+
fetchStatusManager = createFetchStatusManager()
244+
})
245+
246+
afterEach(() => {
247+
vi.clearAllMocks()
248+
})
249+
250+
test('resets the fetching, success and displayed tokens', () => {
251+
fetchStatusManager._fetcherStore = {
252+
primaryFetch: { fetchingToken: 'f', successToken: 's', displayedToken: 'd' },
253+
}
254+
fetchStatusManager.clearPrimaryFetch()
255+
expect(fetchStatusManager._fetcherStore.primaryFetch.fetchingToken).toBeUndefined()
256+
expect(fetchStatusManager._fetcherStore.primaryFetch.successToken).toBeUndefined()
257+
expect(fetchStatusManager._fetcherStore.primaryFetch.displayedToken).toBeUndefined()
258+
})
259+
})
260+
239261
describe('FetchStatusManager -> startFetchResource', () => {
240262
let fetchStatusManager: FetchStatusManager
241263

src/runtime/api/fetcher/fetch-status-manager.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,19 @@ export default class FetchStatusManager {
121121
return startFetchStatus
122122
}
123123

124-
// Whether a fetch's depth-0 page resource has data in the store — i.e. it was actually rendered.
124+
// Whether a fetch was fully rendered — EVERY depth's page resource has data in the store. A
125+
// partially-loaded nested view (e.g. the shared parent at depth 0 loaded but the child at depth 1
126+
// still loading) must NOT be held as the displayed page, or clicking away leaves the user stuck on
127+
// a parent + spinning child instead of falling back to the last fully-loaded page. See #256.
125128
private fetchHasDisplayablePage(token: string): boolean {
126-
const depth0 = this.fetcherStore.fetches[token]?.manifest?.irisByDepth?.[0]
127-
if (!depth0) {
129+
const irisByDepth = this.fetcherStore.fetches[token]?.manifest?.irisByDepth
130+
if (!irisByDepth?.length) {
128131
return false
129132
}
130-
const pageIri = depth0.find(iri => getResourceTypeFromIri(iri) === CwaResourceTypes.PAGE)
131-
return !!(pageIri && this.resourcesStore.current.byId?.[pageIri]?.data)
133+
return irisByDepth.every((depthGroup) => {
134+
const pageIri = depthGroup.find(iri => getResourceTypeFromIri(iri) === CwaResourceTypes.PAGE)
135+
return !!(pageIri && this.resourcesStore.current.byId?.[pageIri]?.data)
136+
})
132137
}
133138

134139
public startFetchResource(event: AddFetchResourceEvent): boolean {
@@ -316,7 +321,12 @@ export default class FetchStatusManager {
316321

317322
// todo: test
318323
public clearPrimaryFetch() {
324+
// Reset the whole primary-fetch state. Previously only successToken was cleared, leaving a stale
325+
// fetchingToken/displayedToken pointing at an abandoned fetch (e.g. navigating to a cwa-disabled
326+
// page while a CWA fetch was in flight), which could keep the CWA view stuck. See #256.
319327
this.fetcherStore.primaryFetch.successToken = undefined
328+
this.fetcherStore.primaryFetch.fetchingToken = undefined
329+
this.fetcherStore.primaryFetch.displayedToken = undefined
320330
}
321331

322332
public get primaryFetchPath(): string | undefined {

src/runtime/resources/navigation-retention.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ function fullyLoad(page: ReturnType<typeof buildPage>): string {
116116
return token
117117
}
118118

119+
// Fully load an arbitrary (possibly multi-depth) view from an explicit tree + resource list.
120+
function fullyLoadView(routeIri: string, tree: NestedJsonStructure[], resourceList: CwaResource[]): string {
121+
const token = startPrimary(routeIri, `${routeIri}/manifest`)
122+
deliverManifest(token, tree)
123+
for (const resource of resourceList) {
124+
beginResource(token, resource['@id'])
125+
resolveResource(resource)
126+
}
127+
fetcherStore.finishFetch({ token })
128+
return token
129+
}
130+
119131
// ---- the invariant assertion ----------------------------------------------
120132

121133
function displayFetchStatus() {
@@ -217,6 +229,53 @@ describe('#256 navigation retention', () => {
217229
expect(fetcherStore.primaryFetch.displayedToken).toBe(fetcherStore.primaryFetch.successToken)
218230
})
219231

232+
test('clicking away (to home) while a nested child is still loading does NOT get stuck holding the half-loaded nested page', () => {
233+
const parent = buildPage('parent')
234+
const overview = buildPage('overview')
235+
const child2 = buildPage('child2')
236+
const home = buildPage('home')
237+
238+
const overviewNested: NestedJsonStructure[] = [parent.tree[0], overview.tree[0]]
239+
const child2Nested: NestedJsonStructure[] = [parent.tree[0], child2.tree[0]]
240+
241+
// 1. On the nested overview page, fully loaded (depth-0 parent + depth-1 overview).
242+
fullyLoadView(overview.routeIri, overviewNested, [...parent.resourcesList, ...overview.resourcesList])
243+
expect(resources.pageIriAtDepth(0).value).toBe(parent.pageIri)
244+
expect(resources.pageIriAtDepth(1).value).toBe(overview.pageIri)
245+
246+
// 2. Navigate to sibling nested page child2 — parent early-switches back in, but child2's depth-1
247+
// page NEVER loads and the fetch is NOT finished (the user clicks away first).
248+
const tokenChild2 = startPrimary(child2.routeIri, `${child2.routeIri}/manifest`)
249+
deliverManifest(tokenChild2, child2Nested)
250+
for (const resource of parent.resourcesList) {
251+
beginResource(tokenChild2, resource['@id'])
252+
resolveResource(resource)
253+
}
254+
// child2's own depth-1 resources are deliberately NOT loaded
255+
256+
// 3. Navigate to home before child2 finished.
257+
const tokenHome = startPrimary(home.routeIri, `${home.routeIri}/manifest`)
258+
259+
// The held view must not be a half-loaded nested page whose depth-1 child has no data —
260+
// that renders parent + a stuck child spinner. It should hold a fully-loaded page instead.
261+
const heldDepth1 = resources.pageIriAtDepth(1).value
262+
if (heldDepth1) {
263+
expect(
264+
resources.getResource(heldDepth1).value?.data,
265+
'held view depth-1 must have data (not a half-loaded nested page)',
266+
).toBeTruthy()
267+
}
268+
269+
// 4. Home finishes and must take over the display.
270+
deliverManifest(tokenHome, home.tree)
271+
for (const resource of home.resourcesList) {
272+
beginResource(tokenHome, resource['@id'])
273+
resolveResource(resource)
274+
}
275+
fetcherStore.finishFetch({ token: tokenHome })
276+
expect(resources.pageIriAtDepth(0).value).toBe(home.pageIri)
277+
})
278+
220279
test('navigating to a nested child never blanks the shared parent (depth 0)', () => {
221280
// Parent page P, displayed at depth 0.
222281
const p = buildPage('parent')

0 commit comments

Comments
 (0)