Skip to content

Commit b0e237e

Browse files
committed
Extract useNodeAttributionResolver hook
Move the SourceInfoReconstructor + getNodeAttribution cache logic out of AstRenderer and into hooks/useAttribution.ts, alongside AttributionContext. NodeAttributionContext moves too, since nothing about its shape is debug-specific. Any renderer with an astContext can now opt in with one hook call and a provider wrap instead of re-deriving the plumbing.
1 parent 7cfb759 commit b0e237e

3 files changed

Lines changed: 83 additions & 64 deletions

File tree

hub-client/src/components/render/ReactAstDebugRenderer.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import React from 'react';
1111
import { render, fireEvent } from '@testing-library/react';
1212
import { Ast } from './ReactAstDebugRenderer';
1313
import type { PandocAST } from './ReactAstDebugRenderer';
14-
import { NodeAttributionContext } from './ReactAstDebugRenderer';
14+
import { NodeAttributionContext } from '../../hooks/useAttribution';
1515
import type { NodeAttribution } from '../../services/attribution';
1616

1717
/** Helper to build minimal AST JSON with a single Str node */

hub-client/src/components/render/ReactAstDebugRenderer.tsx

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
1-
import React, { createContext, useContext, useRef, useState, useCallback, useMemo } from 'react';
1+
import React, { createContext, useContext, useRef, useState, useCallback } from 'react';
22
import type { NodeAttribution } from '../../services/attribution';
3-
import type { SerializableSourceInfo, RustFileInfo } from '@quarto/pandoc-types';
4-
import { SourceInfoReconstructor } from '@quarto/annotated-qmd';
5-
import type { SourceContext } from '@quarto/pandoc-types';
6-
import { AttributionContext } from '../../hooks/useAttribution';
7-
import { getNodeAttribution } from '../../services/attribution';
3+
import {
4+
AttributionContext,
5+
NodeAttributionContext,
6+
useNodeAttributionResolver,
7+
} from '../../hooks/useAttribution';
8+
import type { AstAttributionContext } from '../../hooks/useAttribution';
89

910
// Context for unified component registry
1011
const RegistryContext = createContext<{
1112
registry: Record<string, (props: any) => React.ReactNode>;
1213
} | null>(null);
1314

14-
// Context for per-node attribution queries
15-
export const NodeAttributionContext = createContext<{
16-
getNodeAttribution: (sourceInfoId: number) => NodeAttribution | null;
17-
} | null>(null);
18-
1915
/**
2016
* Simplified Pandoc AST types for rendering
2117
*/
2218
export interface PandocAST {
2319
'pandoc-api-version': [number, number, number];
2420
meta: Record<string, unknown>;
2521
blocks: BlockNode[];
26-
astContext?: {
27-
sourceInfoPool: SerializableSourceInfo[];
28-
files: RustFileInfo[];
29-
};
22+
astContext?: AstAttributionContext;
3023
}
3124

3225
export type ParaBlock = { t: 'Para'; c: InlineNode[] };
@@ -539,50 +532,8 @@ const AstRenderer = ({ ast, onNavigateToDocument, setAst }: {
539532
onNavigateToDocument?: (path: string, anchor: string | null) => void;
540533
setAst: (newAst: PandocAST) => void;
541534
}) => {
542-
// Extract attribution context and build getNodeAttribution closure
543535
const attributionCtx = useContext(AttributionContext);
544-
const astContext = ast.astContext;
545-
546-
const nodeAttributionValue = useMemo(() => {
547-
if (!astContext || !attributionCtx) return null;
548-
549-
try {
550-
// Populate files[0].content from the Automerge source text
551-
const sourceContext: SourceContext = {
552-
files: astContext.files.map((f, idx) => ({
553-
id: idx,
554-
path: f.name,
555-
content: idx === 0 ? attributionCtx.sourceText : (f.content ?? ''),
556-
})),
557-
};
558-
559-
const reconstructor = new SourceInfoReconstructor(
560-
astContext.sourceInfoPool,
561-
sourceContext,
562-
);
563-
564-
// Cache node attribution results — invalidated automatically when
565-
// this useMemo recomputes (new astContext or attributionCtx)
566-
const cache = new Map<number, NodeAttribution | null>();
567-
568-
return {
569-
getNodeAttribution: (sourceInfoId: number) => {
570-
const cached = cache.get(sourceInfoId);
571-
if (cached !== undefined) return cached;
572-
const result = getNodeAttribution(
573-
sourceInfoId,
574-
reconstructor,
575-
attributionCtx.source,
576-
attributionCtx.identities,
577-
);
578-
cache.set(sourceInfoId, result);
579-
return result;
580-
},
581-
};
582-
} catch {
583-
return null;
584-
}
585-
}, [astContext, attributionCtx]);
536+
const nodeAttributionValue = useNodeAttributionResolver(ast.astContext, attributionCtx);
586537

587538
// Use internally-computed value, falling back to any externally-provided context
588539
const externalNodeAttr = useContext(NodeAttributionContext);

hub-client/src/hooks/useAttribution.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
* on mount and refreshes it incrementally on edits.
66
*/
77

8-
import { useState, useEffect, useRef, useCallback, createContext } from 'react';
9-
import { buildByteToCharMap, HistoryCompactedError } from '../services/attribution';
10-
import type { AttributionSource } from '../services/attribution';
8+
import { useState, useEffect, useRef, useCallback, useMemo, createContext } from 'react';
9+
import { buildByteToCharMap, getNodeAttribution, HistoryCompactedError } from '../services/attribution';
10+
import type { AttributionSource, NodeAttribution } from '../services/attribution';
1111
import {
1212
buildRunListAttribution,
1313
updateRunListAttribution,
@@ -16,16 +16,84 @@ import {
1616
import type { RunListAttribution } from '../services/attribution-runs';
1717
import { getFileHandle } from '../services/automergeSync';
1818
import type { ActorIdentity } from '../services/automergeSync';
19+
import type { SerializableSourceInfo, RustFileInfo, SourceContext } from '@quarto/pandoc-types';
20+
import { SourceInfoReconstructor } from '@quarto/annotated-qmd';
1921

2022
// ---------------------------------------------------------------------------
2123
// Context — shared with the Ast component tree
2224
// ---------------------------------------------------------------------------
2325

24-
export const AttributionContext = createContext<{
26+
export interface AttributionValue {
2527
source: AttributionSource;
2628
identities: Record<string, ActorIdentity>;
2729
sourceText: string;
28-
} | null>(null);
30+
}
31+
32+
export const AttributionContext = createContext<AttributionValue | null>(null);
33+
34+
/** Resolver produced by `useNodeAttributionResolver` — what a render subtree consumes. */
35+
export interface NodeAttributionResolver {
36+
getNodeAttribution: (sourceInfoId: number) => NodeAttribution | null;
37+
}
38+
39+
export const NodeAttributionContext = createContext<NodeAttributionResolver | null>(null);
40+
41+
/** Shape of the `astContext` field on a parsed AST. */
42+
export interface AstAttributionContext {
43+
sourceInfoPool: SerializableSourceInfo[];
44+
files: RustFileInfo[];
45+
}
46+
47+
/**
48+
* Build a memoized per-render resolver that maps a `sourceInfoId` to
49+
* `NodeAttribution`. Encapsulates `SourceInfoReconstructor` construction
50+
* (including the `files[0].content` injection required because WASM JSON
51+
* omits file content), and caches results within a single `useMemo` lifetime.
52+
*
53+
* Returns `null` when either input is missing or the reconstructor throws.
54+
*/
55+
export function useNodeAttributionResolver(
56+
astContext: AstAttributionContext | null | undefined,
57+
attributionCtx: AttributionValue | null,
58+
): NodeAttributionResolver | null {
59+
return useMemo(() => {
60+
if (!astContext || !attributionCtx) return null;
61+
62+
try {
63+
const sourceContext: SourceContext = {
64+
files: astContext.files.map((f, idx) => ({
65+
id: idx,
66+
path: f.name,
67+
content: idx === 0 ? attributionCtx.sourceText : (f.content ?? ''),
68+
})),
69+
};
70+
71+
const reconstructor = new SourceInfoReconstructor(
72+
astContext.sourceInfoPool,
73+
sourceContext,
74+
);
75+
76+
const cache = new Map<number, NodeAttribution | null>();
77+
78+
return {
79+
getNodeAttribution: (sourceInfoId: number) => {
80+
const cached = cache.get(sourceInfoId);
81+
if (cached !== undefined) return cached;
82+
const result = getNodeAttribution(
83+
sourceInfoId,
84+
reconstructor,
85+
attributionCtx.source,
86+
attributionCtx.identities,
87+
);
88+
cache.set(sourceInfoId, result);
89+
return result;
90+
},
91+
};
92+
} catch {
93+
return null;
94+
}
95+
}, [astContext, attributionCtx]);
96+
}
2997

3098
// ---------------------------------------------------------------------------
3199
// Hook return type

0 commit comments

Comments
 (0)