diff --git a/docs/specifications/internal-links.md b/docs/specifications/internal-links.md index 17dad2a..d7bfc60 100644 --- a/docs/specifications/internal-links.md +++ b/docs/specifications/internal-links.md @@ -53,6 +53,10 @@ Before this feature, a `page_link` whose target had been deleted was indistingui ## 5. Backlinks -Opening a Document shows a Backlinks panel. It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a navigable referring Document title and the current text of the referring block as context. The panel is derived live from `listIncomingLinks`, so a source edit, rename, move, or deletion is immediately reflected; it never stores a title as link identity and cannot retarget a backlink when Documents have duplicate titles. +Opening a Document shows a Backlinks panel (`BacklinksPanel.svelte`). It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a link to the exact referring block (not just the referring Document) and the current text of that block as context. -The current personal-MVP UI exposes every workspace Document to its sole local user. When workspace/space visibility boundaries are introduced, the panel must filter the `listIncomingLinks` result through the viewer's access scope before rendering; the reverse index itself remains an ID-based data primitive and must not become a permission side channel. +Powered by `services/documents.ts#listBacklinks`, not `$lib/data/links.ts#listIncomingLinks`'s client-side incremental index: that index is scoped to whatever single `Y.Doc` it's built against, which — since each Document has its own shard (#120) — can only ever be the current Document's own shard, structurally unable to span the workspace. The Backlinks panel built on it was removed for exactly this reason when #120 shipped. `listBacklinks` instead fans out across every Document's own shard server-side, the same `fanOutCatalogedAndUncataloged` pattern `search.ts#searchWorkspace` already established (#191), calling the stateless `listOutgoingLinks(doc, documentId)` scan once per fanned-out Document rather than relying on any single Y.Doc's incremental index. The load is SSR-only, not live (same accepted tradeoff `+layout.server.ts`'s own `documents`/`collections` lists already make) — a new backlink appears on next navigation/refresh, not instantly, since re-running a workspace-wide scan on every Yjs observer tick isn't proportionate. + +Each entry navigates via a `#block-` fragment on the referring Document's URL, reusing the same navigate/reveal/temporary-highlight mechanism `SyncedBlockUsage.svelte`'s "used in N places" panel already established: a same-document link short-circuits into an in-place scroll/focus/highlight (`navigateToBlock`) instead of a full reload, a cross-document link is a real `` (preserving browser Back and keyboard activation), and a stale/deleted source block degrades safely to just opening the Document, since both the DOM lookup and the block-ref lookup behind the highlight are optional-chained. + +`listBacklinks` is permission-filtered the same way `listDocuments` is: a source Document the caller can't reach never contributes a backlink entry, so a scoped MCP token (not currently exposed there — UI-only per its `serviceSurfaces` entry) couldn't use this to learn about content outside its grant if it ever were. The current personal-MVP UI's human caller is unscoped (Phase 0), so this is a no-op for the UI's own use — the load-bearing case is forward-looking. diff --git a/src/lib/components/BacklinksPanel.svelte b/src/lib/components/BacklinksPanel.svelte new file mode 100644 index 0000000..1c18031 --- /dev/null +++ b/src/lib/components/BacklinksPanel.svelte @@ -0,0 +1,65 @@ + + +
+
+ + + {backlinks.length} +
+ {#if backlinks.length > 0} +
+ {:else} +

No pages link here yet.

+ {/if} +
diff --git a/src/lib/components/SyncedBlockUsage.svelte.test.ts b/src/lib/components/SyncedBlockUsage.svelte.test.ts new file mode 100644 index 0000000..fdb30ab --- /dev/null +++ b/src/lib/components/SyncedBlockUsage.svelte.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'svelte'; +import type { Backlink } from '$lib/data/links'; +import SyncedBlockUsage from './SyncedBlockUsage.svelte'; + +function instance(overrides: Partial = {}): Backlink { + return { + sourceDocumentId: 'doc-source', + sourceDocumentTitle: 'Source Doc', + sourceRecordId: 'block-1', + context: 'Some context text', + ...overrides + }; +} + +function baseProps( + overrides: Partial> = {} +): ComponentProps { + return { + spaceId: 'space-1', + currentDocumentId: 'doc-current', + instances: [instance()], + onJumpTo: vi.fn(), + ...overrides + }; +} + +// This is the one live surface using the navigate-to-`#block-`-and-reveal +// mechanism issue #83 asks for — the general page_link/wiki-link Backlinks +// panel that issue describes was removed (#120) pending a shard-aware +// reverse index (#81/#70), so this is where that navigation contract is +// actually exercised today. +describe('SyncedBlockUsage (#153, #83)', () => { + it('lists each usage as a real link to its exact source-block fragment', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + + const link = screen.getByRole('menuitem', { name: /Source Doc/ }); + expect(link).toHaveAttribute('href', expect.stringContaining('/space/space-1/doc/doc-source')); + expect(link.getAttribute('href')).toMatch(/#block-block-1$/); + }); + + it('jumps in place and suppresses navigation when the usage is in the current document', async () => { + const user = userEvent.setup(); + const props = baseProps({ + currentDocumentId: 'doc-source', + instances: [instance({ sourceDocumentId: 'doc-source' })] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: /Source Doc/ })); + + expect(props.onJumpTo).toHaveBeenCalledExactlyOnceWith('doc-source', 'block-1'); + }); + + // A real (not a JS-only handler) for a cross-document usage is + // what keeps browser Back and keyboard Enter/Space activation working for + // free — asserting onJumpTo is never called here is how this test proves + // the click was left to normal navigation instead of being intercepted. + it('does not intercept navigation when the usage is in a different document', async () => { + const user = userEvent.setup(); + const props = baseProps({ + currentDocumentId: 'doc-current', + instances: [instance({ sourceDocumentId: 'doc-other' })] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: /Source Doc/ })); + + expect(props.onJumpTo).not.toHaveBeenCalled(); + }); + + // The pointer-click test above only proves the onclick handler's own + // preventDefault branch is skipped for a cross-document usage — it + // doesn't rule out some *other* handler (e.g. a keydown listener) doing + // the same thing only for keyboard activation. Enter-on-a-focused-anchor + // dispatches a real click event the same way a browser does, so this + // listens for that event directly to confirm nothing prevented it. + it('does not intercept keyboard (Enter) activation when the usage is in a different document', async () => { + const user = userEvent.setup(); + const props = baseProps({ + currentDocumentId: 'doc-current', + instances: [instance({ sourceDocumentId: 'doc-other' })] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + const link = screen.getByRole('menuitem', { name: /Source Doc/ }); + let clickEvent: MouseEvent | undefined; + link.addEventListener('click', (e) => { + clickEvent = e as MouseEvent; + }); + + link.focus(); + await user.keyboard('{Enter}'); + + expect(clickEvent).toBeDefined(); + expect(clickEvent?.defaultPrevented).toBe(false); + expect(props.onJumpTo).not.toHaveBeenCalled(); + }); + + it('shows a placeholder instead of a link list when there are no other usages', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps({ instances: [] })); + + await user.click(screen.getByRole('button', { name: 'Used in 0 places' })); + + expect(screen.getByText('No other locations yet.')).toBeInTheDocument(); + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('ArrowDown/ArrowUp move roving focus among usage links, wrapping at each end', async () => { + const user = userEvent.setup(); + const props = baseProps({ + instances: [ + instance({ sourceRecordId: 'block-1', sourceDocumentTitle: 'First Doc' }), + instance({ sourceRecordId: 'block-2', sourceDocumentTitle: 'Second Doc' }) + ] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 2 places' })); + const first = screen.getByRole('menuitem', { name: /First Doc/ }); + const second = screen.getByRole('menuitem', { name: /Second Doc/ }); + expect(first).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(second).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(first).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + expect(second).toHaveFocus(); + }); + + it('offers Detach only when onDetach is provided, and calls it', async () => { + const user = userEvent.setup(); + const props = baseProps({ onDetach: vi.fn() }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: 'Detach to independent copy' })); + + expect(props.onDetach).toHaveBeenCalledOnce(); + }); + + it('does not offer Detach when onDetach is absent', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + + expect(screen.queryByRole('menuitem', { name: 'Detach to independent copy' })).toBeNull(); + }); +}); diff --git a/src/lib/services/documents.ts b/src/lib/services/documents.ts index f96f959..96eb653 100644 --- a/src/lib/services/documents.ts +++ b/src/lib/services/documents.ts @@ -4,6 +4,7 @@ import { createDocument as crdtCreateDocument, deleteDocument as crdtDeleteDocument, getDocument as crdtGetDocument, + listDocuments as crdtListDocuments, resolveChildPages, updateDocumentParent as crdtUpdateDocumentParent, updateDocumentTitle as crdtUpdateDocumentTitle @@ -11,8 +12,10 @@ import { import { getCollection as crdtGetCollection } from '$lib/data/collection-ops'; import { createRecord as crdtCreateRecord, + getRecord as crdtGetRecord, listRecordsForParent as crdtListRecordsForParent } from '$lib/data/record-ops'; +import { plainText } from '$lib/data/richtext'; import { logAudit } from '$lib/server/audit'; import { RecordIdConflictError, @@ -27,8 +30,16 @@ import { resolveShardForParent } from '$lib/server/catalog'; import { grantDocumentAccess, tokenAllowsParent } from '$lib/server/token-store'; -import { listWorkspaceDocuments } from '$lib/server/workspace-repository'; -import { resolveInternalLinkTarget, type InternalLinkTarget } from '$lib/data/links'; +import { + fanOutCatalogedAndUncataloged, + listWorkspaceDocuments +} from '$lib/server/workspace-repository'; +import { + listOutgoingLinks, + resolveInternalLinkTarget, + type Backlink, + type InternalLinkTarget +} from '$lib/data/links'; import type { CalloutStyle, ChildPageNode, @@ -593,6 +604,70 @@ export function getDocument( }; } +/** + * Every Document currently pointing at `documentId` via a `page_link` block + * or an inline `[[wiki link]]` (`internal-links.md` §5) — the reverse of + * `listOutgoingLinks`. Fanned out across every Document's own shard the same + * way `search.ts#searchWorkspace` scans for text matches (#191's shared + * workspace-repository fan-out): `$lib/data/links.ts#listIncomingLinks`'s own + * incremental index is scoped to whatever single `Y.Doc` it's built against + * and can't span per-Document shards on its own (#120) — see that module's + * doc comment, and the Backlinks panel removal note this replaces in + * `+page.svelte`. `listOutgoingLinks` itself has no such limitation: it's a + * plain one-shot scan of one already-resolved `doc`, which is exactly what + * this function calls it with, once per fanned-out Document. + * + * Permission-filtered the same way `listDocuments` is: a source Document the + * caller can't reach never contributes a backlink entry, per + * `internal-links.md` §5's requirement that a viewer only ever sees backlinks + * from Documents already within their own access scope. A no-op filter for + * Phase 0's unscoped human caller (`isAccessToken` is false), load-bearing + * once this is ever reachable by a scoped MCP token. + */ +export function listBacklinks(caller: CallerIdentity, documentId: string): Backlink[] { + requireAccessibleParent(caller, documentId, 'list_backlinks'); + const actor = actorForCaller(caller); + const { workspaceId, defaultSpaceId, doc: defaultDoc } = resolveWorkspaceContext(); + const allowed = (id: string, docSpaceId?: string) => + !isAccessToken(caller) || tokenAllowsParent(caller, id, docSpaceId); + + const backlinks: Backlink[] = []; + for (const { meta, doc } of fanOutCatalogedAndUncataloged({ + workspaceId, + defaultSpaceId, + defaultDoc, + listCatalog: listCatalogDocuments, + listUncataloged: crdtListDocuments, + getId: (m) => m.id, + getSpaceId: (m) => m.spaceId, + allowed, + resolveShardDoc: true + })) { + for (const link of listOutgoingLinks(doc, meta.id)) { + if (link.targetId !== documentId) continue; + const sourceRecord = crdtGetRecord(doc, link.sourceRecordId); + if (!sourceRecord) continue; + const trimmedContent = sourceRecord.content ? plainText(sourceRecord.content).trim() : ''; + backlinks.push({ + sourceDocumentId: meta.id, + sourceDocumentTitle: meta.title, + sourceRecordId: link.sourceRecordId, + context: + trimmedContent || + (sourceRecord.blockType === 'page_link' ? 'Page link' : 'Untitled block') + }); + } + } + + logAudit({ + actor, + action: 'list_backlinks', + targetRecordId: documentId, + diff: { count: backlinks.length } + }); + return backlinks; +} + /** * `spaceId` is optional and additive — omitted, this is exactly today's * behavior (every Document in the workspace, catalog plus uncataloged diff --git a/src/lib/services/manifest.ts b/src/lib/services/manifest.ts index 47a0fbe..c5db83d 100644 --- a/src/lib/services/manifest.ts +++ b/src/lib/services/manifest.ts @@ -80,6 +80,11 @@ export const serviceSurfaces: Record = mcpToolName: 'list_documents', mcpDescription: 'List Documents this connection has access to, including tree hierarchy.' }, + // UI-only for now (issue #83): the Document route's own load function + // renders a Backlinks panel from this. Not MCP-exposed — #83's own scope + // doesn't ask for an agent-facing surface, and this can be added later + // without touching the function's own contract. + 'documents.listBacklinks': { mcp: false, ui: true }, 'records.createRecord': { mcp: true, @@ -207,6 +212,7 @@ export const uiAdapterBindings = { // see docs/specifications/audit-coverage.md. 'documents.updateDocumentTitle': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'documents.listDocuments': 'src/routes/+layout.server.ts', + 'documents.listBacklinks': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts', 'records.createRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'records.writeRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'records.deleteRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', diff --git a/src/lib/services/services.test.ts b/src/lib/services/services.test.ts index 326778f..cf330b2 100644 --- a/src/lib/services/services.test.ts +++ b/src/lib/services/services.test.ts @@ -9,6 +9,7 @@ import { getDocument as servicesGetDocument, getRecord, holdRecords, + listBacklinks, listCollections, listDocuments, moveDocument, @@ -1275,6 +1276,123 @@ describe('service layer: documents — unfiltered listing, delete, and rename', }); }); +describe('service layer: listBacklinks — cross-shard reverse-link fan-out (issue #83)', () => { + it('finds a page_link block in a different Document pointing at the target', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + const block = createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + + const backlinks = listBacklinks(human, target.id); + + expect(backlinks).toHaveLength(1); + expect(backlinks[0]).toEqual({ + sourceDocumentId: source.id, + sourceDocumentTitle: 'Source Doc', + sourceRecordId: block.id, + context: 'Page link' + }); + }); + + it('finds an inline [[wiki link]] mark pointing at the target, using the block text as context', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + const block = createRecord(human, { parentId: source.id, blockType: 'paragraph' }); + writeRecord(human, block.id, { markdown: 'See [[Target Doc]] for more.' }); + + const backlinks = listBacklinks(human, target.id); + + expect(backlinks).toHaveLength(1); + expect(backlinks[0].sourceDocumentId).toBe(source.id); + expect(backlinks[0].sourceRecordId).toBe(block.id); + expect(backlinks[0].context).toContain('Target Doc'); + }); + + it('excludes links pointing at a different Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const other = createDocument(human, { title: 'Other Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: other.id + }); + + expect(listBacklinks(human, target.id)).toEqual([]); + }); + + it('reflects a source edit live — not a cached title/context', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + updateDocumentTitle(human, source.id, 'Renamed Source'); + + const backlinks = listBacklinks(human, target.id); + expect(backlinks[0].sourceDocumentTitle).toBe('Renamed Source'); + }); + + it('omits a backlink whose source Document a token was not granted', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + const { record: tokenRecord } = createToken({ + clientLabel: 'Scoped Backlink Reader', + allowedDocumentIds: [target.id], + allowedCollectionIds: [] + }); + + expect(listBacklinks(tokenRecord, target.id)).toEqual([]); + }); + + it('includes the backlink once the token is also granted the source Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + const { record: tokenRecord } = createToken({ + clientLabel: 'Fully Scoped Backlink Reader', + allowedDocumentIds: [target.id, source.id], + allowedCollectionIds: [] + }); + + const backlinks = listBacklinks(tokenRecord, target.id); + expect(backlinks.some((b) => b.sourceDocumentId === source.id)).toBe(true); + }); + + it('is denied for a token without access to the target Document itself', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const { record: tokenRecord } = createToken({ + clientLabel: 'No Access Bot', + allowedDocumentIds: [], + allowedCollectionIds: [] + }); + + expect(() => listBacklinks(tokenRecord, target.id)).toThrow(PermissionDeniedError); + }); + + it('logs an audit entry attributed to the target Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + listBacklinks(human, target.id); + + const entries = queryAuditLog().filter((e) => e.targetRecordId === target.id); + expect(entries.some((e) => e.action === 'list_backlinks')).toBe(true); + }); +}); + describe('service layer: collections — grants, listing, query, delete, rename', () => { it('a token creating a collection is granted access to it and can query it back', () => { const { record: tokenRecord } = createToken({ diff --git a/src/routes/space/[spaceId]/doc/[id]/+page.server.ts b/src/routes/space/[spaceId]/doc/[id]/+page.server.ts index 067a491..6f10650 100644 --- a/src/routes/space/[spaceId]/doc/[id]/+page.server.ts +++ b/src/routes/space/[spaceId]/doc/[id]/+page.server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; import { getDocument } from '$lib/data/document-ops'; -import { listDocuments, listCollections } from '$lib/services'; +import { listBacklinks, listDocuments, listCollections } from '$lib/services'; import { resolveParentWorkspaceContext } from '$lib/services/permissions'; import type { PageServerLoad } from './$types'; @@ -31,6 +31,12 @@ export const load: PageServerLoad = ({ params, locals }) => { // page_link target rendering. Not live, same accepted tradeoff as // Sidebar's lists. documents: listDocuments(locals.requestContext.caller), - collections: listCollections(locals.requestContext.caller) + collections: listCollections(locals.requestContext.caller), + // Backlinks panel (issue #83) — SSR-only, not live, same accepted + // tradeoff as documents/collections above: a workspace-wide fan-out + // scan (see documents.ts#listBacklinks) isn't something to re-run on + // every Yjs observer tick, so a new backlink appears on next + // navigation/refresh rather than instantly. + backlinks: listBacklinks(locals.requestContext.caller, params.id) }; }; diff --git a/src/routes/space/[spaceId]/doc/[id]/+page.svelte b/src/routes/space/[spaceId]/doc/[id]/+page.svelte index ae04f91..7ed264a 100644 --- a/src/routes/space/[spaceId]/doc/[id]/+page.svelte +++ b/src/routes/space/[spaceId]/doc/[id]/+page.svelte @@ -64,6 +64,7 @@ import PromptDialog from '$lib/components/PromptDialog.svelte'; import BlockActionMenu from '$lib/components/BlockActionMenu.svelte'; import SyncedBlockUsage from '$lib/components/SyncedBlockUsage.svelte'; + import BacklinksPanel from '$lib/components/BacklinksPanel.svelte'; import DocumentOutline from '$lib/components/DocumentOutline.svelte'; import type { PageProps } from './$types'; @@ -82,6 +83,18 @@ // remote title edits — untrack() here just tells Svelte that's deliberate. let title = $state(untrack(() => data.title)); let blocks: WorkspaceRecord[] = $state([]); + // Which Document `blocks` (and `ydoc`) actually reflect right now — set + // only by refresh(), once the shard for that Document has genuinely + // resolved. Distinct from `data.documentId` itself: client-side navigation + // updates `data.documentId` synchronously, but `blocks`/`ydoc` still lag + // behind it until the async shard-resolution fetch below completes. The + // hash-navigation $effect (issue #152) reads this, not just + // `blocks.length`, so it can tell "no blocks because this Document is + // genuinely empty" apart from "no blocks yet because the *previous* + // Document's blocks haven't been replaced" — the latter previously let it + // search the wrong Document's DOM and never retry once the real + // destination blocks loaded (CodeRabbit review, PR #257). + let blocksDocumentId: string | undefined = $state(); let slashMenuBlockId: string | null = $state(null); let slashQuery = $state(''); let heldByOthers: Map = $state(new Map()); @@ -187,6 +200,7 @@ if (!ydoc) return; const nextBlocks = listRecordsForParent(ydoc, data.documentId); blocks = nextBlocks; + blocksDocumentId = data.documentId; const docMeta = getDocument(ydoc, data.documentId); title = docMeta?.title ?? data.title; if (docMeta?.parentDocumentId) { @@ -525,9 +539,22 @@ // subsequent blocks refresh (any later edit anywhere in the Document also // reassigns `blocks`, which would otherwise re-trigger this on every // keystroke). + // + // Gated on `blocksDocumentId === data.documentId`, not just `blocks.length + // === 0`: this effect also reads `data.documentId`, so a client-side + // navigation to a different Document (e.g. a cross-document Backlinks/ + // SyncedBlockUsage link, which sets the hash and navigates in the same + // step) re-runs it immediately — before the shard-resolution $effect + // above has replaced `blocks` with the *new* Document's own. Checking + // `blocks.length` alone couldn't tell "not loaded yet" apart from "the + // previous Document's (non-empty) blocks, still stale" — it would search + // the previous Document's DOM, find nothing, and — because it marks + // `hashNavigatedForDocument` for the new id regardless — never retry once + // the real destination blocks actually did load (CodeRabbit review, + // PR #257). let hashNavigatedForDocument: string | null = $state(null); $effect(() => { - if (!ydoc || blocks.length === 0) return; + if (!ydoc || blocksDocumentId !== data.documentId) return; if (hashNavigatedForDocument === data.documentId) return; hashNavigatedForDocument = data.documentId; const match = /^#block-(.+)$/.exec(page.url.hash); @@ -1633,12 +1660,24 @@
{holdAnnouncement}
+ { + if (documentId === data.documentId) void navigateToBlock(recordId); + }} + />