feat(PBI-74): race-safe loaders + restore-hints + URL-prioriteit (Story 4)

- T-856: activeRequestId-guard zat al in store.ts uit Story 1; bevestigd door
  de race-safety test (in-flight ensurePbiLoaded mag niet overschrijven).
- T-857: restore-hint flow toegevoegd in setActiveProduct/setActivePbi/
  setActiveStory. Async chain: await ensureXxxLoaded → guard check →
  readHints → valideer hint via entities.byId → setActiveYyy(hint).
  Geen setTimeout-trick — chain is alleen await-based.
- T-858: writeProductHint/writePbiHint/writeStoryHint/writeTaskHint
  aangeroepen direct na set(...) zodat de hint-persistentie altijd
  consistent is met de in-store selectie.
- T-859: nieuwe components/backlog/url-task-sync.tsx — leest
  ?editTask=<id> uit useSearchParams, schrijft de hint en roept
  setActiveTask aan zodat de URL wint boven een eerder gepersisteerde
  task-hint. Gemount in beide product-pages (desktop + mobile) binnen
  BacklogHydrationWrapper.
- T-860: 6 nieuwe vitest-cases — 4 voor hint-persist per setter, 2 voor de
  restore-flow chain (hint die niet in entities zit wordt genegeerd; hint
  die wel in entities zit wordt toegepast). Bestaande race-safety test
  blijft groen.

Verify: lint+typecheck clean, 642/642 tests groen.

Refs: PBI-74, ST-1321, T-856..T-860

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 01:17:22 +02:00
parent 5aec101c83
commit 9c769523cf
5 changed files with 181 additions and 3 deletions

View file

@ -629,6 +629,98 @@ describe('resyncActiveScopes', () => {
// Optimistic mutations
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// Restore-hint integratie (Story 4)
// ─────────────────────────────────────────────────────────────────────────
describe('restore-hint flow — setters persisteren hints', () => {
it('setActiveProduct schrijft lastActiveProductId', () => {
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
const raw = localStorage.getItem('product-workspace-hints')
expect(raw).not.toBeNull()
const hints = JSON.parse(raw!)
expect(hints.lastActiveProductId).toBe('prod-1')
})
it('setActivePbi schrijft lastActivePbiId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActivePbi('pbi-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
})
it('setActiveStory schrijft lastActiveStoryId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveStory('story-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a')
})
it('setActiveTask schrijft lastActiveTaskId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveTask('task-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a')
})
})
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
it('hint die NIET in entities zit wordt genegeerd', async () => {
// Schrijf een hint voor een PBI die niet bestaat
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } },
}),
)
// Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen
// ghost-pbi in entities.
mockFetchSequence([
{ product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
// Wacht tot async restore-flow afgewikkeld is.
await new Promise((r) => setTimeout(r, 20))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull()
})
it('hint die wel in entities zit wordt toegepast', async () => {
const validPbi = makePbi({ id: 'pbi-known' })
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } },
}),
)
mockFetchSequence([
// ensureProductLoaded levert pbi-known
{
product: { id: 'prod-1', name: 'P1' },
pbis: [validPbi],
storiesByPbi: {},
tasksByStory: {},
},
// ensurePbiLoaded triggered door setActivePbi(hint) — geen stories
[],
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
await new Promise((r) => setTimeout(r, 30))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known')
})
})
describe('optimistic mutations', () => {
it('rollback herstelt vorige pbi-order', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(

View file

@ -11,6 +11,7 @@ import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
import { TaskPanel } from '@/components/backlog/task-panel'
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
import { UrlTaskSync } from '@/components/backlog/url-task-sync'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
@ -155,6 +156,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
tasksByStory,
}}
>
<UrlTaskSync />
<BacklogSplitPane
cookieKey={`backlog-${id}`}
defaultSplit={[20, 45, 35]}

View file

@ -15,6 +15,7 @@ import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
import { TaskPanel } from '@/components/backlog/task-panel'
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
import { UrlTaskSync } from '@/components/backlog/url-task-sync'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
@ -95,6 +96,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
tasksByStory,
}}
>
<UrlTaskSync />
<BacklogSplitPane
cookieKey={`backlog-${id}-mobile`}
defaultSplit={[20, 45, 35]}

View file

@ -0,0 +1,32 @@
'use client'
// PBI-74 / T-859: URL-prioriteit boven restore-hint.
//
// Als de route `?editTask=<id>` draagt, wint dat boven de localStorage-hint
// die de restore-flow normaal zou toepassen. We schrijven de URL-id direct
// naar de task-hint en roepen setActiveTask aan; de restore-flow leest de
// task-hint pas na drie ensure*Loaded-awaits, dus onze schrijfactie wint
// in de praktijk altijd.
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { writeTaskHint } from '@/stores/product-workspace/restore'
export function UrlTaskSync() {
const searchParams = useSearchParams()
const editTask = searchParams.get('editTask')
useEffect(() => {
if (!editTask) return
const productId = useProductWorkspaceStore.getState().context.activeProduct?.id
if (productId) {
// Hint overschrijven zodat restore-flow's setActiveTask op deze id eindigt
// (mocht hij na onze directe call komen).
writeTaskHint(productId, editTask)
}
useProductWorkspaceStore.getState().setActiveTask(editTask)
}, [editTask])
return null
}

View file

@ -15,6 +15,13 @@ import {
type ResyncReason,
type TaskDetail,
} from './types'
import {
readHints,
writePbiHint,
writeProductHint,
writeStoryHint,
writeTaskHint,
} from './restore'
interface ContextSlice {
activeProduct: ActiveProduct | null
@ -232,13 +239,28 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
}
})
// T-858: persisteer product-hint zodat een volgende cold reload deze
// selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded.
writeProductHint(product?.id ?? null)
if (product) {
void get().ensureProductLoaded(product.id, requestId)
const productId = product.id
void (async () => {
await get().ensureProductLoaded(productId, requestId)
if (get().loading.activeRequestId !== requestId) return
// T-857: cascade-restore — alleen toepassen als hint-id nog in
// entities zit (entiteit accessible).
const hint = readHints().perProduct[productId]?.lastActivePbiId
if (hint && get().entities.pbisById[hint]) {
get().setActivePbi(hint)
}
})()
}
},
setActivePbi(pbiId) {
const requestId = newRequestId()
const productId = get().context.activeProduct?.id ?? null
set((s) => {
s.context.activePbiId = pbiId
@ -247,13 +269,27 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
s.loading.activeRequestId = requestId
})
// T-858: persisteer pbi-hint per product. null wist child-hints (zie
// restore.ts writePbiHint).
if (productId) writePbiHint(productId, pbiId)
if (pbiId) {
void get().ensurePbiLoaded(pbiId, requestId)
void (async () => {
await get().ensurePbiLoaded(pbiId, requestId)
if (get().loading.activeRequestId !== requestId) return
if (!productId) return
// T-857: cascade-restore
const hint = readHints().perProduct[productId]?.lastActiveStoryId
if (hint && get().entities.storiesById[hint]) {
get().setActiveStory(hint)
}
})()
}
},
setActiveStory(storyId) {
const requestId = newRequestId()
const productId = get().context.activeProduct?.id ?? null
set((s) => {
s.context.activeStoryId = storyId
@ -261,16 +297,30 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
s.loading.activeRequestId = requestId
})
if (productId) writeStoryHint(productId, storyId)
if (storyId) {
void get().ensureStoryLoaded(storyId, requestId)
void (async () => {
await get().ensureStoryLoaded(storyId, requestId)
if (get().loading.activeRequestId !== requestId) return
if (!productId) return
const hint = readHints().perProduct[productId]?.lastActiveTaskId
if (hint && get().entities.tasksById[hint]) {
get().setActiveTask(hint)
}
})()
}
},
setActiveTask(taskId) {
const productId = get().context.activeProduct?.id ?? null
set((s) => {
s.context.activeTaskId = taskId
})
if (productId) writeTaskHint(productId, taskId)
if (taskId) {
void get().ensureTaskLoaded(taskId)
}