Skip to content

Commit 5dbe761

Browse files
authored
fix(frontend): wait for redirect route replacement (#771)
* fix(frontend): wait for redirect route replacement Return an error when resource redirects exceed the max depth instead of leaking a redirect resource, avoid carrying source versions onto redirect targets, and keep redirected resource data cached under both source and target query keys. * fix(frontend): render redirected document ids immediately
1 parent 2dc8d70 commit 5dbe761

5 files changed

Lines changed: 90 additions & 18 deletions

File tree

frontend/packages/shared/src/models/__tests__/entity.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {act} from 'react-dom/test-utils'
77
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
88
import {UniversalAppContext} from '../../routing'
99
import {hmId} from '../../utils/entity-id-url'
10+
import {queryKeys} from '../query-keys'
1011
import {useResource} from '../entity'
1112

1213
const docA = hmId('uid1', {path: ['old-name']})
@@ -105,6 +106,7 @@ function renderUseResource(params: {
105106
container.remove()
106107
},
107108
client,
109+
queryClient,
108110
}
109111
}
110112

@@ -131,6 +133,20 @@ describe('useResource redirects', () => {
131133
vi.clearAllMocks()
132134
})
133135

136+
it('caches resolved redirected resources under source and target query keys', async () => {
137+
const onRedirectOrDeleted = vi.fn()
138+
const rendered = renderUseResource({id: docA, onRedirectOrDeleted})
139+
140+
await waitForCondition(() => onRedirectOrDeleted.mock.calls.length === 1)
141+
142+
const oldKey = [queryKeys.ENTITY, docA.id, docA.version || undefined, docA.latest || false]
143+
const newKey = [queryKeys.ENTITY, docB.id, docB.version || undefined, docB.latest || false]
144+
expect(rendered.queryClient.getQueryData(oldKey)).toMatchObject({type: 'document', id: docB})
145+
expect(rendered.queryClient.getQueryData(newKey)).toMatchObject({type: 'document', id: docB})
146+
147+
rendered.cleanup()
148+
})
149+
134150
it('dispatches the same redirect again after navigating away and back', async () => {
135151
const onRedirectOrDeleted = vi.fn()
136152
const rendered = renderUseResource({id: docA, onRedirectOrDeleted})

frontend/packages/shared/src/models/__tests__/queries.test.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,7 @@ describe('queryResource', () => {
142142
expect(result).toMatchObject({type: 'tombstone', id: docB})
143143
})
144144

145-
test('stops following redirects after max depth (5)', async () => {
146-
// Create a chain of 6 redirects — should stop after 5
145+
test('returns an error after redirect max depth instead of leaking a redirect resource', async () => {
147146
const ids = Array.from({length: 7}, (_, i) => hmId('uid1', {path: [`doc-${i}`]}))
148147
const client = createMockClient((_key, input) => {
149148
const idx = ids.findIndex((id) => id.id === input.id)
@@ -152,15 +151,32 @@ describe('queryResource', () => {
152151
}
153152
return documentResponse(ids[idx]!)
154153
})
155-
const query = queryResource(client, ids[0]!)
156-
const result = await query.queryFn!()
157-
// After 5 redirects, we're at ids[5] which still redirects to ids[6],
158-
// but we've hit the limit. The result is the redirect response itself.
159-
expect(result).toMatchObject({type: 'redirect'})
160-
// 1 initial + 5 follows = 6 total requests
154+
155+
const result = await queryResource(client, ids[0]!).queryFn!()
156+
157+
expect(result).toMatchObject({
158+
type: 'error',
159+
id: ids[0],
160+
message: 'Too many redirects while resolving resource',
161+
})
161162
expect(client.request).toHaveBeenCalledTimes(6)
162163
})
163164

165+
test('does not copy source version onto redirect target', async () => {
166+
const versionedDocA = hmId('uid1', {path: ['old-name'], version: 'v123'})
167+
const client = createMockClient((_key, input) => {
168+
if (input.id === versionedDocA.id) return redirectResponse(versionedDocA, docB)
169+
if (input.id === docB.id) return documentResponse(docB)
170+
throw new Error(`Unexpected request: ${input.id}`)
171+
})
172+
173+
const result = await queryResource(client, versionedDocA).queryFn!()
174+
175+
expect(result).toMatchObject({type: 'document', id: docB})
176+
expect(result?.id.version).toBeNull()
177+
expect(client.request).toHaveBeenNthCalledWith(2, 'Resource', docB, {signal: undefined})
178+
})
179+
164180
test('returns null for null id', async () => {
165181
const client = createMockClient(() => {
166182
throw new Error('Should not be called')

frontend/packages/shared/src/models/queries.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export function queryResource(client: UniversalClient, id: UnpackedHypermediaId
9090
}
9191
res = await client.request('Resource', nextTarget, {signal})
9292
}
93+
if (res?.type === 'redirect') {
94+
return {type: 'error', id, message: 'Too many redirects while resolving resource'}
95+
}
9396
const parsed = HMResourceSchema.parse(res)
9497
if (republishSourceId && (parsed.type === 'document' || parsed.type === 'comment') && !id.hostname) {
9598
return {

frontend/packages/ui/src/__tests__/resource-page-common.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getCommentReplyPanelRoute,
55
hasUnpublishedDraftForResourceState,
66
shouldSuppressMainCommentEditor,
7+
getRenderedDocumentId,
78
shouldUseDraftForRenderedDocument,
89
} from '../resource-page-common'
910

@@ -189,3 +190,32 @@ describe('hasUnpublishedDraftForResourceState', () => {
189190
).toBe(false)
190191
})
191192
})
193+
194+
describe('getRenderedDocumentId', () => {
195+
const oldId = hmId('uid1', {path: ['old-name']})
196+
const newId = hmId('uid1', {path: ['new-name']})
197+
const redirectedDocument = {
198+
type: 'document' as const,
199+
id: newId,
200+
document: {
201+
version: 'v1',
202+
account: 'uid1',
203+
path: '/new-name',
204+
authors: [],
205+
content: [],
206+
metadata: {},
207+
genesis: 'genesis1',
208+
visibility: 'PUBLIC' as const,
209+
createTime: '',
210+
updateTime: '',
211+
},
212+
}
213+
214+
it('uses the resolved document id when a redirect returned a different document', () => {
215+
expect(getRenderedDocumentId(oldId, redirectedDocument)).toEqual(newId)
216+
})
217+
218+
it('keeps the route document id when the resource is not a document', () => {
219+
expect(getRenderedDocumentId(oldId, {type: 'not-found', id: oldId})).toEqual(oldId)
220+
})
221+
})

frontend/packages/ui/src/resource-page-common.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
HMComment,
55
HMDocument,
66
HMExistingDraft,
7+
HMResource,
78
UnpackedHypermediaId,
89
} from '@seed-hypermedia/client/hm-types'
910
import {
@@ -134,6 +135,11 @@ type CommentDraftTarget = {
134135
quotingRangeEnd?: number
135136
}
136137

138+
/** Returns the document ID that should back the rendered document content. */
139+
export function getRenderedDocumentId(routeDocId: UnpackedHypermediaId, resourceData: HMResource | null | undefined) {
140+
return resourceData?.type === 'document' ? resourceData.id : routeDocId
141+
}
142+
137143
function extractQuotingRange(blockRange?: BlockRange | null): {start: number; end: number} | undefined {
138144
if (!blockRange) return undefined
139145
if (
@@ -704,7 +710,8 @@ export function ResourcePage({
704710
// exists, fabricate a placeholder so DocumentBody / the document machine
705711
// can transition to "loaded" → editing for the new-document case.
706712
let document: HMDocument
707-
if (resourceFetchId && resource.data?.type === 'document' && resource.data.id.id === resourceFetchId.id) {
713+
const renderedDocId = getRenderedDocumentId(docId, resource.data)
714+
if (resourceFetchId && resource.data?.type === 'document') {
708715
document = resource.data.document
709716
} else if (lastGoodDocumentRef.current) {
710717
// Transient refetch failure / not-found / discovery flap — keep showing the last
@@ -732,9 +739,9 @@ export function ResourcePage({
732739
)
733740
}
734741

735-
const shouldUseDraft = shouldUseDraftForRenderedDocument({docId, existingDraft, isLatest})
742+
const shouldUseDraft = shouldUseDraftForRenderedDocument({docId: renderedDocId, existingDraft, isLatest})
736743
const effectiveCanEdit =
737-
(canEdit || (resourceFetchId === null && !!existingDraft)) && (!docId.version || isLatest || shouldUseDraft)
744+
(canEdit || (resourceFetchId === null && !!existingDraft)) && (!renderedDocId.version || isLatest || shouldUseDraft)
738745
const effectiveExistingDraft = shouldUseDraft ? existingDraft : false
739746
const effectiveExistingDraftVisibility = shouldUseDraft ? existingDraftVisibility : undefined
740747
const effectiveExistingDraftContent = shouldUseDraft ? existingDraftContent : undefined
@@ -744,7 +751,7 @@ export function ResourcePage({
744751
const effectiveExistingDraftDeps = shouldUseDraft ? existingDraftDeps : undefined
745752
const draftVersionEntry = existingDraft
746753
? {
747-
docId,
754+
docId: renderedDocId,
748755
draftId: existingDraft.id,
749756
deps: existingDraftDeps,
750757
metadata: existingDraft.metadata,
@@ -758,15 +765,15 @@ export function ResourcePage({
758765
// path changes (e.g. after first publish from `-${draftId}` → real slug).
759766
// Without this, useActorRef keeps the original actor instance and its
760767
// context still references the old documentId/editPath.
761-
key={`${docId.id}@${docId.version ?? 'latest'}`}
768+
key={`${renderedDocId.id}@${renderedDocId.version ?? 'latest'}`}
762769
input={{
763-
documentId: docId,
770+
documentId: renderedDocId,
764771
canEdit: effectiveCanEdit,
765772
isLatest,
766773
deps: effectiveExistingDraftDeps,
767774
reservedDraftId: reservedDraftId ?? undefined,
768-
editUid: docId.uid,
769-
editPath: docId.path ?? undefined,
775+
editUid: renderedDocId.uid,
776+
editPath: renderedDocId.path ?? undefined,
770777
signingAccountId,
771778
publishAccountUid,
772779
}}
@@ -775,15 +782,15 @@ export function ResourcePage({
775782
>
776783
<PageWrapper
777784
siteHomeId={siteHomeId}
778-
docId={docId}
785+
docId={renderedDocId}
779786
headerData={headerData}
780787
document={document}
781788
rightActions={rightActions}
782789
editNavPane={editNavPane}
783790
transientResourceError={transientResourceError}
784791
>
785792
<DocumentBody
786-
docId={docId}
793+
docId={renderedDocId}
787794
document={document}
788795
activeView={getActiveView(route.key)}
789796
isLatest={isLatest}

0 commit comments

Comments
 (0)