Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions .braid/snapshot.jsonl

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions claude-notes/instructions/performance-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,22 @@ node hub-client/scripts/perf/analyze-cpuprofile.mjs \
the real bottleneck is inside the shimmed dep, you'll need a
browser profile to see it.

**Driving the real render path.** A driver that exercises the production
render-path modules (`wasmRenderer.ts`, `intelligenceService.ts`, …)
needs three shims beyond the template. First, extend the resolve hook:
map `wasm-quarto-hub-client` and `/src/wasm-js-bridge/*` into the
workspace, `@quarto/*` to their `src/`, and any `*.wasm?url` import to
a stub string module — otherwise Node tries to load the `.wasm` as ESM
and fails on its WASI import. Second, shim `fetch` for `file:` URLs so
`initWasm()`\'s own `.wasm` load succeeds — that keeps the production
init path in the profile instead of a test-only bypass. Third, import
`fake-indexeddb/auto` **before** `initWasm()`: the SASS cache bridge
(`wasm-js-bridge/cache.js`) needs IndexedDB, and without it every
render recompiles the theme, roughly doubling the measured
per-keystroke cost. A full driver + fixture built this way is Phase 0
of `claude-notes/plans/2026-10-24-hub-client-main-thread-render-path.md`
(bd-k8ptcrd9).

### 4. Read the numbers before designing the fix

Put the numbers in a table with expected shapes next to them. If you
Expand Down
402 changes: 402 additions & 0 deletions claude-notes/plans/2026-10-24-hub-client-main-thread-render-path.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ WASM rebuild is needed for a changelog-only edit.

-->

### 2026-10-29

- [`d3b294042`](https://github.com/quarto-dev/q2/commits/d3b294042): Added opt-in performance gauges to the preview render path (renders, envelope parsing, VFS writes, semantic tokens), enabled with QUARTO_PERF_STATS=1, localStorage quartoPerfStats=1, or a perf=1 URL parameter and dumped with window.quartoPerfDump() — the measurement baseline for upcoming typing-latency work.

### 2026-10-27

- [`6a4a0d214`](https://github.com/quarto-dev/q2/commits/6a4a0d214): Fixed the "Connection lost - working offline" banner never clearing after the connection is restored, and the share dialog once again selects the link text shortly after it opens (the select-all had silently stopped firing).
Expand Down
9 changes: 9 additions & 0 deletions hub-client/src/components/render/PreviewRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DownloadOnlyView, NeitherView } from './DownloadOnlyViews';
import PdfPreviewPane from './PdfPreviewPane';
import { formatByKey, menuFormats, pdfPreviewAvailable, withOutputExt } from '../../pandoc/downloadService';
import { useDownloadAs } from '../../pandoc/useDownloadAs';
import { perfNow, perfSegment } from '../../utils/perfCounters';

interface PreviewRouterProps {
content: string;
Expand Down Expand Up @@ -133,6 +134,7 @@ export default function PreviewRouter(props: PreviewRouterProps) {
// Check the format whenever content changes
useEffect(() => {
async function checkFormat() {
const fxT0 = perfNow();
try {
// Skip format check if WASM isn't ready yet (will retry when it is)
if (!isWasmReady()) {
Expand All @@ -141,7 +143,11 @@ export default function PreviewRouter(props: PreviewRouterProps) {

// Parse the QMD only to learn whether it parses: a mid-edit syntax error keeps the
// previous mode. The format itself comes from the Rust resolver.
const parseT0 = perfNow();
const result = await parseQmdToAst(props.content);
// Finding 1 of the render-path plan: this per-keystroke full parse's
// AST is discarded — only `success` is read.
perfSegment('router.format-check.parse', parseT0);
const deps = {
// The editor buffer, not the VFS copy: it can lag a keystroke behind.
resolve: (path: string) => (isWasmReady() ? resolvePandocFormats(path, { contentOverride: props.content }) : null),
Expand All @@ -150,7 +156,9 @@ export default function PreviewRouter(props: PreviewRouterProps) {
};
// A format the parser's metadata merge does not know (`latex`) fails the parse. The
// resolver still classifies it; a failed parse of a previewable document changes nothing.
const classifyT0 = perfNow();
const mode = classifyPreviewMode(props.currentFile?.path, deps);
perfSegment('router.format-check.classify', classifyT0);
if (result.success || mode.mode === 'download' || mode.mode === 'pdf' || mode.mode === 'neither') {
const format = mode.mode === 'react' ? mode.format : null;
// Keep the same object when nothing changed, so a keystroke does not re-render consumers.
Expand All @@ -162,6 +170,7 @@ export default function PreviewRouter(props: PreviewRouterProps) {
} catch (err) {
console.error('[PreviewRouter] Error checking format:', err);
} finally {
perfSegment('router.format-check', fxT0);
setCheckedPath(props.currentFile?.path);
}
}
Expand Down
10 changes: 8 additions & 2 deletions hub-client/src/components/render/ReactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { stripAnsi } from '@quarto/preview-renderer/utils/stripAnsi';
import { PreviewErrorOverlay } from '@quarto/preview-renderer/overlays/PreviewErrorOverlay';
import { usePreference } from '../../hooks/usePreference';
import { useScrollSync } from '../../hooks/useScrollSync';
import { perfNow, perfSegment } from '../../utils/perfCounters';
import { perfNow, perfSegment, perfMark } from '../../utils/perfCounters';
import type { Q2PreviewIframeHandle } from '@quarto/preview-renderer/iframe/Q2PreviewIframe';
import ReactRenderer from './ReactRenderer';

Expand Down Expand Up @@ -715,7 +715,13 @@ export default function ReactPreview({
attributionJson: attributionPayload,
captureGzJson: captureBytes,
});
if (qmdContent !== lastContentRef.current) return;
if (qmdContent !== lastContentRef.current) {
// A newer edit superseded this render while it was in flight; drop
// the stale result (finding 2's second line of defense — the wasted
// work already happened, which `render.started/completed` counts).
perfMark('render.discarded-stale');
return;
}

// Update diagnostics
onDiagnosticsChange(result.diagnostics);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,44 @@ describe('classifyPreviewMode against the real wasm', () => {
expect(modeOfBuffer('/project/a.qmd', '# A\n')).toEqual({ mode: 'react', format: 'q2-preview' });
});
});

// Phase 0 characterization for the main-thread render-path plan
// (bd-k8ptcrd9): the outcomes PreviewRouter depends on per keystroke,
// pinned so Phase 2's parse-check-only classification can assert parity.
// Expected values below are DISCOVERED behavior (probed against the real
// resolver 2026-10-29), not the plan's guesses.
describe('editor-edge inputs (Phase 0 characterization, bd-k8ptcrd9)', () => {
it('an unknown format key classifies as neither (not dom)', async () => {
// The resolver succeeds with class 'neither' for a scalar format key
// it does not know. (Extension formats such as `acm-html` are a
// different path — class 'preview' → dom — pinned in
// getQ2Format.test.ts.)
expect(await modeOf('format: some-unknown-format')).toEqual({
mode: 'neither',
formatKey: 'some-unknown-format',
});
});

it('a mid-edit broken body still classifies from the front matter', async () => {
// Unclosed code fence mid-typing: the resolver reads the YAML, so the
// mode must not flicker while the body is transiently unparsable.
const buffer = '---\nformat: revealjs\n---\n\n# Deck\n\n```{r}\n';
expect(modeOfBuffer('/project/doc.qmd', buffer)).toEqual({ mode: 'react', format: 'revealjs' });
});

it('an unterminated front-matter block still resolves — as the default html', async () => {
// Mid-edit YAML: no closing fence. The resolver SUCCEEDS but does not
// see the unterminated block's `format:` key — it falls back to the
// default (html → q2-preview). So an in-progress edit of the format
// key itself can transiently reclassify to q2-preview; PreviewRouter's
// parse guard is what suppresses the flicker (component logic).
const buffer = '---\ntitle: T\nformat: revealjs\n';
const resolved = JSON.parse(
wasm.resolve_pandoc_formats('/project/doc.qmd', { contentOverride: buffer }),
) as ResolvePandocFormatsResponse;
expect(resolved.success).toBe(true);
expect(resolved.key).toBe('html');
expect(modeOfBuffer('/project/doc.qmd', buffer)).toEqual({ mode: 'react', format: 'q2-preview' });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,38 @@ describe('Q2SandboxedPreviewIframe', () => {
});
});

it('pins the exact UPDATE_AST payload key set (Phase 0 characterization, bd-k8ptcrd9)', async () => {
// The main-thread render-path plan's Phase 3 drops `assetManifest` from
// this payload; the exact key set is the contract it changes, so pin it.
const { iframe, postMessage } = renderIframe();
signalIframeReady(iframe);

await waitFor(() => {
const updateAst = postMessage.mock.calls.find(
([msg]) => (msg as { type?: string }).type === 'UPDATE_AST',
);
expect(updateAst).toBeDefined();
const payload = (updateAst![0] as { payload: Record<string, unknown> }).payload;
expect(Object.keys(payload).sort()).toEqual(
[
'astJson',
'currentFilePath',
'assetManifest',
'projectFilePaths',
'pendingAnchor',
'pendingAnchorEpoch',
'renderedContent',
'untransformedAstJson',
'currentActor',
'commentsMode',
'unlockNestingCursor',
'richText',
'nestedEditBuffers',
].sort(),
);
});
});

it('ignores messages whose source is not the sandboxed iframe window', async () => {
// Any window can postMessage the parent; only the embedded frame's
// own contentWindow may drive this component. A forged SET_AST from
Expand Down
35 changes: 33 additions & 2 deletions hub-client/src/services/intelligenceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
} from '@quarto/preview-renderer/types/intelligence';
import { initWasm, vfsAddFile } from '@quarto/preview-runtime';
import { isSourceFile } from '@quarto/preview-renderer/types/project';
import { perfEnabled, perfSegment, perfMark, perfExtra } from '../utils/perfCounters';

// Re-export types for convenience
export type { Symbol, Diagnostic, FoldingRange, DocumentAnalysis, SemanticToken } from '@quarto/preview-renderer/types/intelligence';
Expand Down Expand Up @@ -237,7 +238,14 @@ export async function getSemanticTokens(path: string): Promise<SemanticToken[]>

try {
const wasm = await getWasm();
return parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
if (!perfEnabled()) {
return parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
}
perfMark('intelligence.semantic-tokens');
const t0 = performance.now();
const tokens = parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
perfSegment('intelligence.semantic-tokens.tokenize', t0);
return tokens;
} catch (e) {
console.warn('Failed to get semantic tokens:', e);
return [];
Expand Down Expand Up @@ -270,10 +278,20 @@ export async function getSemanticTokensForContent(

try {
const wasm = await getWasm();
if (!perfEnabled()) {
// Synchronous from here: align the VFS image to the model, then
// tokenise it in the same macrotask (see the enabled path below).
vfsAddFile(path, content);
return parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
}
perfMark('intelligence.semantic-tokens');
const t0 = performance.now();
// Synchronous from here: align the VFS image to the model, then tokenise it
// in the same macrotask so nothing can re-drift the two in between.
vfsAddFile(path, content);
return parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
const tokens = parseSemanticTokensResponse(wasm.lsp_get_semantic_tokens(path));
perfSegment('intelligence.semantic-tokens.tokenize', t0);
return tokens;
} catch (e) {
console.warn('Failed to get semantic tokens:', e);
return [];
Expand All @@ -282,7 +300,20 @@ export async function getSemanticTokensForContent(

/** Decode a `lsp_get_semantic_tokens` JSON envelope, collapsing errors to `[]`. */
function parseSemanticTokensResponse(json: string): SemanticToken[] {
// Finding 5's envelope tax on the token path folds into the shared
// `wasm-envelope.parse` gauge. `bytes` is String.length (UTF-16 units).
if (!perfEnabled()) {
const result: LspSemanticTokensResponse = JSON.parse(json);
if (result.success) {
return result.tokens ?? [];
}
console.warn('Failed to get semantic tokens:', result.error);
return [];
}
const t0 = performance.now();
const result: LspSemanticTokensResponse = JSON.parse(json);
perfSegment('wasm-envelope.parse', t0);
perfExtra('wasm-envelope.parse', 'bytes', json.length);
if (result.success) {
return result.tokens ?? [];
}
Expand Down
29 changes: 29 additions & 0 deletions hub-client/src/utils/perfCounters.sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Drift guard for the two perfCounters copies (see the files' own header
* comments for why two exist, bd-k8ptcrd9). Asserts the implementations
* are byte-identical after stripping each file's leading doc comment —
* the only intended difference is the header naming each copy's location.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

function stripHeaderComment(source: string): string {
const idx = source.indexOf('interface Gauge');
if (idx < 0) throw new Error('perfCounters: "interface Gauge" not found');
return source.slice(idx);
}

describe('perfCounters copies', () => {
it('hub-client and preview-runtime implementations stay byte-identical', () => {
const here = dirname(fileURLToPath(import.meta.url));
const hubCopy = readFileSync(join(here, 'perfCounters.ts'), 'utf8');
const packageCopy = readFileSync(
join(here, '../../../ts-packages/preview-runtime/src/perfCounters.ts'),
'utf8',
);
expect(stripHeaderComment(packageCopy)).toBe(stripHeaderComment(hubCopy));
});
});
Loading
Loading