diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0f37c8d3b..77456b4d64 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -156,6 +156,8 @@ jobs: packages/ui/components/AnnotationPanel.inReplyTo.test.tsx packages/editor/webmcp/useDocumentWebMcp.toggle.test.tsx packages/editor/components/AppHeader.webmcpIndicator.test.tsx + packages/ui/components/MathBlock.firstPaint.test.tsx + packages/ui/components/DiagramBlock.lazyRetry.test.tsx opencode-v2: name: OpenCode 2 installed package diff --git a/apps/guides-show/viewer/stubs/generateIdentity.ts b/apps/guides-show/viewer/stubs/generateIdentity.ts index bbbd783325..4a18d3af4b 100644 --- a/apps/guides-show/viewer/stubs/generateIdentity.ts +++ b/apps/guides-show/viewer/stubs/generateIdentity.ts @@ -2,3 +2,13 @@ export function generateIdentity(): string { return 'reader'; } + +export type IdentityGenerator = () => string; + +/** + * No-op registration, so the viewer build keeps working should + * `packages/ui/configure.ts` (which imports `setIdentityGenerator` from the + * real module) ever enter its import graph. The viewer never mints a name, so + * there is nothing to register into. + */ +export function setIdentityGenerator(_next: IdentityGenerator): void {} diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 526024d0d0..0947db3a93 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,3 +1,14 @@ +// Eager renderer registration (side-effect imports, evaluated before every +// other module below). These keep Plannotator's first paint, identity minting +// and failure surface byte-identical now that @plannotator/ui loads KaTeX, the +// username dictionary and the Mermaid runtime lazily for hosts: math is typeset +// on the first commit, names come from the full dictionary, and Mermaid stays +// in this app's entry chunk (the review editor never renders Mermaid and does +// not import that entry). Guarded by tests/entry-assets.test.ts; do not drop +// or reorder any of these lines. +import '@plannotator/ui/utils/math-eager'; +import '@plannotator/ui/utils/identity-tater'; +import '@plannotator/ui/utils/mermaid-eager'; import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { toast, Toaster } from 'sonner'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index d5a023d52f..9449030844 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -1,3 +1,11 @@ +// Eager renderer registration (side-effect imports, evaluated before every +// other module below). These keep Plannotator's first paint and identity +// minting byte-identical now that @plannotator/ui loads KaTeX and the username +// dictionary lazily for hosts: math is typeset on the first commit and names +// come from the full dictionary. Guarded by tests/entry-assets.test.ts; do not +// drop or reorder either line. +import '@plannotator/ui/utils/math-eager'; +import '@plannotator/ui/utils/identity-tater'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider'; diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index b11b28af35..ac50cea8c5 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -92,6 +92,8 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted | `aiTransport` | `AITransport` | The "Ask AI" chat session/query/abort/permission | `POST /api/ai/{session,query,abort,permission}` | | `serverSync` | `ServerSyncFn` | Push a settings change back to the server | No-op-ish (Plannotator's local sync) | | `loadSettingsFromBackend` | `boolean` | After install, re-hydrate settings from your `storageBackend` | off | +| `mathRendererLoader` | `() => Promise` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries") | `import('katex')`, JS only; CSS stays yours | +| `identityGenerator` | `() => string` | The synchronous generator behind the default "tater" display name when no `identityProvider` is installed | A built-in 16 x 16 word pool of the same `adjective-noun-tater` shape; Plannotator registers the full dictionary via `utils/identity-tater` | ### Interface details worth knowing @@ -206,6 +208,9 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | `utils/annotationHelpers` | Pure annotation utilities (`getAnnotationCountBySection`, `buildTocHierarchy` + `TocItem`). *(Blessed in 0.24.0.)* | | `components/html-viewer` (`HtmlViewer`) | The raw-HTML annotation viewer: overlay-projected placed markers, pinpoint anchors, multi-target comments. Props + validated bridge protocol; no backend of its own. See "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)". *(Blessed in 0.29.0.)* | | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* | +| `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. See "Lazy renderers and eager entries". | +| `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. | +| `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Import `utils/mermaid-eager` to keep Mermaid in your entry chunk as Plannotator does; omit it for the lazy path with retry. See "Lazy renderers and eager entries". | **AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code. @@ -238,6 +243,8 @@ The renderer's `MathBlock` (and inline math) uses KaTeX. **KaTeX's stylesheet an If you skip all three and render math, equations appear as broken-looking raw HTML — that's the symptom to recognize. If you never render math, do nothing. +The JS side is separate and lazy by default: KaTeX's runtime is no longer on the static import graph of `MathBlock` / `InlineMarkdown`. A host that renders `Viewer` without importing `@plannotator/ui/utils/math-eager` gets the TeX source in the same wrapper for one frame, then the typeset markup once `import('katex')` resolves. See "Lazy renderers and eager entries" for the opt-back and the loader seam. + --- ## The annotation anchor schema (what you're storing) @@ -438,6 +445,34 @@ Behavior is pinned by `components/MarkdownEditor.embedPicker.test.ts`, the suppo --- +## Lazy renderers and eager entries (unreleased) + +Four modules that used to ride every document read for a host that bundles by route now load on demand: the Mermaid runtime, the Graphviz engine, KaTeX, and the username dictionary. Plannotator's own apps register KaTeX and the dictionary eagerly in both the plan editor and the review editor, and the plan editor also registers the Mermaid runtime eagerly (the review editor never renders a Mermaid block and deliberately does not), so every surface renders exactly as before; the single-file builds are unchanged in size and first paint and the portal entry chunk keeps Mermaid as on main (the built-HTML markers and the A/B proof live in `tests/entry-assets.test.ts` and the PR that shipped this). + +1. **Graphviz: no seam, nothing to do.** `GraphvizBlock` imports `@viz-js/viz` inside its render effect. It already showed the source fence until the SVG landed, so the only change for a chunking host is that the first dot fence on a page fetches the engine. A failed import is dropped from the memo and re-attempted once with a fresh `import()` after a short delay; a persistently failing chunk surfaces as the existing error panel with the source, plus a Retry button that issues another fresh attempt (a diagram syntax error shows the panel exactly as before, without Retry). Hosts that aliased the specifier to a lazy shim can delete the shim. + + **Mermaid: a runtime slot, filled eagerly by Plannotator.** `utils/mermaid` holds the slot (`getMermaidRuntime`, `setMermaidRuntime`, `getMermaidRuntimeSource`) and the one code path `MermaidBlock` uses, `loadMermaidRuntime()`: it resolves at once from a filled slot and otherwise imports `mermaid` lazily, initialized once with `MERMAID_CONFIG` (`securityLevel: 'strict'` pinned by test), with the same drop-on-rejection, one automatic re-attempt and Retry button as Graphviz. `utils/mermaid-eager` imports the runtime statically, initializes it at module evaluation (where the old module-scope `initialize` ran) and fills the slot; `packages/editor/App.tsx` imports it by policy, so Plannotator's plan surfaces keep Mermaid in their entry chunk and it can never fail separately from the app (on the share portal `mermaid.core` stays in the entry, as on main). The review editor does not import it because it never renders a Mermaid block. A host that wants the same adds `import '@plannotator/ui/utils/mermaid-eager'`; a host that omits it gets the lazy path. + + **Retry, honestly.** An in-page retry cannot recover a chunk whose first fetch failed: browsers record a failed module fetch in the module map for the page lifetime, so a fresh `import()` of the same URL rejects without a request, and package code cannot re-import under a new URL because Rollup minifies the chunk's export names. The retry therefore recovers failures after the fetch (engine instantiation, `initialize`) and hosts that version chunk URLs; a host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level. The panel with the source is always shown, never a blank. + +2. **KaTeX: a renderer slot, filled eagerly by Plannotator.** `utils/math` holds a synchronous slot (`getMathRenderer`, `setMathRenderer`, `subscribeMathRenderer`), an idempotent `loadMathRenderer()` whose default loader is `import('katex')` (JS only; stylesheet policy is unchanged, see "Math rendering"), and `setMathRendererLoader`. `MathBlock` and inline math read the slot during render: filled, they typeset synchronously in the same render exactly as before; empty, they render the same wrapper (`math-block` / `math-inline`, `math-annotatable`, `data-math-tex`, `data-math-display`, `aria-label`, `data-block-id`) with the trimmed TeX as a text child, load the renderer from an effect, and re-render typeset when it lands. Annotation restore and block targeting key on those attributes, so a placeholder is addressable exactly like the typeset node. `throwOnError: false` and `trust: false` are applied to every renderer, including one you register. + + **This is the one place the pass-nothing law bends.** A host that renders `Viewer` and never imports the eager entry now gets lazy math: one frame of TeX text, then typeset. The one-line opt-back for the old behavior: + + ```ts + import '@plannotator/ui/utils/math-eager'; + ``` + + The seam for the lazy path: `configurePlannotatorUI({ mathRendererLoader: () => Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([m]) => m.default) })` puts KaTeX and its CSS on one chunk; `loadMathRenderer()` can be awaited before mounting a body that carries math if you would rather gate first paint yourself. + +3. **Identity: a generator slot, filled eagerly by Plannotator.** `utils/generateIdentity` no longer imports `unique-username-generator`. It holds a synchronous generator slot (`setIdentityGenerator`, `getIdentityGenerator`) with a built-in fallback that produces the same `adjective-noun-tater` shape from a 16 x 16 pool. `utils/identity-tater` registers the full dictionary as a side effect and is what Plannotator's entries import. A host with `identityProvider` never calls the generator and, with the static import gone, no longer ships the word lists; delete any dictionary shim. A host that wants the full dictionary without its own provider imports `@plannotator/ui/utils/identity-tater`, or passes its own `identityGenerator` to `configurePlannotatorUI`. The slot is synchronous on purpose: `configStore` persists the first generated name to the identity cookie during the first render-time settings read, so a name that arrived later would be a visible identity change. + +4. **What did not ship (deliberately).** The raw-HTML bridge script as a separately served asset and a lazy table popout are not in this release; both are tracked in the design record for a follow-up. + +Pinned by `utils/math.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`. + +--- + ## Frozen markdown diff (0.28.0) One additive component for the Workspaces versions/approvals surface: `components/MarkdownDiff`, a theme-bridging shim over `@plannotator/markdown-editor@0.4.0`'s `MarkdownDiff` — a **frozen two-revision markdown comparison**. The newer revision renders as the real document (uncollapsed, full length); deletions are projected struck-through at their original positions; changed spans get character/word emphasis; a toolbar shows the change count with prev/next navigation; a clickable, keyboard-accessible overview rail and a changed-line gutter complete the review chrome. Every 0.27.0 surface is unchanged. diff --git a/packages/ui/README.md b/packages/ui/README.md index de90371a34..f52a0b9733 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -28,6 +28,8 @@ configurePlannotatorUI({ skillContentTransport, // human-only skill contents for feedback injection serverSync, webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix } + mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render + identityGenerator, // sync generator behind the default "tater" name (no identityProvider) }); ``` @@ -48,6 +50,16 @@ The sidebar/panel resize handle exposes seams for hosts that want different edge Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle). +### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`) + +The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots: + +- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). +- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level. +- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`. + +Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries". + ### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`) - **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps. diff --git a/packages/ui/components/DiagramBlock.lazyRetry.test.tsx b/packages/ui/components/DiagramBlock.lazyRetry.test.tsx new file mode 100644 index 0000000000..3030f94a18 --- /dev/null +++ b/packages/ui/components/DiagramBlock.lazyRetry.test.tsx @@ -0,0 +1,153 @@ +/** + * Lazy diagram runtimes must be strictly no worse than a static import on a + * chunking host: a transient failure of the Mermaid or Graphviz chunk must + * not leave the diagram dead. + * + * What regresses if these fail: + * - a rejected import() stays memoized, so every later attempt replays the + * cached rejection and the diagram can never render in that page; + * - the automatic single re-attempt is gone, so a one-off fetch hiccup lands + * the user on the error panel; + * - a persistent failure no longer shows the existing error panel with the + * source (never blank), or its Retry no longer issues a fresh attempt. + * + * The runtimes are stood in for through the test hooks so the real Mermaid + * and Graphviz never load; the rendered SVG is a sentinel string. + * + * DOM-gated (DOM_TESTS=1). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { Block } from '../types'; +import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock'; +import { GraphvizBlock, __setVizLoaderForTests } from './GraphvizBlock'; + +const hasDom = typeof document !== 'undefined'; +const RETRY_DELAY_MS = 10; +const SVG = ''; + +const mermaidBlock: Block = { id: 'm1', type: 'code', language: 'mermaid', content: 'flowchart LR\n A --> B', order: 0, startLine: 1 }; +const dotBlock: Block = { id: 'g1', type: 'code', language: 'dot', content: 'digraph { A -> B }', order: 0, startLine: 1 }; + +const fakeMermaid = { initialize() {}, render: async () => ({ svg: SVG }) } as never; +const fakeViz = { renderString: async () => SVG } as never; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +afterEach(async () => { + if (root) { + await act(async () => root!.unmount()); + root = null; + } + host?.remove(); + host = null; + __setMermaidRuntimeLoaderForTests(undefined); + __setVizLoaderForTests(undefined); +}); + +async function mount(element: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root!.render(element); + }); + return host; +} + +async function settle(ms: number): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +/** A loader that rejects `failures` times, then resolves `value`. */ +function flakyLoader(value: T, failures: number): { load: () => Promise; calls: () => number } { + let calls = 0; + return { + load: () => { + calls += 1; + return calls <= failures ? Promise.reject(new Error(`chunk failed (${calls})`)) : Promise.resolve(value); + }, + calls: () => calls, + }; +} + +const cases = [ + { + name: 'Mermaid', + install: (loader: () => Promise) => __setMermaidRuntimeLoaderForTests(loader as never, { retryDelayMs: RETRY_DELAY_MS }), + runtime: fakeMermaid, + element: , + source: mermaidBlock.content, + errorTitle: 'Mermaid Error', + }, + { + name: 'Graphviz', + install: (loader: () => Promise) => __setVizLoaderForTests(loader as never, { retryDelayMs: RETRY_DELAY_MS }), + runtime: fakeViz, + element: , + source: dotBlock.content, + errorTitle: 'Graphviz Error', + }, +]; + +describe.each(cases)('$name lazy runtime', ({ install, runtime, element, source, errorTitle }) => { + test.skipIf(!hasDom)('a runtime that fails once renders the diagram after the automatic re-attempt', async () => { + const loader = flakyLoader(runtime, 1); + install(loader.load); + const el = await mount(element); + await settle(RETRY_DELAY_MS * 5); + + expect(loader.calls()).toBe(2); + expect(el.innerHTML).toContain('data-sentinel="diagram"'); + expect(el.textContent).not.toContain(errorTitle); + }); + + test.skipIf(!hasDom)('a persistently failing runtime shows the error panel with the source, and Retry issues a fresh attempt', async () => { + let healthy = false; + let calls = 0; + install(() => { + calls += 1; + return healthy ? Promise.resolve(runtime) : Promise.reject(new Error('chunk failed')); + }); + const el = await mount(element); + await settle(RETRY_DELAY_MS * 5); + + // Two attempts (initial + automatic re-attempt), then the existing panel. + expect(calls).toBe(2); + expect(el.textContent).toContain(errorTitle); + expect(el.textContent).toContain('chunk failed'); + expect(el.textContent).toContain(source); + expect(el.innerHTML).not.toContain('data-sentinel="diagram"'); + + const retry = el.querySelector('button[title="Retry loading the diagram renderer"]'); + expect(retry).not.toBeNull(); + + healthy = true; + await act(async () => { + retry!.click(); + }); + await settle(RETRY_DELAY_MS * 5); + + expect(calls).toBe(3); + expect(el.innerHTML).toContain('data-sentinel="diagram"'); + expect(el.textContent).not.toContain(errorTitle); + }); + + test.skipIf(!hasDom)('a diagram syntax error keeps the existing panel without a Retry button', async () => { + const broken = { initialize() {}, render: async () => { throw new Error('Parse error'); }, renderString: async () => { throw new Error('Parse error'); } } as never; + let calls = 0; + install(() => { calls += 1; return Promise.resolve(broken); }); + const el = await mount(element); + await settle(RETRY_DELAY_MS * 5); + + expect(calls).toBe(1); + expect(el.textContent).toContain(errorTitle); + expect(el.textContent).toContain('Parse error'); + expect(el.textContent).toContain(source); + expect(el.querySelector('button[title="Retry loading the diagram renderer"]')).toBeNull(); + }); +}); diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index 1752fbb0ad..073365c7f1 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { instance } from '@viz-js/viz'; +import type { Viz } from '@viz-js/viz'; import type { Block } from '../types'; interface ViewBox { @@ -14,13 +14,52 @@ const ZOOM_STEP = 0.25; const MIN_ZOOM = 0.25; const MAX_ZOOM = 8; -let vizInstancePromise: ReturnType | null = null; - -function getVizInstance() { - vizInstancePromise ??= instance(); +/** + * The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the + * render effect, not statically, so a host that bundles by route only fetches + * it when a dot fence is on the page. WASM instantiation was already deferred + * to first render; in Plannotator's single-file builds the import is inlined + * and resolves in a microtask ahead of a render that was already asynchronous. + */ +const loadVizInstance = (): Promise => import('@viz-js/viz').then((m) => m.instance()); + +let vizLoader = loadVizInstance; +let vizInstancePromise: Promise | null = null; + +/** + * Delay before the one automatic re-attempt after a failed engine import. + * Only chunking hosts can fail here (a single-file build never fetches). + */ +let runtimeRetryDelayMs = 750; + +/** + * Memoized engine. A rejected load is dropped from the memo so the next call + * (the automatic re-attempt, a later mount, or the Retry button) issues a + * fresh import() instead of replaying the cached rejection. + */ +function getVizInstance(): Promise { + if (!vizInstancePromise) { + const attempt = vizLoader().catch((err: unknown) => { + if (vizInstancePromise === attempt) vizInstancePromise = null; + throw err; + }); + vizInstancePromise = attempt; + } return vizInstancePromise; } +/** Test hook: stand in for the engine import and shorten the retry delay. */ +export function __setVizLoaderForTests( + loader: (() => Promise) | undefined, + options?: { retryDelayMs?: number }, +): void { + vizLoader = loader ?? loadVizInstance; + vizInstancePromise = null; + runtimeRetryDelayMs = options?.retryDelayMs ?? 750; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + function parseViewBox(svgEl: SVGSVGElement): ViewBox | null { const raw = svgEl.getAttribute('viewBox'); if (!raw) return null; @@ -107,6 +146,11 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { const containerRef = useRef(null); const [svg, setSvg] = useState(''); const [error, setError] = useState(null); + // True when the failure was the engine import itself (a chunking host's + // fetch), which is the only failure a Retry can change; a dot syntax error + // keeps the panel exactly as it always was. + const [runtimeUnavailable, setRuntimeUnavailable] = useState(false); + const [retryToken, setRetryToken] = useState(0); const [showSource, setShowSource] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -158,8 +202,28 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { let cancelled = false; const renderDiagram = async () => { + let viz: Viz; + try { + try { + viz = await getVizInstance(); + } catch { + // Transient chunk failure on a chunking host: one automatic + // re-attempt with a fresh import() after a short delay. In a + // single-file build the first await never rejects, so this branch + // is unreachable there and the success path is unchanged. + await wait(runtimeRetryDelayMs); + if (cancelled) return; + viz = await getVizInstance(); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to render diagram'); + setRuntimeUnavailable(true); + setSvg(''); + } + return; + } try { - const viz = await getVizInstance(); const renderedSvg = await viz.renderString(block.content, { format: 'svg' }); const cleaned = renderedSvg .replace(/ width="[^"]*"/, ' width="100%"') @@ -177,10 +241,12 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); setSvg(cleaned); setError(null); + setRuntimeUnavailable(false); } } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : 'Failed to render diagram'); + setRuntimeUnavailable(false); setSvg(''); } } @@ -191,7 +257,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { return () => { cancelled = true; }; - }, [block.content]); + }, [block.content, retryToken]); useEffect(() => { zoomLevelRef.current = 1; @@ -362,6 +428,19 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { Graphviz Error + {runtimeUnavailable && ( + + )}
{error}
diff --git a/packages/ui/components/InlineMarkdown.test.ts b/packages/ui/components/InlineMarkdown.test.ts
index a22354a571..d9d76284d5 100644
--- a/packages/ui/components/InlineMarkdown.test.ts
+++ b/packages/ui/components/InlineMarkdown.test.ts
@@ -1,7 +1,9 @@
-import { describe, test, expect } from 'bun:test';
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
 import { createElement } from 'react';
 import { renderToStaticMarkup } from 'react-dom/server';
+import katex from 'katex';
 import { InlineMarkdown, trimUrlTail } from './InlineMarkdown';
+import { getMathRenderer, getMathRendererSource, resetMathRenderer, setMathRenderer } from '../utils/math';
 
 function renderInline(text: string): string {
   return renderToStaticMarkup(createElement(InlineMarkdown, { text }));
@@ -101,6 +103,27 @@ describe('InlineMarkdown angle autolinks', () => {
 });
 
 describe('InlineMarkdown math', () => {
+  // KaTeX reaches InlineMath through the renderer slot now (utils/math.ts);
+  // Plannotator's apps fill it by importing utils/math-eager. Register it
+  // here explicitly so these assertions do not depend on which test file ran
+  // first in bun's shared process, and restore whatever was there after.
+  const saved = getMathRenderer();
+  const savedSource = getMathRendererSource();
+  beforeEach(() => setMathRenderer(katex));
+  afterEach(() => {
+    resetMathRenderer();
+    if (saved) setMathRenderer(saved, savedSource ?? 'host');
+  });
+
+  test('renders the TeX as text in the same wrapper while no renderer is registered', () => {
+    resetMathRenderer();
+    const html = renderToStaticMarkup(createElement(InlineMarkdown, { text: 'Area is $A=\\pi r^2$.' }));
+    expect(html).not.toContain('katex');
+    expect(html).toContain('math-inline');
+    expect(html).toContain('data-math-tex="A=\\pi r^2"');
+    expect(html).toContain('>A=\\pi r^2');
+  });
+
   test('renders inline math with KaTeX markup', () => {
     const html = renderToStaticMarkup(createElement(InlineMarkdown, { text: 'Area is $A=\\pi r^2$.' }));
     expect(html).toContain('katex');
diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx
index e27c202474..7123b7b7f1 100644
--- a/packages/ui/components/InlineMarkdown.tsx
+++ b/packages/ui/components/InlineMarkdown.tsx
@@ -9,7 +9,8 @@ import { getImageSrc } from "./ImageThumbnail";
 import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
 import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
 import { CodeFilePicker } from "./CodeFilePicker";
-import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock";
+import { normalizeMathTex, renderMathToHtml } from "../utils/math";
+import { useMathRenderer } from "../hooks/useMathRenderer";
 
 export interface DocPreviewResult {
   contents?: string;
@@ -312,7 +313,23 @@ const CodeFileIcon = () => (
 
 const InlineMath: React.FC<{ tex: string }> = ({ tex }) => {
   const normalizedTex = normalizeMathTex(tex);
-  const html = useMemo(() => renderMathToHtml(normalizedTex, false), [normalizedTex]);
+  const renderer = useMathRenderer();
+  const html = useMemo(() => renderMathToHtml(normalizedTex, false, renderer), [normalizedTex, renderer]);
+
+  // Same wrapper in both branches (see MathBlock): the placeholder carries the
+  // attributes annotation restore keys on, with the TeX as a text child.
+  if (html === null) {
+    return (
+      
+        {normalizedTex}
+      
+    );
+  }
 
   return (
      {
+  if (root) {
+    await act(async () => root!.unmount());
+    root = null;
+  }
+  host?.remove();
+  host = null;
+  resetMathRenderer();
+  if (savedRenderer) setMathRenderer(savedRenderer, savedSource ?? 'host');
+});
+
+function mount(): { html: string; mutations: MutationRecord[] } {
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  const mutations: MutationRecord[] = [];
+  // Observe before the root mounts so the initial commit itself is recorded;
+  // the first record is expected (the mount), everything after it is a swap.
+  const observer = new MutationObserver((records) => mutations.push(...records));
+  observer.observe(host, { childList: true, subtree: true, characterData: true, attributes: true });
+  root = createRoot(host);
+  act(() => {
+    root!.render();
+  });
+  const html = host.innerHTML;
+  return { html, mutations };
+}
+
+describe('MathBlock first paint', () => {
+  test.skipIf(!hasDom)('with the eager entry imported, the first commit is typeset KaTeX and nothing swaps afterwards', async () => {
+    expect(getMathRenderer()).toBe(katex);
+    const { html, mutations } = mount();
+
+    expect(html).toContain('katex-display');
+    expect(html).not.toContain('>\\int_0^1 x^2 dx');
+    expect(host!.querySelector('[data-math-tex]')?.getAttribute('data-math-tex')).toBe('\\int_0^1 x^2 dx');
+
+    // Let effects, microtasks and any pending re-render settle, then compare.
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+    const mountRecords = mutations.filter((m) => m.target === host && m.type === 'childList');
+    const laterRecords = mutations.filter((m) => !(m.target === host && m.type === 'childList'));
+    expect(mountRecords.length).toBeGreaterThanOrEqual(1);
+    expect(laterRecords).toHaveLength(0);
+    expect(host!.innerHTML).toBe(html);
+  });
+
+  test.skipIf(!hasDom)('without registration the placeholder commits first and typesets once the renderer lands', async () => {
+    resetMathRenderer();
+    const { html } = mount();
+
+    expect(html).not.toContain('katex');
+    expect(html).toContain('>\\int_0^1 x^2 dx');
+    expect(host!.querySelector('[data-math-tex]')?.getAttribute('data-block-id')).toBe('math-1');
+
+    await act(async () => {
+      setMathRenderer(katex);
+    });
+    expect(host!.innerHTML).toContain('katex-display');
+    expect(host!.innerHTML).not.toContain('>\\int_0^1 x^2 dx');
+  });
+});
diff --git a/packages/ui/components/MermaidBlock.test.ts b/packages/ui/components/MermaidBlock.test.ts
index 87d5d0d1b7..fbb23be6c6 100644
--- a/packages/ui/components/MermaidBlock.test.ts
+++ b/packages/ui/components/MermaidBlock.test.ts
@@ -1,5 +1,16 @@
 import { describe, expect, test } from 'bun:test';
 import { normalizeMermaidSvgMarkup } from './mermaidSvg';
+import { MERMAID_CONFIG } from './MermaidBlock';
+
+describe('MERMAID_CONFIG', () => {
+  // Deliberate security pin: the config is applied on first diagram (the
+  // runtime is imported lazily), so a drift here would only show up in the
+  // browser as arbitrary HTML/JS in a document-supplied diagram.
+  test('keeps securityLevel strict and startOnLoad off', () => {
+    expect(MERMAID_CONFIG.securityLevel).toBe('strict');
+    expect(MERMAID_CONFIG.startOnLoad).toBe(false);
+  });
+});
 
 describe('normalizeMermaidSvgMarkup', () => {
   test('replaces natural max-width with max-width:none', () => {
diff --git a/packages/ui/components/MermaidBlock.tsx b/packages/ui/components/MermaidBlock.tsx
index a896dbf666..9d69b46fa4 100644
--- a/packages/ui/components/MermaidBlock.tsx
+++ b/packages/ui/components/MermaidBlock.tsx
@@ -1,33 +1,26 @@
 import React, { useRef, useState, useEffect, useCallback } from 'react';
 import { createPortal } from 'react-dom';
-import mermaid from 'mermaid';
+import type { Mermaid } from 'mermaid';
 import type { Block } from '../types';
 import { normalizeMermaidSvgMarkup } from './mermaidSvg';
+import {
+  MERMAID_CONFIG,
+  getMermaidRetryDelayMs,
+  loadMermaidRuntime,
+  __setMermaidRuntimeLoaderForTests,
+} from '../utils/mermaid';
 
-mermaid.initialize({
-  startOnLoad: false,
-  securityLevel: 'strict',
-  theme: 'dark',
-  themeVariables: {
-    primaryColor: '#3b82f6',
-    primaryTextColor: '#f8fafc',
-    primaryBorderColor: '#475569',
-    lineColor: '#64748b',
-    secondaryColor: '#1e293b',
-    tertiaryColor: '#0f172a',
-    background: '#1e293b',
-    mainBkg: '#1e293b',
-    nodeBorder: '#475569',
-    clusterBkg: '#1e293b',
-    clusterBorder: '#475569',
-    titleColor: '#f8fafc',
-    edgeLabelBackground: '#1e293b',
-  },
-  flowchart: {
-    htmlLabels: true,
-    curve: 'basis',
-  },
-});
+// Re-exported: the config pin test and the lazy-retry test import them from here.
+export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
+
+/**
+ * The runtime comes from the slot in utils/mermaid: filled eagerly by
+ * Plannotator (utils/mermaid-eager, imported by the editor App), loaded
+ * lazily otherwise. See that module for the retry contract.
+ */
+const getMermaid = loadMermaidRuntime;
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
 interface ViewBox {
   x: number;
@@ -133,6 +126,11 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
   const expandedOverlayRef = useRef(null);
   const [svg, setSvg] = useState('');
   const [error, setError] = useState(null);
+  // True when the failure was the runtime import itself (a chunking host's
+  // fetch), which is the only failure a Retry can change; a diagram syntax
+  // error keeps the panel exactly as it always was.
+  const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
+  const [retryToken, setRetryToken] = useState(0);
   const [showSource, setShowSource] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
@@ -188,6 +186,27 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
 
     // Render mermaid diagram
     const renderDiagram = async () => {
+      let mermaid: Mermaid;
+      try {
+        try {
+          mermaid = await getMermaid();
+        } catch {
+          // Transient chunk failure on a chunking host: one automatic
+          // re-attempt with a fresh import() after a short delay. In a
+          // single-file build the first await never rejects, so this branch
+          // is unreachable there and the success path is unchanged.
+          await wait(getMermaidRetryDelayMs());
+          if (cancelled) return;
+          mermaid = await getMermaid();
+        }
+      } catch (err) {
+        if (!cancelled) {
+          setError(err instanceof Error ? err.message : 'Failed to render diagram');
+          setRuntimeUnavailable(true);
+          setSvg('');
+        }
+        return;
+      }
       try {
         const id = `mermaid-${block.id}`;
         const { svg: renderedSvg } = await mermaid.render(id, block.content);
@@ -196,10 +215,12 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
           naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
           setSvg(normalizedSvg);
           setError(null);
+          setRuntimeUnavailable(false);
         }
       } catch (err) {
         if (!cancelled) {
           setError(err instanceof Error ? err.message : 'Failed to render diagram');
+          setRuntimeUnavailable(false);
           setSvg('');
         }
       }
@@ -210,7 +231,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
     return () => {
       cancelled = true;
     };
-  }, [block.content, block.id]);
+  }, [block.content, block.id, retryToken]);
 
   // Reset zoom and pan when content changes
   useEffect(() => {
@@ -419,6 +440,19 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
             
           
           Mermaid Error
+          {runtimeUnavailable && (
+            
+          )}
         
         
{error}
diff --git a/packages/ui/components/blocks/MathBlock.tsx b/packages/ui/components/blocks/MathBlock.tsx
index 0f4d75827c..eb8884f864 100644
--- a/packages/ui/components/blocks/MathBlock.tsx
+++ b/packages/ui/components/blocks/MathBlock.tsx
@@ -1,26 +1,38 @@
 import React, { useMemo } from 'react';
-import katex from 'katex';
 import type { Block } from '../../types';
+import { normalizeMathTex, renderMathToHtml } from '../../utils/math';
+import { useMathRenderer } from '../../hooks/useMathRenderer';
+
+// Kept as re-exports: InlineMarkdown and older consumers import them from here.
+export { normalizeMathTex, renderMathToHtml };
 
 type MathBlockProps = {
   block: Block;
 };
 
-export const normalizeMathTex = (tex: string): string => tex.trim();
-
-export const renderMathToHtml = (tex: string, displayMode: boolean): string => (
-  katex.renderToString(tex, {
-    displayMode,
-    throwOnError: false,
-    strict: 'warn',
-    trust: false,
-    output: 'html',
-  })
-);
-
 export const MathBlock: React.FC = ({ block }) => {
   const tex = normalizeMathTex(block.content);
-  const html = useMemo(() => renderMathToHtml(tex, true), [tex]);
+  const renderer = useMathRenderer();
+  const html = useMemo(() => renderMathToHtml(tex, true, renderer), [tex, renderer]);
+
+  // The wrapper (class names, data-* anchors, aria-label) is identical in both
+  // branches: block targeting and math-annotation restore key on these
+  // attributes, so a placeholder must be addressable exactly like the typeset
+  // block. The placeholder's child is a React text node, never markup.
+  if (html === null) {
+    return (
+      
+ {tex} +
+ ); + } return (
{ + if (renderer) return; + loadMathRenderer().catch(() => { + /* placeholder stays; the next mount retries */ + }); + }, [renderer]); + + return renderer; +} diff --git a/packages/ui/utils/generateIdentity.test.ts b/packages/ui/utils/generateIdentity.test.ts new file mode 100644 index 0000000000..3fb84f8407 --- /dev/null +++ b/packages/ui/utils/generateIdentity.test.ts @@ -0,0 +1,75 @@ +/** + * Identity generator slot (utils/generateIdentity.ts) and its eager entry. + * + * What regresses if these fail: + * - the built-in fallback stops producing the `adjective-noun-tater` shape + * synchronously (configStore persists the first name to the cookie during + * the first render-time settings read, so an async or malformed name would + * be a visible identity change); + * - importing `identity-tater` no longer registers the full dictionary, which + * would silently move Plannotator's names onto the 16 x 16 pool; + * - `configurePlannotatorUI({ identityGenerator })` is ignored. + * + * Bun shares one process across files: the generator is restored after each + * test so files that rely on the dictionary keep it. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + FALLBACK_IDENTITY_POOL, + fallbackIdentityGenerator, + generateIdentity, + getIdentityGenerator, + resetIdentityGenerator, + setIdentityGenerator, +} from './generateIdentity'; +import { generateTaterIdentity } from './identity-tater'; +import { configurePlannotatorUI } from '../configure'; + +const TATER = /^[a-z]+(?:-[a-z]+)*-[a-z]+(?:-[a-z]+)*-tater$/; +const saved = getIdentityGenerator(); + +afterEach(() => { + setIdentityGenerator(saved); +}); + +describe('fallback generator', () => { + test('produces the tater shape from the built-in pool without any registration', () => { + resetIdentityGenerator(); + for (let i = 0; i < 32; i += 1) { + const name = generateIdentity(); + expect(name).toMatch(/^[a-z]+-[a-z]+-tater$/); + const [adjective, noun] = name.split('-'); + expect(FALLBACK_IDENTITY_POOL.adjectives).toContain(adjective); + expect(FALLBACK_IDENTITY_POOL.nouns).toContain(noun); + } + expect(getIdentityGenerator()).toBe(fallbackIdentityGenerator); + }); +}); + +describe('identity-tater eager entry', () => { + test('importing it registers the full dictionary generator', () => { + // The import at the top of this file already ran the side effect once; + // re-registering here makes the assertion independent of test order. + setIdentityGenerator(generateTaterIdentity); + expect(getIdentityGenerator()).toBe(generateTaterIdentity); + expect(generateIdentity()).toMatch(TATER); + }); + + test('the dictionary generator draws from beyond the fallback pool', () => { + // 1,400+ adjectives against a 16-word pool: 64 draws all inside the pool + // has probability well under 1e-100, so this is deterministic in practice. + const pool = new Set(FALLBACK_IDENTITY_POOL.adjectives); + const outside = Array.from({ length: 64 }, () => generateTaterIdentity()).some((name) => { + const adjective = name.slice(0, name.indexOf('-')); + return !pool.has(adjective); + }); + expect(outside).toBe(true); + }); +}); + +describe('configurePlannotatorUI({ identityGenerator })', () => { + test('installs the host generator', () => { + configurePlannotatorUI({ identityGenerator: () => 'host-picked-tater' }); + expect(generateIdentity()).toBe('host-picked-tater'); + }); +}); diff --git a/packages/ui/utils/generateIdentity.ts b/packages/ui/utils/generateIdentity.ts index 695a6e2f06..fbe14f7912 100644 --- a/packages/ui/utils/generateIdentity.ts +++ b/packages/ui/utils/generateIdentity.ts @@ -1,12 +1,73 @@ /** - * Tater name generator — pure function, no storage dependencies. + * Tater name generator: pure function, no storage dependencies. * * Extracted to its own module to avoid circular imports: * settings.ts needs this for the default value, and identity.ts * needs configStore (which imports settings.ts). + * + * The full `unique-username-generator` dictionary is NOT imported here. It is + * registered into the slot below by `./identity-tater`, which every + * Plannotator entry imports eagerly, so Plannotator mints names from the + * full dictionary exactly as before. A host that provides its own + * `identityProvider` never calls the generator and, with the static import + * gone, no longer ships the word lists. + * + * The slot is SYNCHRONOUS on purpose: `configStore.ensureLoaded()` evaluates + * this default during the first settings read (a render-time read) and + * persists the result to the identity cookie at once, so a name that arrived + * later would be a visible identity change. The built-in fallback below keeps + * the same `{adjective}-{noun}-tater` shape from a small inline pool. */ -import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator'; +export type IdentityGenerator = () => string; + +const FALLBACK_ADJECTIVES = [ + 'swift', 'gentle', 'brave', 'calm', 'clever', 'bright', 'quiet', 'bold', + 'eager', 'kind', 'lucky', 'merry', 'nimble', 'proud', 'sunny', 'witty', +] as const; + +const FALLBACK_NOUNS = [ + 'falcon', 'crystal', 'river', 'meadow', 'harbor', 'comet', 'maple', 'otter', + 'summit', 'lantern', 'willow', 'ember', 'pebble', 'breeze', 'orchid', 'canyon', +] as const; + +/** Pool words: exported for tests only. */ +export const FALLBACK_IDENTITY_POOL: { + readonly adjectives: readonly string[]; + readonly nouns: readonly string[]; +} = { + adjectives: FALLBACK_ADJECTIVES, + nouns: FALLBACK_NOUNS, +}; + +function pick(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]!; +} + +/** Built-in generator: same shape as the dictionary one, from a 16 x 16 pool. */ +export const fallbackIdentityGenerator: IdentityGenerator = () => + `${pick(FALLBACK_ADJECTIVES)}-${pick(FALLBACK_NOUNS)}-tater`; + +let generator: IdentityGenerator = fallbackIdentityGenerator; + +/** + * Register the generator `generateIdentity()` delegates to. Must return a + * string synchronously. `./identity-tater` registers the full dictionary; + * a host may register its own via `configurePlannotatorUI({ identityGenerator })`. + */ +export function setIdentityGenerator(next: IdentityGenerator): void { + generator = next; +} + +/** The active generator. Exported so a test can assert which one is registered. */ +export function getIdentityGenerator(): IdentityGenerator { + return generator; +} + +/** Reset to the built-in fallback pool. Mainly for tests. */ +export function resetIdentityGenerator(): void { + generator = fallbackIdentityGenerator; +} /** * Generate a new random tater identity. @@ -14,16 +75,5 @@ import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-gene * Examples: "swift-falcon-tater", "gentle-crystal-tater" */ export function generateIdentity(): string { - // Use a unique separator to split adjective from noun, avoiding issues - // with compound words that contain hyphens (e.g., "behind-the-scenes") - const generated = uniqueUsernameGenerator({ - dictionaries: [adjectives, nouns], - separator: '|||', - style: 'lowerCase', - randomDigits: 0, - length: 50, // Prevent word truncation (default is too short) - }); - - const [adjective, noun] = generated.split('|||'); - return `${adjective}-${noun}-tater`; + return generator(); } diff --git a/packages/ui/utils/identity-tater.ts b/packages/ui/utils/identity-tater.ts new file mode 100644 index 0000000000..29742d426a --- /dev/null +++ b/packages/ui/utils/identity-tater.ts @@ -0,0 +1,36 @@ +/** + * Eager identity registration: installs the full `unique-username-generator` + * dictionary into the generator slot in `./generateIdentity` at module + * evaluation, before any settings read can mint a name. + * + * Every Plannotator entry (`packages/editor/App.tsx`, `packages/review-editor/App.tsx`) + * imports this module for its side effect, which keeps Plannotator's tater + * names byte-identical to before: same library, same config, same call. A host + * that wants the full dictionary without its own identity provider imports it + * too: + * + * import '@plannotator/ui/utils/identity-tater'; + * + * A host that provides `identityProvider` never calls the generator and should + * NOT import this, so the word lists stay out of its bundle. + */ +import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator'; +import { setIdentityGenerator, type IdentityGenerator } from './generateIdentity'; + +/** The dictionary generator Plannotator has always used. */ +export const generateTaterIdentity: IdentityGenerator = () => { + // Use a unique separator to split adjective from noun, avoiding issues + // with compound words that contain hyphens (e.g., "behind-the-scenes") + const generated = uniqueUsernameGenerator({ + dictionaries: [adjectives, nouns], + separator: '|||', + style: 'lowerCase', + randomDigits: 0, + length: 50, // Prevent word truncation (default is too short) + }); + + const [adjective, noun] = generated.split('|||'); + return `${adjective}-${noun}-tater`; +}; + +setIdentityGenerator(generateTaterIdentity); diff --git a/packages/ui/utils/math-eager.ts b/packages/ui/utils/math-eager.ts new file mode 100644 index 0000000000..e44cf9f908 --- /dev/null +++ b/packages/ui/utils/math-eager.ts @@ -0,0 +1,25 @@ +/** + * Eager math registration: fills the renderer slot in `./math` with KaTeX at + * module evaluation, before any component renders. + * + * Every Plannotator entry (`packages/editor/App.tsx`, `packages/review-editor/App.tsx`; + * the hook, review, portal, OpenCode and Pi builds all flow from those two) + * imports this module for its side effect, which is what keeps math typeset + * on the first commit exactly as it was with a static `katex` import. A host + * that wants the same synchronous behavior imports it too: + * + * import '@plannotator/ui/utils/math-eager'; + * + * A host that does not import it gets lazy math: the TeX source in the same + * wrapper for one frame, then the typeset markup once the chunk lands. + * + * The source tag passed below doubles as a build marker: the literal only + * reaches a bundle when this module is evaluated in it, so a dropped or + * tree-shaken side-effect import is caught by the built-HTML check in + * tests/entry-assets.test.ts (KaTeX itself stays inlined either way through + * the loader's import(), so a KaTeX class name cannot prove registration). + */ +import katex from 'katex'; +import { setMathRenderer } from './math'; + +setMathRenderer(katex, 'plannotator-math-eager'); diff --git a/packages/ui/utils/math.test.ts b/packages/ui/utils/math.test.ts new file mode 100644 index 0000000000..f8624c765e --- /dev/null +++ b/packages/ui/utils/math.test.ts @@ -0,0 +1,193 @@ +/** + * Math renderer slot (utils/math.ts). + * + * What regresses if these fail: + * - the empty slot no longer renders an addressable placeholder (annotation + * restore and block targeting key on the wrapper attributes), or leaks the + * TeX as markup instead of text; + * - a filled slot no longer renders KaTeX SYNCHRONOUSLY in the same render + * (this is the whole basis of Plannotator's byte-identical first paint); + * - the loader stops being idempotent or stops retrying after a rejection; + * - the host loader seam is ignored; + * - the security pin (`trust: false`, `throwOnError: false`) stops being applied + * to a host-registered renderer. + * + * No DOM required: everything renders through `renderToStaticMarkup`, which + * is a single synchronous render pass (no effects), so "in the same render" + * is exactly what it measures. + * + * Bun runs every test file in one process, so the slot is saved on entry and + * restored after each test: other files (InlineMarkdown.test.ts, the DOM + * first-paint test) rely on the registration they set up themselves. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import katex from 'katex'; +import { + getMathRenderer, + getMathRendererSource, + loadMathRenderer, + renderMathToHtml, + resetMathRenderer, + setMathRenderer, + setMathRendererLoader, + subscribeMathRenderer, + type MathRenderer, +} from './math'; +import { MathBlock } from '../components/blocks/MathBlock'; +import { InlineMarkdown } from '../components/InlineMarkdown'; +import type { Block } from '../types'; + +const savedRenderer = getMathRenderer(); +const savedSource = getMathRendererSource(); + +const displayBlock: Block = { + id: 'math-1', + type: 'math', + content: ' E = mc^2 ', + order: 0, + startLine: 1, +}; + +beforeEach(() => { + resetMathRenderer(); +}); + +afterEach(() => { + resetMathRenderer(); + if (savedRenderer) setMathRenderer(savedRenderer, savedSource ?? 'host'); +}); + +describe('empty slot', () => { + test('MathBlock renders the wrapper with the TeX as text and the full anchor attribute set', () => { + const html = renderToStaticMarkup(createElement(MathBlock, { block: displayBlock })); + expect(html).not.toContain('katex'); + expect(html).toContain('class="math-block math-annotatable'); + expect(html).toContain('data-block-id="math-1"'); + expect(html).toContain('data-block-type="math"'); + expect(html).toContain('data-math-tex="E = mc^2"'); + expect(html).toContain('data-math-display="true"'); + expect(html).toContain('aria-label="E = mc^2"'); + expect(html).toContain('>E = mc^2
'); + }); + + test('inline math renders the wrapper with the TeX as text and the same attributes', () => { + const html = renderToStaticMarkup(createElement(InlineMarkdown, { text: 'Area is $A=\\pi r^2$.' })); + expect(html).not.toContain('katex'); + expect(html).toContain('class="math-inline math-annotatable'); + expect(html).toContain('data-math-tex="A=\\pi r^2"'); + expect(html).toContain('data-math-display="false"'); + expect(html).toContain('>A=\\pi r^2'); + }); + + test('placeholder TeX is a text child, never markup', () => { + const hostile: Block = { ...displayBlock, content: '' }; + const html = renderToStaticMarkup(createElement(MathBlock, { block: hostile })); + expect(html).not.toContain(' { + test('renders KaTeX markup in the same synchronous render, no effect needed', () => { + setMathRenderer(katex); + const html = renderToStaticMarkup(createElement(MathBlock, { block: displayBlock })); + expect(html).toContain('katex-display'); + expect(html).toContain('data-math-tex="E = mc^2"'); + expect(html).not.toContain('>E = mc^2'); + }); + + test('registering notifies subscribers exactly once per change', () => { + let calls = 0; + const unsubscribe = subscribeMathRenderer(() => { + calls += 1; + }); + setMathRenderer(katex); + setMathRenderer(katex); // same renderer: no notification + expect(calls).toBe(1); + unsubscribe(); + resetMathRenderer(); + expect(calls).toBe(1); + }); +}); + +describe('loadMathRenderer', () => { + test('resolves at once from a filled slot without calling the loader', async () => { + let loads = 0; + setMathRendererLoader(async () => { + loads += 1; + return katex; + }); + setMathRenderer(katex); + expect(await loadMathRenderer()).toBe(katex); + expect(loads).toBe(0); + }); + + test('is idempotent: concurrent calls share one load, later calls reuse the result', async () => { + let loads = 0; + setMathRendererLoader(async () => { + loads += 1; + return katex; + }); + const [a, b] = await Promise.all([loadMathRenderer(), loadMathRenderer()]); + expect(a).toBe(katex); + expect(b).toBe(katex); + expect(getMathRenderer()).toBe(katex); + expect(getMathRendererSource()).toBe('loader'); + await loadMathRenderer(); + expect(loads).toBe(1); + }); + + test('a rejected load is dropped so the next call retries', async () => { + let attempts = 0; + setMathRendererLoader(async () => { + attempts += 1; + if (attempts === 1) throw new Error('chunk failed'); + return katex; + }); + await expect(loadMathRenderer()).rejects.toThrow('chunk failed'); + expect(getMathRenderer()).toBeNull(); + expect(await loadMathRenderer()).toBe(katex); + expect(attempts).toBe(2); + }); + + test('honors the host loader seam and registers what it returns', async () => { + const hostRenderer: MathRenderer = { + renderToString: (tex) => `${tex}`, + }; + setMathRendererLoader(async () => hostRenderer); + await loadMathRenderer(); + expect(getMathRenderer()).toBe(hostRenderer); + const html = renderToStaticMarkup(createElement(MathBlock, { block: displayBlock })); + expect(html).toContain('E = mc^2'); + }); +}); + +describe('renderMathToHtml', () => { + test('returns null while no renderer is registered', () => { + expect(renderMathToHtml('x', false)).toBeNull(); + }); + + // Deliberate security pin: `trust: false` and `throwOnError: false` are + // applied to EVERY renderer, including one a host registered, so document + // TeX can never widen what the renderer may emit. + test('pins trust:false and throwOnError:false regardless of the registered renderer', () => { + const seen: unknown[] = []; + const spy: MathRenderer = { + renderToString: (_tex, options) => { + seen.push(options); + return ''; + }, + }; + setMathRenderer(spy); + renderMathToHtml('x', true); + renderMathToHtml('y', false, spy); + expect(seen).toHaveLength(2); + for (const options of seen) { + expect(options).toMatchObject({ trust: false, throwOnError: false, output: 'html' }); + } + expect(seen[0]).toMatchObject({ displayMode: true }); + expect(seen[1]).toMatchObject({ displayMode: false }); + }); +}); diff --git a/packages/ui/utils/math.ts b/packages/ui/utils/math.ts new file mode 100644 index 0000000000..20ab6f8e69 --- /dev/null +++ b/packages/ui/utils/math.ts @@ -0,0 +1,146 @@ +/** + * Math renderer slot. + * + * `MathBlock` and inline math read their renderer from this module instead of + * importing `katex` statically, so a host that bundles by route does not carry + * KaTeX in every document read. The slot is SYNCHRONOUS: when it is filled + * before the first render (which is what `./math-eager` does, and what every + * Plannotator entry imports) the typeset HTML is in the DOM on the first + * commit, exactly as it was when the import was static. When it is empty the + * components render the same wrapper element with the TeX source as text, + * call `loadMathRenderer()`, and re-render typeset once it resolves. + * + * This module deliberately has NO runtime import of `katex`: the only place + * the dependency is named is the default loader's `import('katex')`, which a + * chunking bundler turns into a lazy chunk and Plannotator's single-file + * builds inline (the eager entry keeps it in the entry either way). + */ + +import type { KatexOptions } from 'katex'; + +/** The subset of KaTeX's API the renderer needs. `katex` itself satisfies it. */ +export interface MathRenderer { + renderToString(tex: string, options?: KatexOptions): string; +} + +export type MathRendererLoader = () => Promise; + +/** + * Default loader: KaTeX's JS only. The stylesheet is deliberately NOT imported + * here; CSS loading stays the host's job (see HANDOFF.md "Math rendering"), + * and a host that already serves `katex.min.css` would otherwise load it twice. + */ +const defaultMathRendererLoader: MathRendererLoader = () => import('katex').then((m) => m.default); + +/** + * Who filled the slot: the eager entry (`./math-eager`), the lazy loader, or a + * host calling `setMathRenderer` directly. Diagnostic for a host chasing a TeX + * flash, and the eager value is a build marker: it only reaches a bundle when + * `./math-eager` is evaluated, which is what `tests/entry-assets.test.ts` + * asserts on the built single-file HTML. + */ +export type MathRendererSource = 'plannotator-math-eager' | 'loader' | 'host'; + +let renderer: MathRenderer | null = null; +let rendererSource: MathRendererSource | null = null; +let loader: MathRendererLoader = defaultMathRendererLoader; +let pending: Promise | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + for (const listener of listeners) listener(); +} + +/** Current renderer, or `null` while none is registered. Safe to call during render. */ +export function getMathRenderer(): MathRenderer | null { + return renderer; +} + +/** How the current renderer was registered, or `null` while the slot is empty. */ +export function getMathRendererSource(): MathRendererSource | null { + return rendererSource; +} + +/** Register a renderer synchronously (what `./math-eager` does with `katex`). */ +export function setMathRenderer(next: MathRenderer, source: MathRendererSource = 'host'): void { + if (renderer === next) return; + renderer = next; + rendererSource = source; + notify(); +} + +/** Subscribe to slot changes. Shaped for `useSyncExternalStore`. */ +export function subscribeMathRenderer(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Swap the loader `loadMathRenderer()` uses. Host seam + * (`configurePlannotatorUI({ mathRendererLoader })`): a host may return a + * module that imports katex AND its stylesheet in one chunk. A load already in + * flight keeps going; the new loader is used from the next `loadMathRenderer()` + * call that finds the slot empty. + */ +export function setMathRendererLoader(next: MathRendererLoader): void { + loader = next; + pending = null; +} + +/** + * Load and register the renderer. Idempotent: a filled slot resolves at once, + * a load in flight is shared, and a rejected load is dropped so the next call + * retries instead of failing forever on a transient chunk error. + */ +export function loadMathRenderer(): Promise { + if (renderer) return Promise.resolve(renderer); + if (!pending) { + const attempt = loader().then( + (loaded) => { + setMathRenderer(loaded, 'loader'); + return loaded; + }, + (err: unknown) => { + if (pending === attempt) pending = null; + throw err; + }, + ); + pending = attempt; + } + return pending; +} + +/** Test hook: clear the slot, the loader override and any pending load. */ +export function resetMathRenderer(): void { + renderer = null; + rendererSource = null; + loader = defaultMathRendererLoader; + pending = null; + notify(); +} + +export const normalizeMathTex = (tex: string): string => tex.trim(); + +/** + * Render TeX with the pinned option set. `throwOnError: false` and + * `trust: false` are a deliberate security pin applied to EVERY renderer, + * including one a host registered: a registered module never widens what + * document-supplied TeX may do. Returns `null` while no renderer is + * registered so callers can fall back to the text placeholder. + */ +export function renderMathToHtml( + tex: string, + displayMode: boolean, + activeRenderer: MathRenderer | null = renderer, +): string | null { + if (!activeRenderer) return null; + return activeRenderer.renderToString(tex, { + displayMode, + throwOnError: false, + strict: 'warn', + trust: false, + output: 'html', + }); +} diff --git a/packages/ui/utils/mermaid-eager.ts b/packages/ui/utils/mermaid-eager.ts new file mode 100644 index 0000000000..e97b3f504c --- /dev/null +++ b/packages/ui/utils/mermaid-eager.ts @@ -0,0 +1,28 @@ +/** + * Eager Mermaid registration: imports the runtime statically, initializes it + * at module evaluation (exactly where the old module-scope + * `mermaid.initialize` ran) and fills the slot in `./mermaid`. + * + * `packages/editor/App.tsx` imports this module for its side effect, by + * policy: Plannotator's own surfaces keep Mermaid in their entry chunk so it + * can never fail separately from the app (on the share portal this is what + * keeps `mermaid.core` out of a lazy chunk). The review editor does not import + * it because it never renders a Mermaid block; adding the runtime there would + * grow that bundle. A host that wants the same import adds: + * + * import '@plannotator/ui/utils/mermaid-eager'; + * + * A host that does not import it gets the lazy path in `./mermaid`. + * + * The source tag passed below doubles as a build marker: the literal only + * reaches a bundle when this module is evaluated in it, so a dropped or + * tree-shaken side-effect import is caught by the built-HTML check in + * tests/entry-assets.test.ts (the runtime itself stays inlined in a + * single-file build through the loader's import(), so a Mermaid diagram id + * cannot prove registration). + */ +import mermaid from 'mermaid'; +import { MERMAID_CONFIG, setMermaidRuntime } from './mermaid'; + +mermaid.initialize(MERMAID_CONFIG); +setMermaidRuntime(mermaid, 'plannotator-mermaid-eager'); diff --git a/packages/ui/utils/mermaid.test.ts b/packages/ui/utils/mermaid.test.ts new file mode 100644 index 0000000000..e97007b573 --- /dev/null +++ b/packages/ui/utils/mermaid.test.ts @@ -0,0 +1,85 @@ +/** + * Mermaid runtime slot (utils/mermaid.ts), sibling of math.test.ts. + * + * What regresses if these fail: + * - a filled slot no longer short-circuits the loader, so Plannotator's eager + * entry (which initialized the runtime at module evaluation) would be + * followed by a second import and a second initialize; + * - the empty-slot path stops memoizing, so every diagram on a page imports + * the runtime again; + * - a rejected load stays memoized, so the block's re-attempt and the Retry + * button replay the cached rejection instead of issuing a fresh import. + * + * No DOM required. The runtime is a stand-in; the real mermaid never loads. + * Bun shares one process across files, so the slot is emptied before and + * after every test through the test hook. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { Mermaid } from 'mermaid'; +import { + __setMermaidRuntimeLoaderForTests, + getMermaidRuntime, + getMermaidRuntimeSource, + loadMermaidRuntime, + setMermaidRuntime, +} from './mermaid'; + +const fakeRuntime = { initialize() {}, render: async () => ({ svg: '' }) } as unknown as Mermaid; + +beforeEach(() => { + __setMermaidRuntimeLoaderForTests(undefined); +}); + +afterEach(() => { + __setMermaidRuntimeLoaderForTests(undefined); +}); + +describe('filled slot', () => { + test('resolves at once without calling the loader (single-initialize guarantee)', async () => { + let loads = 0; + __setMermaidRuntimeLoaderForTests(async () => { + loads += 1; + return fakeRuntime; + }); + setMermaidRuntime(fakeRuntime, 'plannotator-mermaid-eager'); + + expect(getMermaidRuntime()).toBe(fakeRuntime); + expect(getMermaidRuntimeSource()).toBe('plannotator-mermaid-eager'); + expect(await loadMermaidRuntime()).toBe(fakeRuntime); + expect(await loadMermaidRuntime()).toBe(fakeRuntime); + expect(loads).toBe(0); + }); +}); + +describe('empty slot', () => { + test('calls the loader once, memoizes, and fills the slot with source loader', async () => { + let loads = 0; + __setMermaidRuntimeLoaderForTests(async () => { + loads += 1; + return fakeRuntime; + }); + expect(getMermaidRuntime()).toBeNull(); + + const [a, b] = await Promise.all([loadMermaidRuntime(), loadMermaidRuntime()]); + expect(a).toBe(fakeRuntime); + expect(b).toBe(fakeRuntime); + expect(await loadMermaidRuntime()).toBe(fakeRuntime); + expect(loads).toBe(1); + expect(getMermaidRuntime()).toBe(fakeRuntime); + expect(getMermaidRuntimeSource()).toBe('loader'); + }); + + test('a rejected load is dropped so the next call re-attempts', async () => { + let attempts = 0; + __setMermaidRuntimeLoaderForTests(async () => { + attempts += 1; + if (attempts === 1) throw new Error('chunk failed'); + return fakeRuntime; + }); + + await expect(loadMermaidRuntime()).rejects.toThrow('chunk failed'); + expect(getMermaidRuntime()).toBeNull(); + expect(await loadMermaidRuntime()).toBe(fakeRuntime); + expect(attempts).toBe(2); + }); +}); diff --git a/packages/ui/utils/mermaid.ts b/packages/ui/utils/mermaid.ts new file mode 100644 index 0000000000..4a0ed9f5f4 --- /dev/null +++ b/packages/ui/utils/mermaid.ts @@ -0,0 +1,132 @@ +/** + * Mermaid runtime slot. + * + * ONE code path feeds `MermaidBlock`: `loadMermaidRuntime()`. It resolves at + * once from a filled slot and otherwise imports the runtime lazily. Plannotator + * fills the slot at module evaluation through `./mermaid-eager` (imported by + * `packages/editor/App.tsx`), which keeps the runtime in its entry chunk on the + * share portal exactly as it was with the static import, so it cannot fail + * separately from the app. A host that does not import the eager entry gets + * the lazy path: the runtime is fetched on the first diagram, a failed import + * is dropped from the memo so the next call issues a fresh `import()`, and + * the block re-attempts once and offers Retry. + * + * This module has NO static import of `mermaid`; the only place the + * dependency is named at runtime is the default loader's `import('mermaid')`. + */ +import type { Mermaid, MermaidConfig } from 'mermaid'; + +/** + * Hoisted verbatim from the former module-scope `mermaid.initialize(...)` in + * MermaidBlock. Nothing in it reads a CSS token or the resolved mode. + * `securityLevel: 'strict'` is a deliberate security pin (see MermaidBlock.test.ts). + */ +export const MERMAID_CONFIG: MermaidConfig = { + startOnLoad: false, + securityLevel: 'strict', + theme: 'dark', + themeVariables: { + primaryColor: '#3b82f6', + primaryTextColor: '#f8fafc', + primaryBorderColor: '#475569', + lineColor: '#64748b', + secondaryColor: '#1e293b', + tertiaryColor: '#0f172a', + background: '#1e293b', + mainBkg: '#1e293b', + nodeBorder: '#475569', + clusterBkg: '#1e293b', + clusterBorder: '#475569', + titleColor: '#f8fafc', + edgeLabelBackground: '#1e293b', + }, + flowchart: { + htmlLabels: true, + curve: 'basis', + }, +}; + +/** + * Who filled the slot. The eager value doubles as a build marker: the literal + * only reaches a bundle when `./mermaid-eager` is evaluated in it, which is + * what `tests/entry-assets.test.ts` asserts on the built HTML. + */ +export type MermaidRuntimeSource = 'plannotator-mermaid-eager' | 'loader' | 'host'; + +export type MermaidRuntimeLoader = () => Promise; + +/** Default lazy loader: import the runtime and initialize it once. */ +const defaultMermaidLoader: MermaidRuntimeLoader = () => + import('mermaid').then(({ default: mermaid }) => { + mermaid.initialize(MERMAID_CONFIG); + return mermaid; + }); + +let runtime: Mermaid | null = null; +let runtimeSource: MermaidRuntimeSource | null = null; +let loader: MermaidRuntimeLoader = defaultMermaidLoader; +let pending: Promise | null = null; + +/** + * Delay before the block's one automatic re-attempt after a failed lazy + * import. Only chunking hosts can fail here; a filled slot never loads. + */ +let retryDelayMs = 750; + +/** Current runtime, or `null` while the slot is empty. */ +export function getMermaidRuntime(): Mermaid | null { + return runtime; +} + +/** How the current runtime was registered, or `null` while the slot is empty. */ +export function getMermaidRuntimeSource(): MermaidRuntimeSource | null { + return runtimeSource; +} + +/** Register an already-initialized runtime (what `./mermaid-eager` does). */ +export function setMermaidRuntime(next: Mermaid, source: MermaidRuntimeSource = 'host'): void { + runtime = next; + runtimeSource = source; + pending = null; +} + +/** The block's retry delay for the lazy path. */ +export function getMermaidRetryDelayMs(): number { + return retryDelayMs; +} + +/** + * Resolve the runtime: at once from a filled slot, otherwise through the + * loader. A rejected load is dropped from the memo so the next call (the + * block's automatic re-attempt, a later mount, or the Retry button) issues a + * fresh `import()` instead of replaying the cached rejection. + */ +export function loadMermaidRuntime(): Promise { + if (runtime) return Promise.resolve(runtime); + if (!pending) { + const attempt = loader().then( + (loaded) => { + setMermaidRuntime(loaded, 'loader'); + return loaded; + }, + (err: unknown) => { + if (pending === attempt) pending = null; + throw err; + }, + ); + pending = attempt; + } + return pending; +} + +/** Test hook: empty the slot, stand in for the lazy import, shorten the retry delay. */ +export function __setMermaidRuntimeLoaderForTests( + next: MermaidRuntimeLoader | undefined, + options?: { retryDelayMs?: number }, +): void { + runtime = null; + runtimeSource = null; + pending = null; + loader = next ?? defaultMermaidLoader; + retryDelayMs = options?.retryDelayMs ?? 750; +} diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts index 5bf22cd81b..b48750d688 100644 --- a/tests/entry-assets.test.ts +++ b/tests/entry-assets.test.ts @@ -65,6 +65,85 @@ describe('review entry assets', () => { expect(highlighter).not.toMatch(/https?:\/\//); }); + // @plannotator/ui loads KaTeX and the username dictionary through slots + // (utils/math.ts, utils/generateIdentity.ts) so hosts that bundle by route + // can leave them out of a document read. Plannotator's parity rests on two + // side-effect imports per app entry: without them, plans with math would + // paint TeX for a frame in every runtime and identities would come from the + // 16-word fallback pool, with no error anywhere. Both apps must carry both. + test.each(['packages/editor/App.tsx', 'packages/review-editor/App.tsx'])( + '%s registers the eager math renderer and identity dictionary', + (path) => { + const source = read(path); + expect(source).toContain("import '@plannotator/ui/utils/math-eager';"); + expect(source).toContain("import '@plannotator/ui/utils/identity-tater';"); + }, + ); + + // Mermaid is eager in the plan editor by policy (the portal entry chunk must + // keep it, as on main) and deliberately absent from the review editor, which + // never renders a Mermaid block: importing it there would grow that bundle. + test('only the plan editor registers the eager Mermaid runtime', () => { + expect(read('packages/editor/App.tsx')).toContain("import '@plannotator/ui/utils/mermaid-eager';"); + expect(read('packages/review-editor/App.tsx')).not.toContain('mermaid-eager'); + }); + + // The other half of the optimization: the renderers must stay OFF the + // static import graph of the components, otherwise a host's bundler puts + // them back into every document read and the slots become decoration. + test('renderer runtimes are not statically imported by the components', () => { + const staticImport = (spec: string) => new RegExp(`^import\\s+(?!type\\b)[^;]*from\\s+['"]${spec}['"]`, 'm'); + expect(read('packages/ui/components/blocks/MathBlock.tsx')).not.toMatch(staticImport('katex')); + expect(read('packages/ui/components/InlineMarkdown.tsx')).not.toMatch(staticImport('katex')); + expect(read('packages/ui/utils/math.ts')).not.toMatch(staticImport('katex')); + expect(read('packages/ui/utils/math.ts')).toContain("import('katex')"); + expect(read('packages/ui/components/MermaidBlock.tsx')).not.toMatch(staticImport('mermaid')); + expect(read('packages/ui/utils/mermaid.ts')).not.toMatch(staticImport('mermaid')); + expect(read('packages/ui/utils/mermaid.ts')).toContain("import('mermaid')"); + expect(read('packages/ui/components/GraphvizBlock.tsx')).not.toMatch(staticImport('@viz-js/viz')); + expect(read('packages/ui/components/GraphvizBlock.tsx')).toContain("import('@viz-js/viz')"); + expect(read('packages/ui/utils/generateIdentity.ts')).not.toMatch(staticImport('unique-username-generator')); + }); + + // Built-artifact check: a lost eager import would still type-check and pass + // every unit test, so the built single-file bundles are read directly. + // Two different kinds of marker, deliberately: + // + // - Registration markers, which only reach a bundle when the eager module is + // evaluated in it: the source tags `math-eager` and `mermaid-eager` pass + // to their slots, and the dictionary's exported function name (the + // dictionary is imported ONLY by identity-tater). These are the guards for + // a dropped or tree-shaken side-effect import (a future + // `"sideEffects": false` would let Vite discard `import '.../math-eager'`, + // the slot would stay empty and every runtime would paint TeX for a frame; + // a dropped mermaid-eager would move Mermaid into a lazy portal chunk that + // can fail separately). Proven by removing each import and rebuilding: the + // registration marker count drops to zero while the presence markers stay. + // The review bundle must NOT carry the Mermaid marker: it never renders a + // Mermaid block and main's review bundle has no Mermaid in it. + // - Presence markers (a KaTeX class name, a Mermaid diagram id, an + // Emscripten symbol from Graphviz, the bridge global), which only say the + // runtime is still inlined by inlineDynamicImports. KaTeX is inlined + // through utils/math.ts's import('katex') whether or not it is registered, + // so `katex-display` cannot prove registration and is not asked to. + // + // dist/ is gitignored, so this is skipped on an unbuilt checkout; the CI job + // that builds the bundles runs it right after. + const REGISTRATION_MARKERS = ['plannotator-math-eager', 'uniqueUsernameGenerator']; + const markerExpectations: Array<[bundle: string, present: string[], absent: string[]]> = [ + ['apps/hook/dist/index.html', [...REGISTRATION_MARKERS, 'plannotator-mermaid-eager', 'katex-display', 'flowchart-v2', 'viz_set_y_invert', '__plannotatorLiveConfig'], []], + ['apps/review/dist/index.html', [...REGISTRATION_MARKERS, 'katex-display', '__plannotatorLiveConfig'], ['plannotator-mermaid-eager', 'flowchart-v2']], + ]; + for (const [path, present, absent] of markerExpectations) { + test.skipIf(!existsSync(resolve(root, path)))(`${path} carries the eager registration and renderer markers`, () => { + const html = readFileSync(resolve(root, path), 'utf8'); + // Asserted per marker on a boolean so a failure never prints the 20MB bundle. + const missing = present.filter((marker) => !html.includes(marker)); + const unexpected = absent.filter((marker) => html.includes(marker)); + expect({ path, missing, unexpected }).toEqual({ path, missing: [], unexpected: [] }); + }); + } + test('nothing depends on highlight.js any more', () => { for (const manifest of ['packages/ui/package.json', 'packages/review-editor/package.json']) { expect(read(manifest)).not.toContain('highlight.js'); @@ -91,14 +170,14 @@ describe('review entry assets', () => { // job that builds the bundles runs this file right after the build so the // assertion is not silently optional there. const bundles = ['apps/review/dist/index.html', 'apps/hook/dist/index.html']; - test.each(bundles)('%s ships no inlined WebAssembly (skipped if unbuilt)', (path) => { - const full = resolve(root, path); - if (!existsSync(full)) return; - // Asserted on a boolean, not the string: these bundles are ~20MB and a - // `toContain` failure would print all of it. - const inlinedWasm = readFileSync(full, 'utf8').includes('AGFzbQ'); - expect({ path, inlinedWasm }).toEqual({ path, inlinedWasm: false }); - }); + for (const path of bundles) { + test.skipIf(!existsSync(resolve(root, path)))(`${path} ships no inlined WebAssembly`, () => { + // Asserted on a boolean, not the string: these bundles are ~20MB and a + // `toContain` failure would print all of it. + const inlinedWasm = readFileSync(resolve(root, path), 'utf8').includes('AGFzbQ'); + expect({ path, inlinedWasm }).toEqual({ path, inlinedWasm: false }); + }); + } }); describe('marketing embeds', () => {