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:
parent
5aec101c83
commit
9c769523cf
5 changed files with 181 additions and 3 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
32
components/backlog/url-task-sync.tsx
Normal file
32
components/backlog/url-task-sync.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue