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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 41 additions & 8 deletions packages/ui/HANDOFF.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Building your own tooltip and removing the built-in double-click reset are host-

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). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias.
- **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). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
- **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`.

Expand Down Expand Up @@ -122,7 +122,7 @@ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-scrip
<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
```

The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's `./bridge-script` resolution to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The package owns that banner by default (`bridgeErrorDisplay="banner"`); a host that renders its own notice from `onBridgeUnavailable` passes `bridgeErrorDisplay="none"` (0.34.0) and no strip is rendered, while the callback and the console warning are unchanged. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's relative `./bridge-script` import (match `/^\.\/bridge-script$/`, never a bare `/\/bridge-script$/`, which would also catch another package's `bridge-script` entry) to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").

#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`

Expand Down Expand Up @@ -164,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
- Versioned together (currently `@plannotator/ui` 0.33.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 is such a release, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
- Versioned together (currently `@plannotator/ui` 0.34.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 and 0.34.0 are such releases, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`**, the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").

## The one rule

Expand Down
71 changes: 71 additions & 0 deletions packages/ui/components/DiagramBlock.lazyRetry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import { createRoot, type Root } from 'react-dom/client';
import type { Block } from '../types';
import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock';
import { GraphvizBlock, __setVizLoaderForTests } from './GraphvizBlock';
import {
getMathRenderer,
getMathRendererSource,
resetMathRenderer,
setMathRenderer,
setMathRendererLoader,
type MathRenderer,
} from '../utils/math';

const hasDom = typeof document !== 'undefined';
const RETRY_DELAY_MS = 10;
Expand Down Expand Up @@ -151,3 +159,66 @@ describe.each(cases)('$name lazy runtime', ({ install, runtime, element, source,
expect(el.querySelector('button[title="Retry loading the diagram renderer"]')).toBeNull();
});
});

/**
* A host that redirects Mermaid's `katex` import to `utils/mermaid-math-slot`
* gets `$$` labels typeset through the math slot, which is only useful if the
* slot is filled by the time Mermaid renders. What regresses: the block stops
* awaiting the registered loader before a math diagram (labels throw on an
* empty slot), or starts loading KaTeX for diagrams with no math at all.
*/
describe('Mermaid math labels warm the math slot', () => {
const savedRenderer = getMathRenderer();
const savedSource = getMathRendererSource();
const hostRenderer: MathRenderer = { renderToString: (tex) => tex };

afterEach(() => {
resetMathRenderer();
setMathRendererLoader(null);
if (savedRenderer) setMathRenderer(savedRenderer, savedSource ?? 'host');
});

test.skipIf(!hasDom)('a diagram with a $$ label awaits the registered loader before rendering', async () => {
resetMathRenderer();
let loads = 0;
setMathRendererLoader(async () => {
loads += 1;
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
return hostRenderer;
});
let slotAtRender: MathRenderer | null | undefined;
const runtime = {
initialize() {},
render: async () => {
slotAtRender = getMathRenderer();
return { svg: SVG };
},
} as never;
__setMermaidRuntimeLoaderForTests(() => Promise.resolve(runtime), { retryDelayMs: RETRY_DELAY_MS });

const mathBlock: Block = { ...mermaidBlock, id: 'm-math', content: 'flowchart LR\n A["$$\\sqrt{2}$$"] --> B' };
const el = await mount(<MermaidBlock block={mathBlock} />);
await settle(RETRY_DELAY_MS * 5);

expect(loads).toBe(1);
expect(slotAtRender).toBe(hostRenderer);
expect(el.innerHTML).toContain('data-sentinel="diagram"');
});

test.skipIf(!hasDom)('a diagram without math never invokes the math loader', async () => {
resetMathRenderer();
let loads = 0;
setMathRendererLoader(async () => {
loads += 1;
return hostRenderer;
});
__setMermaidRuntimeLoaderForTests(() => Promise.resolve(fakeMermaid), { retryDelayMs: RETRY_DELAY_MS });

const el = await mount(<MermaidBlock block={mermaidBlock} />);
await settle(RETRY_DELAY_MS * 5);

expect(loads).toBe(0);
expect(getMathRenderer()).toBeNull();
expect(el.innerHTML).toContain('data-sentinel="diagram"');
});
});
16 changes: 16 additions & 0 deletions packages/ui/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
loadMermaidRuntime,
__setMermaidRuntimeLoaderForTests,
} from '../utils/mermaid';
import { loadMathRenderer } from '../utils/math';
import { hasMermaidMath } from '../utils/mermaid-math-slot';

// Re-exported: the config pin test and the lazy-retry test import them from here.
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
Expand Down Expand Up @@ -208,6 +210,20 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
return;
}
try {
// A `$$` label makes Mermaid render KaTeX. On a host that redirects
// Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
// typeset through the math slot, which must be filled by then: warm
// it with the registered loader first. A filled slot (Plannotator's
// eager entry) resolves at once; a load failure is left to the
// render, whose error panel names it with the source.
if (hasMermaidMath(block.content)) {
try {
await loadMathRenderer();
} catch {
// Reported by the render below.
}
if (cancelled) return;
}
const id = `mermaid-${block.id}`;
const { svg: renderedSvg } = await mermaid.render(id, block.content);
if (!cancelled) {
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/components/html-viewer/HtmlViewer.bridgeAsset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type MountProps = {
bridgeScriptUrl?: string;
bridgeReadyTimeoutMs?: number;
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
bridgeErrorDisplay?: 'banner' | 'none';
rawHtml?: string;
};

Expand Down Expand Up @@ -70,6 +71,7 @@ async function mount(props: MountProps) {
bridgeScriptUrl={next.bridgeScriptUrl}
bridgeReadyTimeoutMs={next.bridgeReadyTimeoutMs}
onBridgeUnavailable={next.onBridgeUnavailable}
bridgeErrorDisplay={next.bridgeErrorDisplay}
/>,
);
});
Expand Down Expand Up @@ -240,6 +242,47 @@ describe.if(hasDom)('HtmlViewer bridgeScriptUrl', () => {
expect(timedOut.host.querySelector('[data-bridge-error-dismiss]')).toBeNull();
});

// 0.34.0: a host that renders its own notice from onBridgeUnavailable
// could not suppress the package strip, so both showed. What regresses:
// 'none' still renders a strip (double banner), or 'none' also mutes the
// callback or the version-mismatch console warning the host relies on.
test("bridgeErrorDisplay='none' renders no strip while the callback and the mismatch warning still fire", async () => {
const warnings = captureWarnings();
const unavailable: BridgeUnavailableInfo[] = [];
const { postReady, banner, host } = await mount({
bridgeScriptUrl: ASSET_URL,
bridgeErrorDisplay: 'none',
onBridgeUnavailable: (info) => unavailable.push(info),
});
await postReady({ type: 'plannotator-bridge-ready' });
expect(banner()).toBeNull();
expect(host.querySelector('[data-bridge-error-dismiss]')).toBeNull();
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(`expects ${BRIDGE_PROTOCOL_VERSION}`);
expect(unavailable.map((info) => info.kind)).toEqual(['version-mismatch']);

const timedOut = await mount({
bridgeScriptUrl: ASSET_URL,
bridgeErrorDisplay: 'none',
bridgeReadyTimeoutMs: 20,
onBridgeUnavailable: (info) => unavailable.push(info),
});
await act(async () => { await wait(70); });
expect(timedOut.banner()).toBeNull();
expect(unavailable.map((info) => info.kind)).toEqual(['version-mismatch', 'timeout']);
});

test("bridgeErrorDisplay='banner' is the default and renders the strip as before", async () => {
captureWarnings();
const explicit = await mount({ bridgeScriptUrl: ASSET_URL, bridgeErrorDisplay: 'banner' });
await explicit.postReady({ type: 'plannotator-bridge-ready' });
expect(explicit.banner()?.getAttribute('data-bridge-error')).toBe('version-mismatch');

const implicit = await mount({ bridgeScriptUrl: ASSET_URL });
await implicit.postReady({ type: 'plannotator-bridge-ready' });
expect(implicit.banner()?.getAttribute('data-bridge-error')).toBe('version-mismatch');
});

test('inline path: no timer, no banner, no callback; a stamp-less ready only warns', async () => {
const warnings = captureWarnings();
const unavailable: BridgeUnavailableInfo[] = [];
Expand Down
21 changes: 17 additions & 4 deletions packages/ui/components/html-viewer/HtmlViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,19 @@ export interface HtmlViewerProps {
bridgeReadyTimeoutMs?: number;
/** The bridge could not be established on the `bridgeScriptUrl` path (no
* ready within the timeout, or a protocol version mismatch). The surface
* shows its own banner as well; this lets the host react (telemetry, a
* retry affordance). Never called on the inline path. */
* shows its own banner as well unless `bridgeErrorDisplay` is `'none'`;
* this lets the host react (telemetry, a retry affordance). Never called
* on the inline path. */
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
/**
* Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
* `'banner'` (default): the package renders its `[data-bridge-error]`
* strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
* the host owns the display through `onBridgeUnavailable`, which fires
* exactly as before (and a version mismatch still logs its one console
* warning). Meaningless on the inline path, which never shows a strip.
*/
bridgeErrorDisplay?: "banner" | "none";
}

/**
Expand Down Expand Up @@ -329,6 +339,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
bridgeScriptUrl,
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
onBridgeUnavailable,
bridgeErrorDisplay = "banner",
},
ref,
) => {
Expand Down Expand Up @@ -1061,8 +1072,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
{/* bridgeScriptUrl path only: the bridge did not come up (no
ready within the timeout, or a stale asset's version). Floated
over the top of the iframe so it never changes the layout the
page renders in; the page itself stays visible. */}
{bridgeError && !bridgeErrorDismissed && (
page renders in; the page itself stays visible. A host that
renders its own notice from onBridgeUnavailable passes
bridgeErrorDisplay="none" and no strip is rendered at all. */}
{bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
<div
role="alert"
data-print-hide
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@plannotator/ui",
"version": "0.33.0",
"version": "0.34.0",
"type": "module",
"exports": {
"./components/*": "./components/*.tsx",
Expand Down
Loading