From 0e49755e7947d0457719d1efbe855b2152724840 Mon Sep 17 00:00:00 2001 From: Ion Date: Thu, 7 May 2026 13:10:33 +0200 Subject: [PATCH 1/2] Fix comment permalink version handling --- frontend/apps/web/app/loaders.ts | 5 ++++- frontend/apps/web/app/routes/$.tsx | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/apps/web/app/loaders.ts b/frontend/apps/web/app/loaders.ts index 1e1a4aafb..c8004a4ff 100644 --- a/frontend/apps/web/app/loaders.ts +++ b/frontend/apps/web/app/loaders.ts @@ -586,9 +586,12 @@ export async function loadSiteResource = Recor let comment = resourceContent.comment let commentAuthorTitle: string | undefined const openCommentId = (extraData as any)?.openComment as string | undefined + const commentVersion = (extraData as any)?.commentVersion as string | undefined if (!comment && openCommentId) { try { - comment = (await getComment(openCommentId)) ?? undefined + // Comment permalink routes use ?v for the comment version CID. The + // comments API accepts either the stable comment id or a version CID. + comment = (await getComment(commentVersion || openCommentId)) ?? undefined if (comment?.author) { try { const authorResource = await resolveResource(hmId(comment.author)) diff --git a/frontend/apps/web/app/routes/$.tsx b/frontend/apps/web/app/routes/$.tsx index 522e67aeb..eaf6e2770 100644 --- a/frontend/apps/web/app/routes/$.tsx +++ b/frontend/apps/web/app/routes/$.tsx @@ -48,6 +48,7 @@ type ExtendedSitePayload = SiteDocumentPayload & { viewTerm?: ViewRouteKey | null panelParam?: string | null // Supports extended format like "comments/BLOCKID" or "comments/COMMENT_ID" openComment?: string | null + commentVersion?: string | null accountUid?: string | null inspectTab?: InspectTab | null } @@ -294,6 +295,10 @@ export const loader = async ({params, request}: {params: Params; request: Reques let documentId let isInspect = false let viewTerm: ViewRouteKey | null = null + // On comment permalink routes, ?v refers to the comment version CID, not + // the target document version. Keep it off the document lookup or the + // target document resolves as not found. + let isCommentPermalink = false // Merge activity filter slug from path into panelParam for createDocumentNavRoute let effectivePanelParam = panelParam let openComment: string | null = null @@ -313,12 +318,13 @@ export const loader = async ({params, request}: {params: Params; request: Reques } if (extracted.commentId) { openComment = extracted.commentId + isCommentPermalink = true } accountUid = extracted.accountUid || null documentId = hmId(docUid, { path: extracted.path, - version, - latest, + version: isCommentPermalink ? null : version, + latest: isCommentPermalink ? true : latest, }) } else { // Site document (regular path) or inspector document (/inspect/path...) @@ -332,12 +338,13 @@ export const loader = async ({params, request}: {params: Params; request: Reques } if (extracted.commentId) { openComment = extracted.commentId + isCommentPermalink = true } accountUid = extracted.accountUid || null documentId = hmId(registeredAccountUid, { path: extracted.path, - version, - latest, + version: isCommentPermalink ? null : version, + latest: isCommentPermalink ? true : latest, }) } @@ -347,6 +354,7 @@ export const loader = async ({params, request}: {params: Params; request: Reques viewTerm, panelParam: effectivePanelParam, openComment, + commentVersion: isCommentPermalink ? version : null, accountUid, isInspect, inspectTab: isInspect && inspectTab ? (inspectTab as ExtendedSitePayload['inspectTab']) : null, From ae883eb1f7375da78c6496baa1c40a0453e0b921 Mon Sep 17 00:00:00 2001 From: Ion Date: Thu, 7 May 2026 14:17:17 +0200 Subject: [PATCH 2/2] Add comment permalink route regression test --- .../web/app/comment-permalink-route.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 frontend/apps/web/app/comment-permalink-route.test.ts diff --git a/frontend/apps/web/app/comment-permalink-route.test.ts b/frontend/apps/web/app/comment-permalink-route.test.ts new file mode 100644 index 000000000..b4792ea08 --- /dev/null +++ b/frontend/apps/web/app/comment-permalink-route.test.ts @@ -0,0 +1,113 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + loadSiteResource: vi.fn(), +})) + +vi.mock('@/cache-policy', () => ({ + useFullRender: () => true, +})) + +vi.mock('@/client-lazy', () => ({ + WebCommenting: () => null, +})) + +vi.mock('@/instrumentation.server', () => ({ + createInstrumentationContext: () => ({enabled: false}), + instrument: (_ctx: unknown, _name: string, fn: () => unknown) => fn(), + printInstrumentationSummary: vi.fn(), + setRequestInstrumentationContext: vi.fn(), +})) + +vi.mock('@/hypermedia-metadata', () => ({ + createResourceMetadata: vi.fn(), + metadataToPageMeta: () => [], +})) + +vi.mock('@/loaders', () => ({ + loadSiteResource: mocks.loadSiteResource, +})) + +vi.mock('@/meta', () => ({ + defaultPageMeta: () => () => [], +})) + +vi.mock('@/not-registered', () => ({ + NoSitePage: () => null, + NotRegisteredPage: () => null, +})) + +vi.mock('@/providers', () => ({ + WebSiteProvider: ({children}: {children: unknown}) => children, +})) + +vi.mock('@/site-config.server', () => ({ + getConfig: mocks.getConfig, +})) + +vi.mock('@/wrapping', () => ({ + unwrap: (value: T) => value, +})) + +vi.mock('@/web-feed-page', () => ({ + WebFeedPage: () => null, +})) + +vi.mock('@/web-resource-page', () => ({ + WebInspectorPage: () => null, + WebResourcePage: () => null, +})) + +vi.mock('@/wrapping.server', () => ({ + wrapJSON: (data: unknown, init?: unknown) => ({data, init}), +})) + +vi.mock('@shm/shared/utils/navigation', () => ({ + useNavigationState: () => null, +})) + +vi.mock('@shm/ui/inspect-ipfs-page', () => ({ + InspectIpfsPage: () => null, +})) + +vi.mock('@shm/shared/translation', () => ({ + useTx: () => (key: string, fallback?: string) => fallback || key, +})) + +vi.mock('@shm/ui/text', () => ({ + SizableText: ({children}: {children: unknown}) => children, +})) + +import {loader} from './routes/$' + +describe('comment permalink route loading', () => { + beforeEach(() => { + mocks.getConfig.mockReset() + mocks.loadSiteResource.mockReset() + mocks.getConfig.mockResolvedValue({registeredAccountUid: 'site-account'}) + mocks.loadSiteResource.mockResolvedValue({ok: true}) + }) + + it('treats ?v on a comment permalink as the comment version, not the document version', async () => { + await loader({ + params: {'*': 'hm/doc-account/docs/:comments/comment-id'}, + request: new Request('https://seed.example/hm/doc-account/docs/:comments/comment-id?v=comment-version-cid'), + }) + + expect(mocks.loadSiteResource).toHaveBeenCalledTimes(1) + const [, documentId, extraData] = mocks.loadSiteResource.mock.calls[0] + + expect(documentId).toMatchObject({ + uid: 'doc-account', + path: ['docs'], + version: null, + latest: true, + }) + expect(extraData).toMatchObject({ + viewTerm: 'comments', + openComment: 'comment-id', + commentVersion: 'comment-version-cid', + }) + }) +})