Skip to content

Commit 1d29f25

Browse files
committed
Move fetch depth tracking into the store so it survives SSR hydration (#261)
A dynamic component position on a data-page parent lost its resolved `component` moments after a server-side load of a nested child page, collapsing the parent data page to a placeholder (admin) or nothing. The depth-aware `path` request header requires a depth-0 resource to be requested with the depth-0 route path, so the API can resolve the position's `pageDataProperty` against the parent's page data. That lookup was backed by in-memory Maps on FetchStatusManager, populated only by `setManifestIrisByDepth`. On SSR the server built them and fetched correctly, but the client hydrated the store while constructing a fresh manager with empty maps — no manifest fetch runs client-side, so nothing rebuilt them. Client-side re-fetches then fell back to `primaryFetchPath` (the child route), whose static page has no page data, and the API returned the position with `component: null`. Only depth-0 resources broke: the fallback happens to be correct for depth 1. Move the tracking into the fetcher store as `iriDepths`/`depthPaths` (plain objects, so they serialise into the payload), derived by the `setManifestIrisByDepth` action, with `registerIriDepth`/`resetIriDepths` as store actions and the manager delegating. Single source of truth, so the desync cannot recur. `registerIriDepth` is preserved intact — deriving from `irisByDepth` alone would drop IRIs the manifest never contained, and it was collateral here anyway (nested IRIs only register when the parent's depth is known). Reproduction: nested-page-hydration.spec.ts drives the real stores through the SSR fetch, then simulates hydration with a new manager over the same store, against a stub modelling the API's page-data resolution. It asserts the outcome — the position still has its component — so it fails whether the fallout is a placeholder or nothing. Depth-tracking behaviour ported to the store's actions.spec.ts; the manager spec now pins delegation.
1 parent 689bc66 commit 1d29f25

9 files changed

Lines changed: 449 additions & 101 deletions

File tree

CLAUDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ const { resource } = useCwaResource(pageDataIri)
292292
- **`resource_iris` is `NestedJsonStructure[]`** — outer array = rendering depth (root first); each depth a `{ iri, children }` tree. Flattened per-depth into `irisByDepth: string[][]` for existing consumers; raw tree retained as `resourceTree` for future placeholder rendering
293293
- **Manifest for both public and admin** — UUID-based manifest collapses 4+ serial round trips into one parallel batch
294294
- **`irisByDepth` set before batch starts** — decouples "we know depth structure" from "batch complete"
295+
- **Depth tracking lives in the fetcher store, never in memory**`iriDepths` (IRI → depth) and `depthPaths` (depth → route path) are store state, derived by the `setManifestIrisByDepth` action (+ the `registerIriDepth` action for nested IRIs the manifest doesn't contain), reset per primary fetch via `resetIriDepths`. They drive the depth-aware `path` request header (`createRequestHeaders`), and **must survive the SSR→client payload**: the client builds a fresh `FetchStatusManager` and runs no manifest fetch, so in-memory Maps would start empty and every client-side re-fetch after a server-side load would fall back to `primaryFetchPath` — the *child* route. See "Bug: dynamic position loses its component after an SSR load of a nested page" below.
295296
- **Early-switch is depth-0 aware**`displayFetchStatus` checks `irisByDepth[0]` root page against `currentIds`; covers first visits (wait), return visits (switch immediately), sibling nav (parent renders, child loads progressively)
296297
- **Route concatenation recommended, not required** — rendering never depends on URL structure
297298
- **Hierarchy on AbstractPage, not Route** — settable before publication (before any route exists)
@@ -451,6 +452,36 @@ Reached **71.0%** statement coverage (2026-06-28). See `### Coverage progress` a
451452
452453
---
453454
455+
## Bug: dynamic position loses its `component` after an SSR load of a nested page ✅ Fixed ([#261](https://github.com/components-web-app/cwa-nuxt-module/issues/261))
456+
457+
**Reported from:** SRNTE (a nested static page whose parent is a data page using the dynamic page template). Fixed 2026-07-16.
458+
459+
### Symptom
460+
On a **server-side load/refresh** of the nested child page, the parent data page rendered correctly and then, moments later, its content vanished — leaving a component-position placeholder (admin) or nothing (logged out). Intermittent. Client-side *navigation* to the same page was unaffected.
461+
462+
### Root cause
463+
The `path` request header is depth-aware (`createRequestHeaders`, `api/fetcher/fetcher.ts`): a depth-0 resource must be requested with the **depth-0 route path**, so the API resolves the position's `pageDataProperty` against the **parent's** page data (`ComponentPositionNormalizer::normalizeForPageData``PageDataProvider::getPageData()`, which reads the `path` header).
464+
465+
That lookup was backed by `_iriToDepth` / `_depthPaths` — **in-memory Maps on `FetchStatusManager`**, populated only by `setManifestIrisByDepth` (a live manifest fetch, or the #257 route-cache prime). On SSR the *server* built them and fetched everything correctly; the Pinia store hydrated fine, but the **client constructed a fresh `FetchStatusManager` with empty maps and never rebuilt them** — no manifest fetch runs client-side. Any client-side re-fetch then fell back to `primaryFetchPath` = the **child** route, a static page with no page data → API returned `component: null` → the component disappeared.
466+
467+
The client re-fetch comes from `ResourceLoader`'s `onMounted` paths: `isOutdated` (SSR data >5s old — ISR/CDN-cached), `ssrPositionHasPartialData` (admin; fires for *every* position because `usesPageTemplate` is a depth-0 global check, true whenever the parent is a data page), `refetchPublishedSsrResourceToResolveDraft` (admin), and `ssrNoDataWithSilentError`.
468+
469+
**Not Mercure** (ruled out): Mercure only fires when a resource actually *changes*, and although `mercure.ts` does force-refetch `ComponentPosition` messages (it knows dynamic positions can't be resolved in a Mercure serialisation — the `'no_path'` branch — so it re-checks staleness itself), it saves with `isNew: true`, staging into the **temporary `new` store** awaiting merge rather than overwriting `current.byId`. It shares the same header path, so it was latent, but it is not the trigger.
470+
471+
Only **depth-0** resources broke: the fallback path is the current route, which for a depth-1 resource is coincidentally correct — hence the *parent* data page vanishing while the nested child stayed.
472+
473+
`registerIriDepth` was collateral: `fetcher.ts` only registers nested IRIs `if (parentDepth !== undefined)`, so with empty maps those registrations never happened either.
474+
475+
### Fix (landed)
476+
Moved the depth tracking into the **fetcher store** (`iriDepths` / `depthPaths` — plain objects, so they serialise into the payload), derived by the `setManifestIrisByDepth` action; `registerIriDepth` / `resetIriDepths` are now store actions. `FetchStatusManager` delegates. Single source of truth, no SSR desync possible. `registerIriDepth` is preserved intact — deriving from `irisByDepth` alone would have dropped IRIs the manifest never contained.
477+
478+
Reproduction + regression guard: `api/fetcher/nested-page-hydration.spec.ts` — drives the real stores through the SSR primary fetch, then simulates hydration by constructing a **new** `FetchStatusManager` over the same store, with a stubbed API that models the page-data resolution (resolves only for the `/conference` parent path). Asserts the **outcome** (the position still has its `component`), so it fails whether the fallout is a placeholder or nothing. Store behaviour moved to `storage/stores/fetcher/actions.spec.ts` ("depth tracking" describe); the manager spec's equivalent block now pins delegation.
479+
480+
### Known related bug (NOT fixed here) — [#260](https://github.com/components-web-app/cwa-nuxt-module/issues/260)
481+
`ComponentPosition.vue` gates its admin-only placeholder on `v-else-if="$cwa.auth.isAdmin"` — a **nested** access to a getter returning `computed()`, which Vue does not auto-unwrap, so it's always truthy and logged-out users see the admin placeholder. (Same trap as `upload.bind` in #252; `.value` is needed.) The nameless placeholder is the tell: `pageDataProperty` is `ComponentPosition:read:role_admin`, so non-admins never receive it. A third instance of the same trap: `ResourceLoader.vue` uses bare `hasSilentError` (a `ComputedRef`) instead of `.value` inside `ssrNoDataWithSilentError`, collapsing it to `ssr && data === undefined`.
482+
483+
---
484+
454485
## Bug: empty component-group `location` when adding a component to an unpublished draft ✅ Fixed
455486
456487
**Reported from:** SRNTE (adding a component inside a static page nested in a data page). Fixed 2026-07-10.

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

Lines changed: 27 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ describe('FetchStatusManager -> startFetch (Start a new fetch chain)', () => {
226226
primaryFetch: {},
227227
setDisplayedToken: vi.fn(),
228228
routeCache: new Map(),
229+
resetIriDepths: vi.fn(),
229230
}
230231
const startFetchEvent: StartFetchEvent = {
231232
path: '/fetch-path',
@@ -770,112 +771,62 @@ describe('FetchStatusManager -> primaryFetchPath', () => {
770771
})
771772
})
772773

773-
describe('FetchStatusManager -> depth tracking (setManifestIrisByDepth / getDepthForIri / getPathForDepth / registerIriDepth)', () => {
774+
describe('FetchStatusManager -> depth tracking (delegates to the fetcher store)', () => {
775+
// The depth lookups themselves live in the fetcher store so they survive the SSR->client payload
776+
// (see `storage/stores/fetcher/actions.spec.ts` for their behaviour, and
777+
// `nested-page-hydration.spec.ts` for why). The manager's remaining job is to delegate.
774778
let fetchStatusManager: FetchStatusManager
775779

776-
// Build a depth tree node from a flat IRI list (first = root, rest = direct children); it flattens
777-
// back to the same flat list the depth-tracking logic previously received directly.
778780
const depthNode = (iris: string[]) => ({ iri: iris[0], children: iris.slice(1).map(iri => ({ iri, children: [] })) })
779781

780782
beforeEach(() => {
781783
fetchStatusManager = createFetchStatusManager()
782-
fetchStatusManager._fetcherStore = { setManifestIrisByDepth: vi.fn() }
784+
fetchStatusManager._fetcherStore = {
785+
iriDepths: { '/_/pages/parent': 0 },
786+
depthPaths: { 0: '/topic-1' },
787+
setManifestIrisByDepth: vi.fn(),
788+
registerIriDepth: vi.fn(),
789+
resetIriDepths: vi.fn(),
790+
startFetch: vi.fn(() => ({ continue: true, token: 'token', resources: [] })),
791+
primaryFetch: {},
792+
setDisplayedToken: vi.fn(),
793+
routeCache: new Map(),
794+
}
783795
})
784796

785797
afterEach(() => {
786798
vi.clearAllMocks()
787799
})
788800

789-
test('getDepthForIri returns undefined before any manifest is set', () => {
790-
expect(fetchStatusManager.getDepthForIri('/_/routes//topic-1')).toBeUndefined()
791-
})
792-
793-
test('getPathForDepth returns undefined before any manifest is set', () => {
794-
expect(fetchStatusManager.getPathForDepth(0)).toBeUndefined()
795-
})
796-
797-
test('setManifestIrisByDepth maps every IRI in each depth group to its depth index', () => {
798-
fetchStatusManager.setManifestIrisByDepth({
799-
token: 'token',
800-
resourceIris: [
801-
depthNode(['/_/routes//topic-1', '/_/pages/parent-template', '/_/component_positions/parent-cp']),
802-
depthNode(['/_/routes//topic-1/chapter-one', '/_/pages/child-template', '/_/component_positions/child-cp']),
803-
],
804-
})
805-
expect(fetchStatusManager.getDepthForIri('/_/routes//topic-1')).toBe(0)
806-
expect(fetchStatusManager.getDepthForIri('/_/pages/parent-template')).toBe(0)
807-
expect(fetchStatusManager.getDepthForIri('/_/component_positions/parent-cp')).toBe(0)
808-
expect(fetchStatusManager.getDepthForIri('/_/routes//topic-1/chapter-one')).toBe(1)
809-
expect(fetchStatusManager.getDepthForIri('/_/pages/child-template')).toBe(1)
810-
expect(fetchStatusManager.getDepthForIri('/_/component_positions/child-cp')).toBe(1)
801+
test('getDepthForIri reads the depth from the store', () => {
802+
expect(fetchStatusManager.getDepthForIri('/_/pages/parent')).toBe(0)
811803
expect(fetchStatusManager.getDepthForIri('/unknown')).toBeUndefined()
812804
})
813805

814-
test('getPathForDepth returns the path derived from the ROUTE IRI in each depth group', () => {
815-
fetchStatusManager.setManifestIrisByDepth({
816-
token: 'token',
817-
resourceIris: [
818-
depthNode(['/_/pages/parent-template', '/_/routes//topic-1']),
819-
depthNode(['/_/routes//topic-1/chapter-one', '/_/pages/child-template']),
820-
],
821-
})
806+
test('getPathForDepth reads the path from the store', () => {
822807
expect(fetchStatusManager.getPathForDepth(0)).toBe('/topic-1')
823-
expect(fetchStatusManager.getPathForDepth(1)).toBe('/topic-1/chapter-one')
824808
expect(fetchStatusManager.getPathForDepth(2)).toBeUndefined()
825809
})
826810

827-
test('setManifestIrisByDepth replaces previous depth tracking data', () => {
828-
fetchStatusManager.setManifestIrisByDepth({
829-
token: 'token',
830-
resourceIris: [depthNode(['/_/routes//old', '/_/pages/old-page'])],
831-
})
832-
fetchStatusManager.setManifestIrisByDepth({
833-
token: 'token',
834-
resourceIris: [depthNode(['/_/routes//new', '/_/pages/new-page'])],
835-
})
836-
expect(fetchStatusManager.getDepthForIri('/_/pages/old-page')).toBeUndefined()
837-
expect(fetchStatusManager.getDepthForIri('/_/pages/new-page')).toBe(0)
838-
expect(fetchStatusManager.getPathForDepth(0)).toBe('/new')
811+
test('setManifestIrisByDepth passes the event to the store, which derives the depths', () => {
812+
const event = { token: 'token', resourceIris: [depthNode(['/_/routes//topic-1', '/_/pages/parent'])] }
813+
fetchStatusManager.setManifestIrisByDepth(event)
814+
expect(fetchStatusManager._fetcherStore.setManifestIrisByDepth).toHaveBeenCalledWith(event)
839815
})
840816

841-
test('registerIriDepth adds an IRI to the depth map', () => {
817+
test('registerIriDepth passes the IRI and depth to the store', () => {
842818
fetchStatusManager.registerIriDepth('/component/some-uuid', 0)
843-
expect(fetchStatusManager.getDepthForIri('/component/some-uuid')).toBe(0)
819+
expect(fetchStatusManager._fetcherStore.registerIriDepth).toHaveBeenCalledWith({ iri: '/component/some-uuid', depth: 0 })
844820
})
845821

846822
test('startFetch with isPrimary clears depth tracking', () => {
847-
fetchStatusManager._fetcherStore = {
848-
setManifestIrisByDepth: vi.fn(),
849-
startFetch: vi.fn(() => ({ continue: true, token: 'token', resources: [] })),
850-
primaryFetch: {},
851-
setDisplayedToken: vi.fn(),
852-
routeCache: new Map(),
853-
}
854-
fetchStatusManager.setManifestIrisByDepth({
855-
token: 'token',
856-
resourceIris: [depthNode(['/_/routes//topic-1', '/_/pages/parent'])],
857-
})
858-
expect(fetchStatusManager.getDepthForIri('/_/pages/parent')).toBe(0)
859-
860823
fetchStatusManager.startFetch({ path: '/new', isPrimary: true })
861-
862-
expect(fetchStatusManager.getDepthForIri('/_/pages/parent')).toBeUndefined()
863-
expect(fetchStatusManager.getPathForDepth(0)).toBeUndefined()
824+
expect(fetchStatusManager._fetcherStore.resetIriDepths).toHaveBeenCalled()
864825
})
865826

866827
test('startFetch without isPrimary preserves depth tracking', () => {
867-
fetchStatusManager._fetcherStore = {
868-
setManifestIrisByDepth: vi.fn(),
869-
startFetch: vi.fn(() => ({ continue: true, token: 'token', resources: [] })),
870-
}
871-
fetchStatusManager.setManifestIrisByDepth({
872-
token: 'token',
873-
resourceIris: [depthNode(['/_/routes//topic-1', '/_/pages/parent'])],
874-
})
875828
fetchStatusManager.startFetch({ path: '/new', isPrimary: false })
876-
877-
expect(fetchStatusManager.getDepthForIri('/_/pages/parent')).toBe(0)
878-
expect(fetchStatusManager.getPathForDepth(0)).toBe('/topic-1')
829+
expect(fetchStatusManager._fetcherStore.resetIriDepths).not.toHaveBeenCalled()
879830
})
880831
})
881832

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

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@ import { FinishFetchManifestType } from '../../storage/stores/fetcher/actions'
1616
import type { CwaResourcesStoreInterface, ResourcesStore } from '../../storage/stores/resources/resources-store'
1717
import type { CwaResourceError } from '../../errors/cwa-resource-error'
1818
import { createCwaResourceError } from '../../errors/cwa-resource-error'
19-
import { CwaResourceTypes, getResourceTypeFromIri, isCwaResource, ResourceTypeFromIri } from '../../resources/resource-utils'
19+
import { CwaResourceTypes, getResourceTypeFromIri, isCwaResource } from '../../resources/resource-utils'
2020
import type { CwaResource } from '../../resources/resource-utils'
2121
import { CwaResourceApiStatuses } from '../../storage/stores/resources/state'
2222
import type { CwaFetchRequestHeaders, CwaFetchResponse } from './fetcher'
2323
import type { FetchAbortReason, FetchStatus, RouteCacheEntry } from '#cwa/storage/stores/fetcher/state'
24-
import { flattenManifestNode } from '#cwa/storage/stores/fetcher/manifest-utils'
2524
import { clearError, useError } from '#imports'
2625

2726
export interface FinishFetchResourceEvent {
@@ -55,8 +54,6 @@ export default class FetchStatusManager {
5554
private readonly _fetcherStore: CwaFetcherStoreInterface
5655
private readonly _resourcesStore: CwaResourcesStoreInterface
5756

58-
private _iriToDepth = new Map<string, number>()
59-
private _depthPaths = new Map<number, string>()
6057
// Max routes retained in the instant-revisit cache (#257). Overridable via the `cwa` nuxt config.
6158
private readonly routeCacheLimit: number
6259

@@ -107,8 +104,7 @@ export default class FetchStatusManager {
107104
// capture the page currently being loaded before it is superseded by this new primary fetch
108105
const outgoingFetchingToken = event.isPrimary ? this.fetcherStore.primaryFetch.fetchingToken : undefined
109106
if (event.isPrimary) {
110-
this._iriToDepth = new Map()
111-
this._depthPaths = new Map()
107+
this.fetcherStore.resetIriDepths()
112108
}
113109
const startFetchStatus = this.fetcherStore.startFetch({ ...event, isCurrentSuccessResourcesResolved: this.isCurrentSuccessResourcesResolved })
114110
if (event.isPrimary) {
@@ -380,32 +376,20 @@ export default class FetchStatusManager {
380376
}
381377

382378
public setManifestIrisByDepth(event: SetManifestIrisByDepthEvent): void {
383-
this._iriToDepth = new Map()
384-
this._depthPaths = new Map()
385-
const prefix = ResourceTypeFromIri.getPathPrefix() || ''
386-
const routePathPrefix = `${prefix}/_/routes/`
387-
const irisByDepth = event.resourceIris.map(flattenManifestNode)
388-
for (let depth = 0; depth < irisByDepth.length; depth++) {
389-
for (const iri of irisByDepth[depth]!) {
390-
this._iriToDepth.set(iri, depth)
391-
if (!this._depthPaths.has(depth) && iri.startsWith(routePathPrefix)) {
392-
this._depthPaths.set(depth, iri.substring(routePathPrefix.length))
393-
}
394-
}
395-
}
379+
// the store derives the depth lookups from the manifest as it stores it
396380
this.fetcherStore.setManifestIrisByDepth(event)
397381
}
398382

399383
public getDepthForIri(iri: string): number | undefined {
400-
return this._iriToDepth.get(iri)
384+
return this.fetcherStore.iriDepths[iri]
401385
}
402386

403387
public getPathForDepth(depth: number): string | undefined {
404-
return this._depthPaths.get(depth)
388+
return this.fetcherStore.depthPaths[depth]
405389
}
406390

407391
public registerIriDepth(iri: string, depth: number): void {
408-
this._iriToDepth.set(iri, depth)
392+
this.fetcherStore.registerIriDepth({ iri, depth })
409393
}
410394

411395
public finishManifestFetch(event: ManifestSuccessFetchEvent | ManifestErrorFetchEvent): void {

src/runtime/api/fetcher/fetcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export default class Fetcher {
188188
}
189189
else if (resource && shallowFetch !== true) {
190190
// Wait for the manifest batch to complete before traversing associated resources.
191-
// This ensures _iriToDepth and _depthPaths are populated so createRequestHeaders
191+
// This ensures the store's depth tracking is populated so createRequestHeaders
192192
// sends the correct depth-aware path for every follow-up request. fetchAssociatedResources
193193
// still runs as a safety pass for anything the manifest did not include.
194194
if (manifestPromise) {

0 commit comments

Comments
 (0)